mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 18:02:18 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66fe29b024 | ||
|
|
85017b2c54 | ||
|
|
4fb5eeb5a7 | ||
|
|
3bbc502481 | ||
|
|
0037d4f0ba | ||
|
|
0d19314106 | ||
|
|
e2348280c1 |
No files matched your search
Generated
+185
-2
@@ -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.3-preview.2"
|
||||
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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.4.3-preview.2"
|
||||
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"
|
||||
|
||||
@@ -7,13 +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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -22,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
|
||||
```
|
||||
|
||||
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. 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.
|
||||
|
||||
|
||||
+17
-3
@@ -7,13 +7,25 @@
|
||||
|
||||
[English](README.md)
|
||||
|
||||
> Framely 目前仍在快速迭代中,Preview 版本可能频繁更新,功能与交互仍在持续完善。
|
||||
|
||||
Steam Frame 的 React 插件管理器:Rust 核心服务、独立 CEF/OpenVR 宿主、网络管理面板和电脑端安装器。可管理插件、来源、窗口、通知和更新。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 安装
|
||||
|
||||
目标为 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
|
||||
@@ -22,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 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 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`。未配置密码时首次访问必须设置并确认密码,保存后登录。设备密码与面板密码相互独立。
|
||||
|
||||
|
||||
@@ -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.
|
||||
÷÷
|
||||
@@ -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/.
|
||||
@@ -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.
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -150,3 +150,7 @@ Standalone plugin windows display the plugin page without a host title bar. Plug
|
||||
## 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 |
+20
-4
@@ -2,16 +2,32 @@
|
||||
|
||||
[简体中文](zh-CN/launcher.md)
|
||||
|
||||
Point at the Framely Dock icon: use the upper trigger for the launcher and the primary trigger for the quick panel. **Settings → Launcher → Open launcher with the primary trigger** swaps these shortcuts immediately. Other controllers keep the ordinary click entry.
|
||||
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 meters wide. Steam places and curves it and switches from the current Dock panel when it opens. It closes when the Dock is hidden. UI drawing follows the headset refresh rate within a 60–144 FPS budget, with a 90 FPS fallback when the rate is unavailable. This is a drawing limit, not a guaranteed measured frame rate. The renderer retains the high-resolution texture and uploads only changed regions. 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. Use the side category selector for All, Plugins, Steam, Lepton or Desktop apps, the bottom dots to change pages, and the star on hover to favorite apps. 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.
|
||||
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 **Settings → Launcher**. Uninstall requires confirmation and keeps plugin data.
|
||||
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. A manually arranged order takes precedence over favorite sorting; new apps are appended.
|
||||
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`.
|
||||
@@ -56,3 +56,9 @@ sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -157,3 +157,7 @@ const off = framely.ui.onVisibilityChanged(state => {
|
||||
## 启动台操作与本体兼容
|
||||
|
||||
[启动台操作与本体兼容](launcher.md)
|
||||
|
||||
### 本体页面滚动
|
||||
|
||||
本体桥接脚本会为现有插件页面和新版 SDK 页面安装主扳机纵向拖动滚动。它选择指针下可滚动的祖先区域,并在边界继续滚动外层。输入框、文本域、可编辑内容和滑块保留自身交互。若插件需要自行处理纵向拖拽,请在对应元素或其祖先上设置 `data-framely-no-scroll-drag`;原生 `draggable="true"` 元素也会排除。无需新增清单字段或重新打包插件。
|
||||
+22
-4
@@ -2,16 +2,34 @@
|
||||
|
||||
[English](../launcher.md)
|
||||
|
||||
激光指向 Framely Dock 图标:上扳机打开启动台,主扳机打开快捷面板。在 **设置 → 启动台 → 主扳机打开启动台** 开启开关,两个入口立即互换。其他手柄保留普通点击入口。
|
||||
激光指向 Framely Dock 图标:默认主扳机打开启动台,上扳机打开快捷面板。在 **管理面板 → 启动台 → 主扳机打开启动台** 开关默认开启,关闭后两个入口立即互换。已保存的扳机偏好会继续生效。其他手柄保留普通点击入口。
|
||||
|
||||
启动台是 Dock 内宽 3 米的原生面板,由 Steam 管理位置和曲面;打开时通过 Dock 的面板切换机制替代当前面板,Dock 隐藏时关闭。界面绘制上限跟随头显刷新率,在 60–144 FPS 范围内调整;读取不到刷新率时回退到 90 FPS。这是绘制上限,并不保证实测达到该帧率。渲染保留高分辨率纹理,只上传发生变化的区域。图标区域始终保留六列三行的空间,不随应用数量变化;每页最多 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 本机各库已安装应用,不包含仅在账号库或串流电脑中的应用。
|
||||
启动台是 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 秒)。卸载需要确认,并保留插件数据。
|
||||
按下后的激光落点允许在 36 个界面像素内抖动,点击区域大于图标本身;明显拖动会取消落点稳定处理。主扳机按住插件图标 400 毫秒展开操作菜单,普通抖动不会取消长按,包含开发者按钮和“快捷面板、管理插件、卸载插件”。按 **B** 或点击空白区域关闭启动台;处于图标整理状态时,第一次操作先退出整理,启动台继续显示。按住主扳机横向拖动时,当前页随手移动,相邻页同步滑入;松手后吸附到相邻页,拖动距离不足或到达边界则回到当前页。操作菜单默认在指针离开 10 秒后关闭,重新移入取消计时。在 **管理面板 → 启动台** 调整自动关闭开关与时长(1–300 秒)。卸载需要确认,并保留插件数据。
|
||||
|
||||
继续按住到 **800 毫秒**进入图标整理。拖动任意图标到其他位置,其他图标按顺序移动并自动补齐 4/5/4 槽位,不留空洞。Framely 本身也在启动台中,点击打开管理面板,并可一起排列。向左拖动的翻页边缘位于分类器右侧,分类器本身不会触发翻页。拖到图标区域左右边缘停留 650 毫秒自动翻页,继续停留可跨更多页面。放手保存排列,点击“完成”退出整理。关闭启动台或隐藏 Dock 会取消尚未完成的拖动。手动排列优先于收藏排序,新应用追加在后面。
|
||||
继续按住到 **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`,同时包含于本体更新包和完整离线包。
|
||||
|
||||
已收藏应用的右上角显示常驻的蓝色实心星标,再次点击即可取消收藏;收藏状态变化不会改变图标排序。
|
||||
@@ -60,3 +60,9 @@ sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs
|
||||
```
|
||||
|
||||
输出路径必须是新文件。本体程序无法运行时,解压发行包中的 `tools/export-diagnostics.py` 也可独立使用:`sudo python3 tools/export-diagnostics.py --output /tmp/framely-logs.zip`。
|
||||
|
||||
## VR 页面滚动
|
||||
|
||||
激光指向页面后,拨动任意手柄的摇杆即可滚动,无需先点击或聚焦页面。滚动作用于激光下的元素,也支持插件页面的内层滚动区域。键盘打开时暂停页面摇杆滚动;启动台仍使用原有的摇杆分类与翻页操作。
|
||||
|
||||
按住主扳机并上下移动激光,可拖动可滚动的页面内容。小幅抖动仍视为普通点击;纵向拖动开始后,松开不会触发原先按下的按钮。文本输入、滑块及明确声明的自定义拖拽区域保留自身交互。启动台保留图标排列和横向拖动翻页操作。
|
||||
+60
-21
@@ -10,6 +10,7 @@
|
||||
#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"
|
||||
@@ -50,11 +51,15 @@ 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::atomic<bool> launcher_primary{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;
|
||||
@@ -83,6 +88,7 @@ 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;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;
|
||||
};
|
||||
@@ -164,7 +170,8 @@ static void show(View& v,bool shown){
|
||||
if(v.key=="menu"||v.key=="notifications")update_menu_modal();
|
||||
}
|
||||
}
|
||||
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")update_menu_modal();if(keyboard_frame){keyboard_frame->base.release(&keyboard_frame->base);keyboard_frame=nullptr;}keyboard_view.clear();}
|
||||
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){
|
||||
@@ -175,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;
|
||||
@@ -203,7 +210,9 @@ static void anchor_notification(View& v){
|
||||
}
|
||||
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&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="notifications"&&key!="launcher")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"||key=="launcher")?4:(key=="menu"||key=="notifications")?2:1;
|
||||
// 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,0);
|
||||
|
||||
@@ -217,8 +226,8 @@ static View& create_view(const std::string& key,const std::string& title,const s
|
||||
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:key=="launcher"?3.f: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(vr_system->GetFloatTrackedDeviceProperty(vr::k_unTrackedDeviceIndex_Hmd,vr::Prop_DisplayFrequency_Float));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(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){
|
||||
@@ -236,12 +245,28 @@ static void plugin_thumbnail(View& view,const std::string& encoded){
|
||||
overlays->SetOverlayRaw(view.thumbnail,pixels.data(),128,128,4);
|
||||
}
|
||||
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 hide_launcher(){
|
||||
launcher_open=false;launcher_selected=false;
|
||||
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);
|
||||
@@ -254,9 +279,9 @@ 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,1200,800);
|
||||
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_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={};
|
||||
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());}
|
||||
@@ -272,11 +297,23 @@ static void setup_entry_actions(){
|
||||
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;
|
||||
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;
|
||||
@@ -315,25 +352,25 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
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_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
|
||||
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)continue;
|
||||
if(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(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,¤t)&&models->GetComponentState(model,"thumbstick",&neutral,&mode,&rest)&&models->GetComponentState(model,"thumbstick",&sx,&mode,&full_x)&&models->GetComponentState(model,"thumbstick",&sy,&mode,&full_y)&&launcher_stick_axes(rest,full_x,full_y,current,x,y);}
|
||||
// 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;}
|
||||
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(stick_x,stick_y,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);}}
|
||||
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={};}
|
||||
if(launcher_open&&launcher_outside_at.time_since_epoch().count()&&now>=launcher_outside_at){launcher_outside_at={};launcher_back();}
|
||||
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=="updates"||page=="notification-settings"||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(page=="catalog"||page=="updates"||page=="notification-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");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 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);switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open)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){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: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"&&v.key!="launcher")close_view(v);break;default:break;}}
|
||||
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];}
|
||||
@@ -361,7 +398,7 @@ int main(int argc,char** argv){
|
||||
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);notification_badge_count.store(data.value("notificationBadge",0u));launcher_primary.store(data.value("launcher",json::object()).value("primaryTrigger",false));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));}});
|
||||
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;
|
||||
@@ -379,13 +416,14 @@ int main(int argc,char** argv){
|
||||
if(!main_overlay)main_overlay=active_main_window();
|
||||
if(main_overlay&&!overlays->GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose;
|
||||
if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);}
|
||||
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;launcher_open=false;for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher")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&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(1)))launcher_open=false;
|
||||
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))){
|
||||
@@ -400,6 +438,7 @@ int main(int argc,char** argv){
|
||||
}
|
||||
}
|
||||
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);}}
|
||||
|
||||
@@ -82,3 +82,16 @@ inline bool launcher_stick_axes(const vr::RenderModel_ComponentState_t& rest,con
|
||||
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;}
|
||||
};
|
||||
@@ -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;}
|
||||
};
|
||||
@@ -1,7 +1,3 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
inline int browser_frame_rate(float refresh){
|
||||
// Preserve smooth UI on an unknown display, with a bounded update budget.
|
||||
return std::isfinite(refresh)&&refresh>0?std::clamp(int(std::round(std::clamp(refresh,60.f,144.f))),60,144):90;
|
||||
}
|
||||
// Cap CEF painting independently of headset refresh to limit pixel readback.
|
||||
inline constexpr int browser_frame_rate(float = 0){return 60;}
|
||||
@@ -146,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
|
||||
|
||||
@@ -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();});
|
||||
}
|
||||
@@ -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=`
|
||||
|
||||
+49
-30
@@ -313,17 +313,11 @@ pub fn launch(home: &Path, id: &str) -> Result<()> {
|
||||
fn launch_target(target: Target) -> Result<()> {
|
||||
match target {
|
||||
Target::Desktop(path) => {
|
||||
let output = crate::process::tool("gio")
|
||||
.arg("launch")
|
||||
.arg(path)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.context("Cannot launch desktop application")?;
|
||||
ensure!(
|
||||
output.status.success(),
|
||||
"Desktop launch failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
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,
|
||||
@@ -332,18 +326,19 @@ fn launch_target(target: Target) -> Result<()> {
|
||||
} => {
|
||||
// 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::tool("podman")
|
||||
.args([
|
||||
let running = crate::process::command_output_timeout(
|
||||
crate::process::tool("podman").args([
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{.State.Running}}",
|
||||
&format!("lepton-{context}"),
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.is_ok_and(|o| {
|
||||
o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true"
|
||||
});
|
||||
]),
|
||||
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 {
|
||||
@@ -357,11 +352,16 @@ fn launch_target(target: Target) -> Result<()> {
|
||||
.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::tool("podman")
|
||||
.args([
|
||||
let output = crate::process::command_output_timeout(
|
||||
crate::process::tool("podman").args([
|
||||
"exec",
|
||||
&format!("lepton-{context}"),
|
||||
"monkey",
|
||||
@@ -370,20 +370,19 @@ fn launch_target(target: Target) -> Result<()> {
|
||||
"-c",
|
||||
"android.intent.category.LAUNCHER",
|
||||
"1",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.output();
|
||||
if output.is_ok_and(|o| o.status.success()) {
|
||||
]),
|
||||
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));
|
||||
}
|
||||
if let Some(mut child) = child {
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
ensure!(
|
||||
ready,
|
||||
"Lepton application did not become ready; retry after the context starts"
|
||||
@@ -469,6 +468,26 @@ mod tests {
|
||||
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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod diagnostics;
|
||||
mod http;
|
||||
mod ipc;
|
||||
mod jobs;
|
||||
mod launcher_search;
|
||||
mod localization;
|
||||
mod model;
|
||||
mod package;
|
||||
|
||||
+21
-2
@@ -179,12 +179,20 @@ pub struct LauncherAction {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct LauncherSettings {
|
||||
#[serde(default)]
|
||||
#[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
|
||||
@@ -192,14 +200,23 @@ fn menu_timeout() -> u32 {
|
||||
impl Default for LauncherSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
primary_trigger: false,
|
||||
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"
|
||||
@@ -773,6 +790,8 @@ pub struct Database {
|
||||
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)]
|
||||
|
||||
+131
-10
@@ -36,18 +36,100 @@ pub(crate) fn tool(name: &str) -> Command {
|
||||
}
|
||||
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"));
|
||||
}
|
||||
}
|
||||
+48
-1
@@ -85,6 +85,10 @@ impl Service {
|
||||
self.notifications.retain(|_, n| n.plugin != *id);
|
||||
self.save_notifications()?;
|
||||
}
|
||||
// Safe mode is an uninstall guard, not the default for a later install.
|
||||
// A failed hook exits above and leaves the guard persisted.
|
||||
self.db.safe_mode = false;
|
||||
self.save()?;
|
||||
Ok(json!({"uninstalled": ids}))
|
||||
}
|
||||
pub fn load(root: &Path, manager: u32) -> Result<Self> {
|
||||
@@ -1012,6 +1016,48 @@ impl Service {
|
||||
self.runtime.remove(&id);
|
||||
Ok(json!(true))
|
||||
}
|
||||
"launcher.favorite" => {
|
||||
let key = p["key"].as_str().context("Missing launcher entry")?;
|
||||
let favorite = p["favorite"].as_bool().context("Missing favorite")?;
|
||||
if let Some(id) = key.strip_prefix("plugin:") {
|
||||
self.db
|
||||
.plugins
|
||||
.get_mut(id)
|
||||
.context("Unknown plugin")?
|
||||
.favorite = favorite;
|
||||
} else if let Some(id) = key.strip_prefix("steam:") {
|
||||
let app: u32 = id.parse().context("Invalid Steam ID")?;
|
||||
ensure!(app > 0, "Invalid Steam ID");
|
||||
if favorite {
|
||||
self.db.steam_favorites.insert(app);
|
||||
} else {
|
||||
self.db.steam_favorites.remove(&app);
|
||||
}
|
||||
} else {
|
||||
ensure!(
|
||||
key.len() <= 512
|
||||
&& !key.chars().any(char::is_control)
|
||||
&& (key == "framely"
|
||||
|| ["desktop:", "lepton:"]
|
||||
.iter()
|
||||
.any(|prefix| key.starts_with(prefix)
|
||||
&& key.len() > prefix.len())),
|
||||
"Invalid launcher entry"
|
||||
);
|
||||
if favorite {
|
||||
ensure!(
|
||||
self.db.launcher_favorites.len() < 4096
|
||||
|| self.db.launcher_favorites.contains(key),
|
||||
"Too many favorites"
|
||||
);
|
||||
self.db.launcher_favorites.insert(key.into());
|
||||
} else {
|
||||
self.db.launcher_favorites.remove(key);
|
||||
}
|
||||
}
|
||||
self.save()?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
"launcher.settings.save" => {
|
||||
let settings: LauncherSettings = serde_json::from_value(p)?;
|
||||
settings.validate()?;
|
||||
@@ -2228,7 +2274,8 @@ impl Service {
|
||||
let roots: Vec<_> = impacted
|
||||
.iter()
|
||||
.filter(|id| {
|
||||
self.db.plugins.get(*id).is_some_and(|p| p.enabled)
|
||||
!self.db.safe_mode
|
||||
&& self.db.plugins.get(*id).is_some_and(|p| p.enabled)
|
||||
&& (running.contains(*id) || plugin_autostart(&self.db, id))
|
||||
})
|
||||
.cloned()
|
||||
|
||||
+133
-3
@@ -31,6 +31,7 @@ struct Agent {
|
||||
commands: Mutex<VecDeque<Value>>,
|
||||
events: Mutex<EventLog>,
|
||||
jobs: crate::jobs::Jobs,
|
||||
launcher_search: Mutex<crate::launcher_search::SearchCache>,
|
||||
catalog_cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
@@ -617,6 +618,33 @@ impl Agent {
|
||||
);
|
||||
}
|
||||
match method {
|
||||
"launcher.search.index" => {
|
||||
let names: Vec<String> = serde_json::from_value(p["names"].clone())?;
|
||||
ensure!(
|
||||
names.len() <= 4096
|
||||
&& names.iter().map(String::len).sum::<usize>() <= 262144
|
||||
&& names
|
||||
.iter()
|
||||
.all(|n| n.len() <= 1024 && !n.chars().any(char::is_control)),
|
||||
"Invalid launcher search names"
|
||||
);
|
||||
let parent = self
|
||||
.assets
|
||||
.parent()
|
||||
.context("Missing search asset directory")?;
|
||||
let packaged = parent.join("search/ipadic.dic.zst");
|
||||
let path = if packaged.is_file() {
|
||||
packaged
|
||||
} else {
|
||||
// Source checkout: --assets ui/dist, with data in assets/search.
|
||||
parent
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.join("assets/search/ipadic.dic.zst")
|
||||
};
|
||||
// HTTP requests run on workers, so dictionary loading never blocks native input.
|
||||
self.launcher_search.lock().unwrap().batch(&path, &names)
|
||||
}
|
||||
"desktop.list" => Ok(json!(crate::desktop::discover(&crate::steam::home()?))),
|
||||
"desktop.launch" => {
|
||||
let id = p["app"].as_str().context("Missing desktop app")?;
|
||||
@@ -770,7 +798,13 @@ impl Agent {
|
||||
if method == "host.manager.open"
|
||||
&& matches!(
|
||||
p["page"].as_str(),
|
||||
Some("catalog" | "updates" | "notification-settings")
|
||||
Some(
|
||||
"catalog"
|
||||
| "settings"
|
||||
| "updates"
|
||||
| "notification-settings"
|
||||
| "launcher-settings"
|
||||
)
|
||||
)
|
||||
{
|
||||
command["page"] = p["page"].clone();
|
||||
@@ -903,6 +937,7 @@ impl Agent {
|
||||
| "plugin.launcher.action"
|
||||
| "launcher.settings.save"
|
||||
| "launcher.order.save"
|
||||
| "launcher.favorite"
|
||||
| "steam.favorite"
|
||||
| "window.open"
|
||||
| "window.close"
|
||||
@@ -1212,6 +1247,7 @@ pub fn serve(
|
||||
commands: Mutex::default(),
|
||||
events: Mutex::default(),
|
||||
jobs: crate::jobs::Jobs::default(),
|
||||
launcher_search: Mutex::default(),
|
||||
catalog_cache: Arc::default(),
|
||||
});
|
||||
let network_agent = agent.clone();
|
||||
@@ -1596,6 +1632,7 @@ mod tests {
|
||||
commands: Mutex::default(),
|
||||
events: Mutex::default(),
|
||||
jobs: crate::jobs::Jobs::default(),
|
||||
launcher_search: Mutex::default(),
|
||||
catalog_cache: Arc::default(),
|
||||
})
|
||||
}
|
||||
@@ -1613,6 +1650,89 @@ mod tests {
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn launcher_favorites_pass_through_real_http_session_and_persist() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
let agent = agent(&server, root.path());
|
||||
let manager = unsafe { libc::geteuid() };
|
||||
let mut core = crate::tests::accepted_service(&agent.state, manager).unwrap();
|
||||
let package = crate::tests::fixture(root.path(), "favorite.demo", "1.0.0", 1, None);
|
||||
core.handle(
|
||||
"install",
|
||||
json!({"package":STANDARD.encode(package),"approve":true}),
|
||||
)
|
||||
.unwrap();
|
||||
let socket = UnixListener::bind(&agent.socket).unwrap();
|
||||
socket.set_nonblocking(true).unwrap();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let core_stop = stop.clone();
|
||||
let core_worker = std::thread::spawn(move || {
|
||||
while !core_stop.load(Ordering::Relaxed) {
|
||||
if let Ok((mut stream, _)) = socket.accept() {
|
||||
let request = ipc::read(&mut stream).unwrap();
|
||||
let response = ipc::response(core.handle(
|
||||
request["method"].as_str().unwrap(),
|
||||
request["params"].clone(),
|
||||
));
|
||||
ipc::write(&mut stream, &response).unwrap();
|
||||
} else {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
});
|
||||
let http = http_server(server, agent.clone(), stop.clone());
|
||||
let request = |method: &str, params: Value| -> Value {
|
||||
ureq::post(&format!("{}/api", agent.origin))
|
||||
.set("Cookie", &format!("framely={}", agent.web_key))
|
||||
.set("Origin", &agent.origin)
|
||||
.send_json(json!({"method":method,"params":params}))
|
||||
.unwrap()
|
||||
.into_json()
|
||||
.unwrap()
|
||||
};
|
||||
let responses: Vec<_> = [
|
||||
"plugin:favorite.demo",
|
||||
"steam:42",
|
||||
"framely",
|
||||
"desktop:terminal.desktop",
|
||||
"lepton:default/app",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|key| request("launcher.favorite", json!({"key":key,"favorite":true})))
|
||||
.collect();
|
||||
let status = request("status", json!({}));
|
||||
let removed = request(
|
||||
"launcher.favorite",
|
||||
json!({"key":"framely","favorite":false}),
|
||||
);
|
||||
let denied = request("launcher.favorite.invalid", json!({}));
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
http.join().unwrap();
|
||||
core_worker.join().unwrap();
|
||||
for response in responses {
|
||||
assert_eq!(response["result"], true, "{response}");
|
||||
}
|
||||
assert_eq!(
|
||||
status["result"]["database"]["plugins"]["favorite.demo"]["favorite"],
|
||||
true
|
||||
);
|
||||
assert_eq!(status["result"]["database"]["steamFavorites"], json!([42]));
|
||||
assert_eq!(removed["result"], true);
|
||||
assert!(denied.get("error").is_some());
|
||||
let loaded = crate::service::Service::load(&agent.state, manager).unwrap();
|
||||
assert!(loaded.db.plugins["favorite.demo"].favorite);
|
||||
assert!(loaded.db.steam_favorites.contains(&42));
|
||||
assert_eq!(
|
||||
loaded.db.launcher_favorites,
|
||||
[
|
||||
"desktop:terminal.desktop".to_owned(),
|
||||
"lepton:default/app".to_owned()
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn manager_entry_preserves_catalog_and_update_targets_and_rejects_arbitrary_pages() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
@@ -1621,6 +1741,8 @@ mod tests {
|
||||
json!({"page":"catalog"}),
|
||||
json!({"page":"updates"}),
|
||||
json!({"page":"notification-settings"}),
|
||||
json!({"page":"launcher-settings"}),
|
||||
json!({"page":"settings"}),
|
||||
json!({}),
|
||||
json!({"page":"https://example.org"}),
|
||||
] {
|
||||
@@ -1635,8 +1757,16 @@ mod tests {
|
||||
commands[2],
|
||||
json!({"kind":"manager.open","page":"notification-settings"})
|
||||
);
|
||||
assert_eq!(commands[3], json!({"kind":"manager.open"}));
|
||||
assert_eq!(commands[4], json!({"kind":"manager.open"}));
|
||||
assert_eq!(
|
||||
commands[3],
|
||||
json!({"kind":"manager.open","page":"launcher-settings"})
|
||||
);
|
||||
assert_eq!(
|
||||
commands[4],
|
||||
json!({"kind":"manager.open","page":"settings"})
|
||||
);
|
||||
assert_eq!(commands[5], json!({"kind":"manager.open"}));
|
||||
assert_eq!(commands[6], json!({"kind":"manager.open"}));
|
||||
}
|
||||
#[test]
|
||||
fn declining_agreement_only_closes_current_view() {
|
||||
|
||||
+5
-11
@@ -5,7 +5,6 @@ use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -302,16 +301,11 @@ fn launch_installed(home: &Path, id: u32) -> Result<()> {
|
||||
apps.iter().any(|a| a.id == id),
|
||||
"Steam app is no longer installed"
|
||||
);
|
||||
let output = crate::process::tool("steam")
|
||||
.arg(format!("steam://rungameid/{id}"))
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.context("Cannot contact Steam")?;
|
||||
ensure!(
|
||||
output.status.success(),
|
||||
"Steam launch failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
crate::process::launch_handoff(
|
||||
crate::process::tool("steam").arg(format!("steam://rungameid/{id}")),
|
||||
std::time::Duration::from_secs(10),
|
||||
)
|
||||
.map_err(|error| anyhow::anyhow!("Steam launch failed: {error:#}"))?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
|
||||
+103
-1
@@ -814,6 +814,36 @@ fn apply_prepared(core: &mut Service, prepared: crate::planner::Prepared) -> any
|
||||
core.handle("install.batch",json!({"approve":true,"approveRunAs":true,"plan":prepared.plan,"packages":prepared.packages.iter().map(|(bytes,source)|{let mut request=bytes.request();request["source"]=json!(source);request}).collect::<Vec<_>>()}))
|
||||
}
|
||||
#[test]
|
||||
fn batch_install_in_safe_mode_preserves_packages_without_starting_plugins() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _tools = fake_backend_tools(tmp.path());
|
||||
let mut core = accepted_service(&tmp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
|
||||
core.handle("safeMode", json!({"enabled":true})).unwrap();
|
||||
for version in ["1.0.0", "1.1.0"] {
|
||||
let bytes = relation_package(
|
||||
tmp.path(),
|
||||
"test.safe",
|
||||
version,
|
||||
json!({"backend":{"entry":"fail.sh","autostart":true}}),
|
||||
);
|
||||
let prepared = crate::planner::prepare(&core.db, bytes, None, Default::default()).unwrap();
|
||||
apply_prepared(&mut core, prepared).unwrap();
|
||||
assert_eq!(core.db.plugins["test.safe"].manifest.version, version);
|
||||
assert!(core.db.plugins["test.safe"].enabled);
|
||||
assert!(core.db.safe_mode);
|
||||
assert!(!core.root.join("install-transaction.json").exists());
|
||||
let status = core.handle("status", json!({})).unwrap();
|
||||
assert_eq!(status["runtime"]["test.safe"]["phase"], "installed");
|
||||
let reloaded = Service::load(&core.root, core.manager).unwrap();
|
||||
assert!(reloaded.db.safe_mode);
|
||||
assert_eq!(reloaded.db.plugins["test.safe"].manifest.version, version);
|
||||
}
|
||||
core.handle("safeMode", json!({"enabled":false})).unwrap();
|
||||
let lifecycle = fs::read_to_string(core.root.join("logs/test.safe.lifecycle.log")).unwrap();
|
||||
assert!(lifecycle.contains("\"phase\":\"starting\""));
|
||||
core.shutdown();
|
||||
}
|
||||
#[test]
|
||||
fn dependency_install_conflicts_reverse_constraints_and_removal() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut core = accepted_service(&tmp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
|
||||
@@ -1578,13 +1608,14 @@ fn manager_uninstall_disables_all_plugins_and_removes_packages_before_body() {
|
||||
.unwrap();
|
||||
assert_eq!(result["uninstalled"].as_array().unwrap().len(), 2);
|
||||
assert!(core.db.plugins.is_empty());
|
||||
assert!(core.db.safe_mode);
|
||||
assert!(!core.db.safe_mode);
|
||||
assert!(!root.path().join("plugins/test.first").exists());
|
||||
assert!(!root.path().join("plugins/test.second").exists());
|
||||
assert!(root.path().join("data/test.first/saved").exists());
|
||||
assert!(root.path().join("manager-marker").exists());
|
||||
let reloaded = Service::load(root.path(), 1000).unwrap();
|
||||
assert!(reloaded.db.plugins.is_empty());
|
||||
assert!(!reloaded.db.safe_mode);
|
||||
core.handle("system.uninstall.prepare", json!({"approve":true}))
|
||||
.unwrap();
|
||||
}
|
||||
@@ -1616,6 +1647,7 @@ fn manager_uninstall_failure_keeps_plugin_and_persists_disabling() {
|
||||
assert!(root.path().join("plugins/test.retained").exists());
|
||||
let reloaded = Service::load(root.path(), 1000).unwrap();
|
||||
assert!(!reloaded.db.plugins["test.retained"].enabled);
|
||||
assert!(reloaded.db.safe_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1969,3 +2001,73 @@ fn launcher_dispatch_preserves_old_open_semantics_and_window_context() {
|
||||
let loaded = Service::load(&root.path().join("state"), unsafe { libc::geteuid() }).unwrap();
|
||||
assert_eq!(loaded.db.launcher_order, order);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_categories_migrate_validate_and_favorites_survive_reload() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let state = root.path().join("state");
|
||||
let mut core = accepted_service(&state, unsafe { libc::geteuid() }).unwrap();
|
||||
assert_eq!(
|
||||
core.db.launcher.all_categories,
|
||||
vec!["plugin", "steam", "lepton", "desktop"]
|
||||
);
|
||||
core.handle("launcher.settings.save",json!({"primaryTrigger":true,"menuAutoClose":true,"menuTimeoutSeconds":10,"allCategories":[]})).unwrap();
|
||||
for key in [
|
||||
"framely",
|
||||
"desktop:terminal.desktop",
|
||||
"lepton:default/app",
|
||||
"steam:42",
|
||||
] {
|
||||
core.handle("launcher.favorite", json!({"key":key,"favorite":true}))
|
||||
.unwrap();
|
||||
}
|
||||
for invalid in [
|
||||
json!(["all"]),
|
||||
json!(["plugin", "plugin"]),
|
||||
json!(["favorites"]),
|
||||
] {
|
||||
assert!(core.handle("launcher.settings.save",json!({"primaryTrigger":true,"menuAutoClose":true,"menuTimeoutSeconds":10,"allCategories":invalid})).is_err());
|
||||
}
|
||||
for invalid in [
|
||||
"",
|
||||
"plugin:missing",
|
||||
"steam:0",
|
||||
"steam:-1",
|
||||
"steam:4294967296",
|
||||
"desktop:",
|
||||
"invalid:app",
|
||||
"lepton:\napp",
|
||||
] {
|
||||
assert!(core
|
||||
.handle("launcher.favorite", json!({"key":invalid,"favorite":true}))
|
||||
.is_err());
|
||||
}
|
||||
let mut loaded = Service::load(&state, unsafe { libc::geteuid() }).unwrap();
|
||||
assert!(loaded.db.launcher.all_categories.is_empty());
|
||||
assert!(loaded.db.launcher.primary_trigger);
|
||||
assert_eq!(loaded.db.launcher_favorites.len(), 3);
|
||||
assert!(loaded.db.steam_favorites.contains(&42));
|
||||
loaded
|
||||
.handle(
|
||||
"launcher.favorite",
|
||||
json!({"key":"framely","favorite":false}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!loaded.db.launcher_favorites.contains("framely"));
|
||||
loaded
|
||||
.handle("launcher.settings.save", json!({"menuTimeoutSeconds":25}))
|
||||
.unwrap();
|
||||
assert_eq!(loaded.db.launcher.all_categories.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_trigger_defaults_preserve_explicit_preferences() {
|
||||
assert!(LauncherSettings::default().primary_trigger);
|
||||
let missing: LauncherSettings = serde_json::from_value(json!({})).unwrap();
|
||||
assert!(missing.primary_trigger);
|
||||
let saved: LauncherSettings = serde_json::from_value(json!({"primaryTrigger":false})).unwrap();
|
||||
assert!(!saved.primary_trigger);
|
||||
let restored: LauncherSettings =
|
||||
serde_json::from_value(serde_json::to_value(saved).unwrap()).unwrap();
|
||||
assert!(!restored.primary_trigger);
|
||||
}
|
||||
@@ -419,6 +419,14 @@ impl Updater {
|
||||
request["version"].as_str() == Some(&release.version),
|
||||
"待安装版本已变,请重新确认"
|
||||
);
|
||||
let current = fs::read_to_string(root.join("current/VERSION"))
|
||||
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into());
|
||||
ensure!(
|
||||
descriptor_version(&release.version)?
|
||||
.cmp_precedence(&descriptor_version(current.trim())?)
|
||||
!= std::cmp::Ordering::Equal,
|
||||
"当前已是此版本,无需重复更新。"
|
||||
);
|
||||
launch_helper(root, manager, Some(&stage))?;
|
||||
*self.prepared.lock().unwrap() = None;
|
||||
write_status(
|
||||
@@ -786,6 +794,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn same_version_with_different_build_cannot_launch_update() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("current")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("current/VERSION"),
|
||||
"0.4.3-preview.2-3640daa86d54\n",
|
||||
)
|
||||
.unwrap();
|
||||
let updater = Updater::default();
|
||||
let (_, bytes) = fixture("0.4.3-preview.2", false);
|
||||
let mut release = verify_descriptor(&bytes).unwrap();
|
||||
release.version = "0.4.3-preview.2-16320a685fb1".into();
|
||||
*updater.prepared.lock().unwrap() = Some((
|
||||
dir.path().into(),
|
||||
release.clone(),
|
||||
(source(), UpdateChannel::Testing),
|
||||
));
|
||||
let error = updater
|
||||
.apply(
|
||||
dir.path(),
|
||||
1000,
|
||||
&source(),
|
||||
UpdateChannel::Testing,
|
||||
json!({"approve":true,"version":release.version}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("无需重复更新"));
|
||||
assert!(updater.prepared.lock().unwrap().is_some());
|
||||
assert!(!dir.path().join("update-status.json").exists());
|
||||
}
|
||||
#[test]
|
||||
fn changing_channel_invalidates_a_downloaded_release() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let updater = Updater::default();
|
||||
|
||||
@@ -36,11 +36,11 @@ int main(int argc,char** argv){
|
||||
cef_main_args_t args{argc,argv};if(std::strcmp(cef_api_hash(CEF_API_VERSION,0),CEF_API_HASH_PLATFORM))return 2;int child=cef_execute_process(&args,nullptr,nullptr);if(child>=0)return child;if(url.empty()||runtime.empty()||cef.empty())return 2;
|
||||
cef_settings_t settings{};settings.size=sizeof(settings);settings.windowless_rendering_enabled=1;str(settings.root_cache_path,runtime+"/cache");str(settings.log_file,runtime+"/cef.log");str(settings.resources_dir_path,cef+"/Resources");str(settings.locales_dir_path,cef+"/Resources/locales");
|
||||
if(!cef_initialize(&args,&settings,nullptr,nullptr))return 3;
|
||||
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1200,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1200,800,4);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:600,std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?4800:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?3200:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);if(!notification_mode&&w>0&&h>0){auto alpha=[&](int x,int y){return pixels[(y*w+x)*4+3];};if(alpha(0,0)==0&&alpha(w-1,0)==0&&alpha(0,h-1)==0&&alpha(w-1,h-1)==0&&alpha(w/2,40)>240)rounded_paint=true;}};
|
||||
display.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* m,const cef_string_t*,int){auto s=text(m);std::cout<<s<<"\n";if(s.rfind("FRAMELY_NATIVE_KEYBOARD_",0)==0){auto* frame=b->get_focused_frame(b);auto command=s.substr(24);if(command=="TEXT")keyboard_edit(frame,"汉😃");else if(command=="BACKSPACE")keyboard_edit(frame,"\b");else if(command=="ENTER")keyboard_edit(frame,"\n");else if(command=="X")keyboard_edit(frame,"x");else if(command=="Y")keyboard_edit(frame,"y");else if(command=="LEFT")keyboard_edit(frame,"\x1b[D");else if(command=="RIGHT")keyboard_edit(frame,"\x1b[C");frame->base.release(&frame->base);}if(s=="FRAMELY_LAUNCHER_CANCEL_PRESS"){auto* pointer_host=b->get_host(b);cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_move_event(pointer_host,&outside,0);outside.modifiers=0;pointer_host->send_mouse_click_event(pointer_host,&outside,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;auto* frame=b->get_main_frame(b);cef_string_t code{},source{};str(code,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->execute_java_script(frame,&code,&source,1);cef_string_utf16_clear(&code);frame->base.release(&frame->base);pointer_host->base.release(&pointer_host->base);}if(s.rfind("FRAMELY_LAUNCHER_DRAG_",0)==0){int x=0,y=0;if(press_pending&&std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_DRAG_%d_%d",&x,&y)==2){press_dx=x-test_pointer.x;press_dy=y-test_pointer.y;}}if(s.rfind("FRAMELY_LAUNCHER_PRESS_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_PRESS_%d_%d_%d_%d_%d",&x,&y,&press_dx,&press_dy,&press_duration)==5){auto* pointer_host=b->get_host(b);test_pointer.begin(x,y,std::chrono::steady_clock::now());press_pending=true;cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;pointer_host->set_focus(pointer_host,1);pointer_host->send_mouse_move_event(pointer_host,&mouse,0);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_click_event(pointer_host,&mouse,MBT_LEFT,0,1);pointer_host->base.release(&pointer_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_HOVER_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_HOVER_%d_%d",&x,&y)==2){auto* hover_host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;hover_host->send_mouse_move_event(hover_host,&mouse,0);hover_host->base.release(&hover_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_REGION_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_REGION_%d_%d_%d_%d",&x,&y,&w,&h)==4){int ink=0;for(int py=std::max(0,y*4);py<std::min(pixel_height,(y+h)*4);py++)for(int px=std::max(0,x*4);px<std::min(pixel_width,(x+w)*4);px++)if(pixels[(size_t(py)*pixel_width+px)*4+3]>128)ink++;if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted control "<<x<<","<<y<<"\n";}}}if(s.rfind("FRAMELY_LAUNCHER_INK_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_INK_%d_%d_%d_%d",&x,&y,&w,&h)==4){int ink=0;for(int py=std::max(0,y*4);py<std::min(pixel_height,(y+h)*4);py++)for(int px=std::max(0,x*4);px<std::min(pixel_width,(x+w)*4);px++){size_t i=(size_t(py)*pixel_width+px)*4;if(pixels[i+3]>128&&pixels[i]>180&&pixels[i+1]>180&&pixels[i+2]>180)ink++;}if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted label "<<x<<","<<y<<" ink="<<ink<<"\n";}}}
|
||||
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,3);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:600,std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?4320:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?2400:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);if(!notification_mode&&w>0&&h>0){auto alpha=[&](int x,int y){return pixels[(y*w+x)*4+3];};if(alpha(0,0)==0&&alpha(w-1,0)==0&&alpha(0,h-1)==0&&alpha(w-1,h-1)==0&&alpha(w/2,40)>240)rounded_paint=true;}};
|
||||
display.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* m,const cef_string_t*,int){auto s=text(m);std::cout<<s<<"\n";if(s.rfind("FRAMELY_PAGE_WHEEL_",0)==0){int x=0,y=0,dx=0,dy=0;if(std::sscanf(s.c_str(),"FRAMELY_PAGE_WHEEL_%d_%d_%d_%d",&x,&y,&dx,&dy)==4){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;host->send_mouse_move_event(host,&mouse,0);host->send_mouse_wheel_event(host,&mouse,dx,dy);host->base.release(&host->base);}}if(s.rfind("FRAMELY_NATIVE_KEYBOARD_",0)==0){auto* frame=b->get_focused_frame(b);auto command=s.substr(24);if(command=="TEXT")keyboard_edit(frame,"汉😃");else if(command=="BACKSPACE")keyboard_edit(frame,"\b");else if(command=="ENTER")keyboard_edit(frame,"\n");else if(command=="X")keyboard_edit(frame,"x");else if(command=="Y")keyboard_edit(frame,"y");else if(command=="LEFT")keyboard_edit(frame,"\x1b[D");else if(command=="RIGHT")keyboard_edit(frame,"\x1b[C");frame->base.release(&frame->base);}if(s=="FRAMELY_LAUNCHER_CANCEL_PRESS"){auto* pointer_host=b->get_host(b);cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_move_event(pointer_host,&outside,0);outside.modifiers=0;pointer_host->send_mouse_click_event(pointer_host,&outside,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;auto* frame=b->get_main_frame(b);cef_string_t code{},source{};str(code,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->execute_java_script(frame,&code,&source,1);cef_string_utf16_clear(&code);frame->base.release(&frame->base);pointer_host->base.release(&pointer_host->base);}if(s.rfind("FRAMELY_LAUNCHER_DRAG_",0)==0){int x=0,y=0;if(press_pending&&std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_DRAG_%d_%d",&x,&y)==2){press_dx=x-test_pointer.x;press_dy=y-test_pointer.y;}}if(s.rfind("FRAMELY_LAUNCHER_PRESS_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_PRESS_%d_%d_%d_%d_%d",&x,&y,&press_dx,&press_dy,&press_duration)==5){auto* pointer_host=b->get_host(b);test_pointer.begin(x,y,std::chrono::steady_clock::now());press_pending=true;cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;pointer_host->set_focus(pointer_host,1);pointer_host->send_mouse_move_event(pointer_host,&mouse,0);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_click_event(pointer_host,&mouse,MBT_LEFT,0,1);pointer_host->base.release(&pointer_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_HOVER_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_HOVER_%d_%d",&x,&y)==2){auto* hover_host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;hover_host->send_mouse_move_event(hover_host,&mouse,0);hover_host->base.release(&hover_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_REGION_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_REGION_%d_%d_%d_%d",&x,&y,&w,&h)==4){const int scale=launcher_mode?3:4;int ink=0;for(int py=std::max(0,y*scale);py<std::min(pixel_height,(y+h)*scale);py++)for(int px=std::max(0,x*scale);px<std::min(pixel_width,(x+w)*scale);px++)if(pixels[(size_t(py)*pixel_width+px)*4+3]>128)ink++;if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted control "<<x<<","<<y<<"\n";}}}if(s.rfind("FRAMELY_LAUNCHER_INK_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_INK_%d_%d_%d_%d",&x,&y,&w,&h)==4){const int scale=launcher_mode?3:4;int ink=0;for(int py=std::max(0,y*scale);py<std::min(pixel_height,(y+h)*scale);py++)for(int px=std::max(0,x*scale);px<std::min(pixel_width,(x+w)*scale);px++){size_t i=(size_t(py)*pixel_width+px)*4;if(pixels[i+3]>128&&pixels[i]>180&&pixels[i+1]>180&&pixels[i+2]>180)ink++;}if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted label "<<x<<","<<y<<" ink="<<ink<<"\n";}}}
|
||||
if(s=="FRAMELY_DPI_CLICK_REQUEST"){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=50;mouse.y=45;host->set_focus(host,1);host->send_mouse_move_event(host,&mouse,0);host->send_mouse_click_event(host,&mouse,MBT_LEFT,0,1);host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);host->base.release(&host->base);}if(s=="FRAMELY_DPI_CLICK_PASS")dpi_click=true;if(s.rfind("FRAMELY_PREVIEW_INSTALL_",0)==0)snapshot(s.substr(16));if(s=="FRAMELY_PREVIEW_STORE")snapshot("store");if(s=="FRAMELY_PREVIEW_FAVORITES")snapshot("favorites");if(s=="FRAMELY_PREVIEW_INSTALLED")snapshot("installed");if(s=="FRAMELY_PREVIEW_SETTINGS")snapshot("settings");if(s=="FRAMELY_VIEW_READY")ready=true;if(s=="FRAMELY_BRIDGE_PASS")passed=true;if(s.rfind("FRAMELY_BRIDGE_FAIL",0)==0)failed=true;return 1;};life.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t*){closed=true;};
|
||||
client.api.get_render_handler=[](cef_client_t*){return render.acquire();};client.api.get_display_handler=[](cef_client_t*){return display.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life.acquire();};
|
||||
cef_window_info_t win{};win.size=sizeof(win);win.windowless_rendering_enabled=1;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=90;cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&win,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);if(!browser)return 4;auto* host=browser->get_host(browser);
|
||||
cef_window_info_t win{};win.size=sizeof(win);win.windowless_rendering_enabled=1;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=60;cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&win,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);if(!browser)return 4;auto* host=browser->get_host(browser);
|
||||
auto end=std::chrono::steady_clock::now()+std::chrono::seconds(45);bool injected=false;
|
||||
while(std::chrono::steady_clock::now()<end&&!passed&&!failed){cef_do_message_loop_work();if(press_pending){auto now=std::chrono::steady_clock::now();cef_mouse_event_t mouse{};mouse.x=test_pointer.x+press_dx;mouse.y=test_pointer.y+press_dy;test_pointer.constrain(mouse.x,mouse.y,now);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;host->send_mouse_move_event(host,&mouse,0);if(now-test_pointer.pressed>=std::chrono::milliseconds(press_duration)){mouse.modifiers=0;host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;}}if(ready&&!injected){auto* f=browser->get_main_frame(browser);cef_string_t code{},source{};str(code,install_review_mode?"window.runInstallReviewChecks()":notification_mode?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));for(let i=0;i<100&&!document.querySelector('.toast');i++)await wait(30);if(innerWidth!==600||innerHeight!==360||devicePixelRatio!==2)throw Error('notification dimensions');const toast=document.querySelector('.toast');if(!toast?.textContent.includes('Toast integration'))throw Error('notification missing');if(toast.getBoundingClientRect().height>240)throw Error('notification too large');if(toast.querySelectorAll('.toast-actions button').length!==3)throw Error('actions missing');toast.querySelector('[aria-label="确认"]').click();await wait(150);document.querySelector('[aria-label="关闭通知"]').click();await wait(150);const status=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method:'status',params:{}})}).then(r=>r.json());if(status.result.notifications.length)throw Error('notification not withdrawn');console.log('FRAMELY_BRIDGE_PASS');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_REMOTE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));await wait(300);const button=document.createElement('button');button.style.cssText='position:fixed;left:20px;top:20px;width:100px;height:50px;z-index:9999';button.onclick=()=>console.log('FRAMELY_DPI_CLICK_PASS');document.body.append(button);console.log('FRAMELY_DPI_CLICK_REQUEST');await wait(200);button.remove();for(let i=0;i<100;i++){const plugin=document.querySelector('.plugin-main');if(plugin){plugin.click();return;}await wait(50);}throw Error('remote plugin list timeout');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_STORE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));const until=async find=>{for(let i=0;i<160;i++){const value=find();if(value)return value;await wait(50);}throw Error("store timeout "+document.body.innerText);};await wait(300);if(innerWidth!==1440||innerHeight!==810||devicePixelRatio!==4)throw Error("manager layout or DPI mismatch");[...document.querySelectorAll("nav button")].find(b=>b.textContent==="插件源").click();await wait(200);if(!document.body.textContent.includes("Subscription fixture"))throw Error("subscription settings missing");const autoRefresh=[...document.querySelectorAll("button")].find(b=>b.textContent==="关闭自动刷新");if(!autoRefresh)throw Error("subscription automatic refresh missing");autoRefresh.click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="开启自动刷新"));[...document.querySelectorAll("nav button")].find(b=>b.textContent==="关于").click();await wait(150);const updates=document.querySelector(".system-updates");if(!updates||updates.querySelector("input:not([type=checkbox]),textarea"))throw Error("release configuration leaked into user settings");if(!updates.querySelector(".automatic-update-settings input[type=checkbox]")?.disabled)throw Error("automatic check controls enabled without source");if(!updates.textContent.includes("尚未配置更新服务"))throw Error("missing unconfigured update service message");const check=[...updates.querySelectorAll("button")].find(b=>b.textContent==="检查更新");if(!check?.disabled)throw Error("unconfigured check enabled");[...document.querySelectorAll("button")].find(b=>b.textContent==="查看协议与隐私声明").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议"));document.querySelector('.language-picker button').click();const english=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='English'));english.click();await until(()=>document.querySelector('h1')?.textContent==='Before using Framely');document.querySelector('.language-picker button').click();const chinese=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='简体中文'));chinese.click();await until(()=>[...document.querySelectorAll('button')].find(b=>b.textContent==='拒绝协议'));[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议").click();await until(()=>document.body.textContent.includes("拒绝协议将停用全部插件"));[...document.querySelectorAll("button")].fiLine truncated
|
||||
if(!passed&&!failed){auto* f=browser->get_main_frame(browser);cef_string_t diagnostic{},source{};str(diagnostic,"console.error('FRAMELY_TIMEOUT '+location.pathname.replace(/\\/boot\\/[^/]+/,'/boot/token')+' '+document.body.innerText.slice(0,400))");f->execute_java_script(f,&diagnostic,&source,1);cef_string_utf16_clear(&diagnostic);f->base.release(&f->base);for(int i=0;i<40;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}snapshot("FAILURE");}
|
||||
|
||||
+30
-14
@@ -1,13 +1,14 @@
|
||||
import React,{useState} from 'react';import{createRoot}from'react-dom/client';
|
||||
import{Launcher,LauncherSettings,type LauncherConfig}from'../ui/src/launcher';import{configureLanguage}from'../ui/src/i18n';import type{Plugin}from'../ui/src/main';import'../ui/src/style.css';
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<120;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,240)+' '+JSON.stringify(calls.slice(-4))+' edit='+!!document.querySelector('.launcher-editing'));};
|
||||
let stalledMethod='',releaseStalled:((r:Response)=>void)|undefined;
|
||||
let config:LauncherConfig={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:1};let order:string[]=[];let localApps:any[]=[];let redraw:()=>void;let settings=false;const calls:{method:string;params:any}[]=[];
|
||||
const plugins:Record<string,Plugin>={
|
||||
'demo.alpha':{manifest:{id:'demo.alpha',icon:'icon.png',name:'Termix',description:'Terminal',author:'Framely',version:'1.0.0',ui:{quickPage:'page.js',windows:{main:{}},launcherActions:[{id:'connect',label:'Connect terminal',target:{type:'frontend',entry:'actions.js'}}]}},enabled:true,favorite:true,order:0},
|
||||
'demo.beta':{manifest:{id:'demo.beta',icon:'icon.png',name:'Passthrough color',description:'Color',author:'Framely',version:'1.0.0',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:false,order:1},
|
||||
'demo.off':{manifest:{id:'demo.off',name:'Disabled app',description:'',author:'Framely',version:'1.0.0',ui:{windows:{}}},enabled:false,favorite:false,order:2},
|
||||
};
|
||||
window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected fetch');const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
|
||||
window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected fetch');const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});if(method===stalledMethod)return new Promise<Response>(resolve=>{releaseStalled=resolve;});let result:any=true;
|
||||
if(method==='steam.list')result=[{id:620980,name:'Beat Saber'},{id:546560,name:'Half-Life: Alyx'},{id:413150,name:'Stardew Valley'},{id:1245620,name:'Elden Ring'},{id:367520,name:'Hollow Knight'},{id:570,name:'Dota 2'},{id:440,name:'Team Fortress 2'}].map(a=>({...a,icon:`/preview-icons/${a.id}.jpg`,iconFit:'cover'}));
|
||||
else if(method==='desktop.list')result=localApps;
|
||||
else if(method==='status')result={database:{plugins,safeMode:false}};
|
||||
@@ -16,17 +17,17 @@ window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected f
|
||||
else if(method==='launcher.settings.save'){config=params;}
|
||||
else if(method==='plugin.launcher.action')result={frontend:'actions.js',context:{source:'launcher',trigger:'menuAction',actionId:params.action}};
|
||||
return new Response(JSON.stringify({result}));};
|
||||
function Fixture(){const[,render]=useState(0);redraw=()=>render(n=>n+1);return settings?<div className="app manager"><LauncherSettings config={config} refresh={async()=>redraw()}/></div>:<Launcher plugins={plugins} safeMode={false} config={config} favorites={[]} order={order} refresh={async()=>redraw()}/>;}
|
||||
function Fixture(){const[,render]=useState(0);redraw=()=>render(n=>n+1);return settings?<div className="app manager"><LauncherSettings config={config} refresh={async()=>redraw()}/></div>:<Launcher plugins={{...plugins}} safeMode={false} config={config} favorites={[]} order={order} refresh={async()=>redraw()}/>;}
|
||||
function button(text:string){return [...document.querySelectorAll<HTMLButtonElement>('button')].find(b=>b.textContent===text)!;}
|
||||
function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-icon')!;b.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));}
|
||||
function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Termix]')!;b.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));}
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
configureLanguage('en-US',[]);redraw();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
configureLanguage('en-US',[]);redraw();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
if(document.body.textContent?.includes('Disabled app'))throw Error('Disabled plugin displayed');
|
||||
if(document.querySelector('.launcher-header')||document.querySelector('.launcher-shell input'))throw Error('Launcher contains panel controls');
|
||||
if(document.querySelector('.launcher-header')||!document.querySelector('.launcher-search input'))throw Error('Launcher contains panel controls');
|
||||
if(getComputedStyle(document.querySelector('.launcher-shell')!).backgroundColor!=='rgba(0, 0, 0, 0)')throw Error('Launcher is not transparent');
|
||||
const grid=document.querySelector('.launcher-grid')!,filters=document.querySelector('.launcher-filters')!;const gridBefore=grid.getBoundingClientRect(),filterBefore=filters.getBoundingClientRect();if(gridBefore.width<1110)throw Error('Icon spacing was not widened');if(document.querySelector('.launcher-filters button')!.getBoundingClientRect().width<56)throw Error('Category hit target is too small');
|
||||
const grid=document.querySelector('.launcher-grid')!,filters=document.querySelector('.launcher-filters')!;const gridBefore=grid.getBoundingClientRect(),filterBefore=filters.getBoundingClientRect();if(gridBefore.width<1300)throw Error('Icon canvas was narrowed too far');if(document.querySelector('.launcher-filters button')!.getBoundingClientRect().width<56)throw Error('Category hit target is too small');
|
||||
const shellRect=document.querySelector('.launcher-shell')!.getBoundingClientRect();if(Math.abs(gridBefore.x+gridBefore.width/2-shellRect.x-shellRect.width/2)>1)throw Error('Icon area is not independently centered');const categoryRight=filterBefore.right;
|
||||
const discs=[...document.querySelectorAll<HTMLElement>('.launch-disc')].map(n=>n.getBoundingClientRect());const middle=discs.filter(r=>Math.abs(r.y-discs[4].y)<1);const betweenIcons=middle[1].left-middle[0].right,gapToCategories=middle[0].left-categoryRight;if(betweenIcons<90||gapToCategories<=betweenIcons)throw Error('Category gap must exceed horizontal artwork gap');
|
||||
const discs=[...document.querySelectorAll<HTMLElement>('.launch-disc')].map(n=>n.getBoundingClientRect());const middle=discs.filter(r=>Math.abs(r.y-discs[4].y)<1);const betweenIcons=middle[1].left-middle[0].right,gapToCategories=middle[0].left-categoryRight;const gapToShortcuts=document.querySelector('.launcher-shortcuts')!.getBoundingClientRect().left-middle[middle.length-1].right;if(betweenIcons<106||gapToCategories<132||gapToShortcuts<132||gapToCategories<=betweenIcons||gapToShortcuts<=betweenIcons)throw Error('Both capsule gaps must exceed the wider horizontal artwork gap '+JSON.stringify({betweenIcons,gapToCategories,gapToShortcuts}));
|
||||
const firstSlotY=document.querySelector<HTMLElement>('.launch-item')!.offsetTop;
|
||||
const sameLayout=()=>{const g=document.querySelector('.launcher-grid')!.getBoundingClientRect(),f=filters.getBoundingClientRect();if(g.width!==gridBefore.width||g.height!==gridBefore.height||g.x!==gridBefore.x||g.y!==gridBefore.y||f.x!==filterBefore.x||f.y!==filterBefore.y)throw Error('App count moved the fixed grid or categories '+JSON.stringify({before:gridBefore.toJSON(),after:g.toJSON(),filterBefore:filterBefore.toJSON(),filterAfter:f.toJSON()}));};
|
||||
const assertRows=(expected:number[])=>{const groups=new Map<string,DOMRect[]>();for(const item of document.querySelectorAll<HTMLElement>('.launch-item')){const r=item.querySelector('.launch-icon')!.getBoundingClientRect();if(item.style.gridRow.startsWith('1 /')&&Math.abs(item.offsetTop-firstSlotY)>1)throw Error('Partial rows moved the first icon row');const key=item.style.gridRow;groups.set(key,[...(groups.get(key)??[]),r]);}const rows=[...groups.values()];if(rows.map(row=>row.length).join(',')!==expected.join(','))throw Error('Unexpected row counts '+rows.map(row=>row.length));const center=(r:DOMRect)=>r.x+r.width/2;if(rows.length===1){const mean=rows[0].reduce((sum,r)=>sum+center(r),0)/rows[0].length;if(Math.abs(mean-gridBefore.x-gridBefore.width/2)>1)throw Error('Single row is not centered');}else{const step=center(rows[0][1])-center(rows[0][0]);if(Math.abs(center(rows[1][0])-center(rows[0][0])+step/2)>1)throw Error('Second row did not start half a slot left');if(rows[2]&&Math.abs(center(rows[2][0])-center(rows[0][0]))>1)throw Error('Third row is not aligned with the first');}};
|
||||
@@ -34,17 +35,17 @@ function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-i
|
||||
plugins['demo.ten']={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:'demo.ten',name:'Tenth app'}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===11);sameLayout();assertRows([4,5,2]);await wait(300);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_TEN_EN');delete plugins['demo.ten'];redraw();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
for(let n=0;n<3;n++)plugins[`demo.extra${n}`]={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:`demo.extra${n}`,name:`Extra ${n}`}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===13);sameLayout();assertRows([4,5,4]);for(let n=3;n<7;n++)plugins[`demo.extra${n}`]={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:`demo.extra${n}`,name:`Extra ${n}`}};redraw();await until(()=>document.querySelectorAll('.launcher-pages button').length===2);sameLayout();if(document.querySelectorAll('.launch-icon').length!==13)throw Error('AVP page capacity changed');document.querySelectorAll<HTMLButtonElement>('.launcher-pages button')[1].click();await until(()=>document.querySelectorAll('.launch-icon').length===4);sameLayout();document.querySelectorAll<HTMLButtonElement>('.launcher-pages button')[0].click();await until(()=>document.querySelectorAll('.launch-icon').length===13);if(!document.querySelector('.launcher-page')!.getAnimations().length)throw Error('Page change has no animation');await wait(340);for(let n=0;n<7;n++)delete plugins[`demo.extra${n}`];redraw();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
if(Math.abs(document.querySelector('.launch-disc')!.getBoundingClientRect().width-96)>.1)throw Error('Launcher artwork is too large');
|
||||
document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=Plugin]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===3);sameLayout();if(!document.querySelector('.launcher-page')!.getAnimations().length)throw Error('Category change has no animation');const categoryMotion=document.querySelector('.launcher-page')!.getAnimations()[0].effect as KeyframeEffect;if(!String(categoryMotion.getKeyframes()[0].translate).includes('110px'))throw Error('Category motion is too subtle');const activeSurface=document.querySelector<HTMLElement>('.launcher-category-active')!;await wait(60);const surfaceY=activeSurface.getBoundingClientRect().top,selectedY=document.querySelector('.launcher-filters button.active')!.getBoundingClientRect().top;if(surfaceY<=filterBefore.top+7||surfaceY>=selectedY)throw Error('Category active surface did not slide through intermediate positions');await wait(360);if(Math.abs(activeSurface.getBoundingClientRect().top-selectedY)>1)throw Error('Category surface did not settle under active item');
|
||||
document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=Plugin]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===3);sameLayout();if(!document.querySelector('.launcher-page')!.getAnimations().length)throw Error('Category change has no animation');const categoryMotion=document.querySelector('.launcher-page')!.getAnimations()[0].effect as KeyframeEffect;if(!String(categoryMotion.getKeyframes()[0].translate).includes('110px'))throw Error('Category motion is too subtle');const activeSurface=document.querySelector<HTMLElement>('.launcher-category-active')!;await wait(60);const surfaceY=activeSurface.getBoundingClientRect().top,selectedY=document.querySelector('.launcher-filters button.active')!.getBoundingClientRect().top;if(surfaceY<=filterBefore.top+7||surfaceY>=selectedY)throw Error('Category active surface did not slide through intermediate positions');await until(()=>Math.abs(activeSurface.getBoundingClientRect().top-selectedY)<=1);
|
||||
const arrangements=[[1],[2],[3],[4],[4,1],[4,2],[4,3],[4,4],[4,5],[4,5,1],[4,5,2],[4,5,3],[4,5,4]];
|
||||
plugins['demo.alpha'].enabled=false;plugins['demo.beta'].enabled=false;
|
||||
redraw();await until(()=>document.querySelectorAll('.launch-icon').length===1);sameLayout();assertRows([1]);
|
||||
for(let n=1;n<13;n++){plugins[`layout.${n}`]={...plugins['demo.beta'],enabled:true,manifest:{...plugins['demo.beta'].manifest,id:`layout.${n}`,name:`Layout ${String(n).padStart(2,'0')}`}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===n+1);sameLayout();assertRows(arrangements[n]);}
|
||||
for(let n=1;n<=13;n++)delete plugins[`layout.${n}`];plugins['demo.alpha'].enabled=true;plugins['demo.beta'].enabled=true;redraw();await until(()=>document.querySelectorAll('.launch-icon').length===3);
|
||||
document.querySelector<HTMLButtonElement>('.launcher-filters button')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
const category=document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=Steam]')!;const categoryRect=category.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(categoryRect.x+categoryRect.width/2)}_${Math.round(categoryRect.y+categoryRect.height/2)}_28_15_150`);await until(()=>document.querySelectorAll('.launch-icon').length===7);sameLayout();assertRows([4,3]);document.querySelector<HTMLButtonElement>('.launcher-filters button')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
const category=document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=Steam]')!;const categoryRect=category.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(categoryRect.x+categoryRect.width/2)}_${Math.round(categoryRect.y+categoryRect.height/2)}_28_15_150`);await until(()=>document.querySelectorAll('.launch-icon').length===7);sameLayout();assertRows([4,3]);document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
const assertControlsPainted=()=>{for(const node of document.querySelectorAll('.launcher-filters button svg,.launcher-pages button span')){const r=node.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_REGION_${Math.floor(r.x)}_${Math.floor(r.y)}_${Math.ceil(r.width)}_${Math.ceil(r.height)}`);}};
|
||||
await wait(500);assertControlsPainted();for(const label of document.querySelectorAll('.launch-name')){const r=label.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_INK_${Math.floor(r.x)}_${Math.floor(r.y)}_${Math.ceil(r.width)}_${Math.ceil(r.height)}`);}console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_EN');
|
||||
const icon=document.querySelector<HTMLButtonElement>('.launch-icon')!;
|
||||
const icon=document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Termix]')!;
|
||||
const rect=icon.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_HOVER_${Math.round(rect.x+rect.width/2)}_${Math.round(rect.y+rect.height/2)}`);await wait(350);if(icon.getBoundingClientRect().width!==rect.width)throw Error('Hover moved the input target');console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_HOVER_EN');console.log('FRAMELY_LAUNCHER_HOVER_0_0');await wait(250);
|
||||
const beforeClick=calls.filter(c=>c.method==='plugin.launch').length;console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(rect.x+10)}_${Math.round(rect.y+rect.height/2)}_-28_15_150`);await until(()=>calls.filter(c=>c.method==='plugin.launch').length===beforeClick+1);if(calls.filter(c=>c.method==='plugin.launch').at(-1)?.params.plugin!=='demo.alpha')throw Error('Jitter click launched a different app');
|
||||
const beforeDrag=calls.filter(c=>c.method==='plugin.launch').length;console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(rect.x+rect.width/2)}_${Math.round(rect.y+rect.height/2)}_80_0_300`);await wait(550);if(calls.filter(c=>c.method==='plugin.launch').length!==beforeDrag||document.querySelector('[role=menu]'))throw Error('Deliberate movement activated an app');
|
||||
@@ -74,7 +75,7 @@ function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-i
|
||||
const selfIcon=document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Framely]')!;selfIcon.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await wait(80);button('Arrange icons').click();await until(()=>!!document.querySelector('.launcher-editing'));const closeCount=calls.filter(c=>c.method==='host.launcher.close').length;window.dispatchEvent(new Event('framely.back'));await until(()=>!document.querySelector('.launcher-editing'));if(calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('B closed the launcher instead of exiting arrangement');selfIcon.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await wait(80);button('Arrange icons').click();await until(()=>!!document.querySelector('.launcher-editing'));document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));await until(()=>!document.querySelector('.launcher-editing'));if(calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('Blank click closed launcher during arrangement');
|
||||
// Add enough icons for edge paging and swipe navigation.
|
||||
for(let n=0;n<9;n++)plugins[`edge.${n}`]={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:`edge.${n}`,name:`Edge ${n}`}};redraw();await until(()=>document.querySelectorAll('.launcher-pages button').length===2);
|
||||
console.log('FRAMELY_LAUNCHER_PRESS_700_40_-180_0_550');await until(()=>!!document.querySelector('.launcher-adjacent-page'));const activePage=document.querySelector<HTMLElement>('.launcher-page')!,adjacent=document.querySelector<HTMLElement>('.launcher-adjacent-page')!;if(!document.querySelector('.launcher-grid')!.classList.contains('launcher-panning')||!getComputedStyle(activePage).translate.startsWith('-180px'))throw Error('Canvas did not follow the held trigger');if(getComputedStyle(adjacent).translate==='none')throw Error('Neighbor did not slide alongside canvas');if(document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')!=='page')throw Error('Swipe committed before release');await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');
|
||||
console.log('FRAMELY_LAUNCHER_PRESS_700_90_-180_0_550');await until(()=>!!document.querySelector('.launcher-adjacent-page'));const activePage=document.querySelector<HTMLElement>('.launcher-page')!,adjacent=document.querySelector<HTMLElement>('.launcher-adjacent-page')!;if(!document.querySelector('.launcher-grid')!.classList.contains('launcher-panning')||!getComputedStyle(activePage).translate.startsWith('-180px'))throw Error('Canvas did not follow the held trigger');if(getComputedStyle(adjacent).translate==='none')throw Error('Neighbor did not slide alongside canvas');if(document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')!=='page')throw Error('Swipe committed before release');await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');
|
||||
window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-1}));await until(()=>document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')==='page');
|
||||
await wait(340);const edgeFirst=document.querySelector<HTMLButtonElement>('.launch-icon')!.getBoundingClientRect();const saves=calls.filter(c=>c.method==='launcher.order.save').length;
|
||||
console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(edgeFirst.x+edgeFirst.width/2)}_${Math.round(edgeFirst.y+edgeFirst.height/2)}_0_0_2600`);await until(()=>document.querySelector('.launcher-editing'));const edgeBox=document.querySelector('.launcher-grid')!.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(edgeBox.right-25)}_400`);await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');console.log('FRAMELY_EDGE_PAGE_OK');await wait(340);const leftEdge=Math.max(edgeBox.left,categoryRight+24)+25;if(leftEdge<=categoryRight)throw Error('Left drag edge overlaps category rail');console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(leftEdge)}_400`);await until(()=>!!document.querySelector('.launcher-drag-edge.left'));const indicator=document.querySelector('.launcher-drag-edge.left')!.getBoundingClientRect();if(indicator.left<=categoryRight)throw Error('Left edge feedback is behind the category rail');await until(()=>calls.filter(c=>c.method==='launcher.order.save').length>saves);console.log('FRAMELY_EDGE_SAVE_OK');await until(()=>!button('Done').disabled);button('Done').click();await until(()=>!document.querySelector('.launcher-editing'));
|
||||
@@ -82,10 +83,25 @@ function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-i
|
||||
console.log('FRAMELY_EDGE_DONE_OK');
|
||||
// Framely and new app kinds use their own safe launch APIs.
|
||||
document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Framely]')!.click();await until(()=>calls.some(c=>c.method==='host.manager.open'));
|
||||
console.log('FRAMELY_SELF_LAUNCH_OK');localApps=[{id:'terminal.desktop',name:'Desktop terminal',kind:'desktop'},{id:'test/com.example.browser',name:'Android browser',kind:'lepton'}];window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>document.querySelector('.launch-icon[aria-label="Desktop terminal"]'));
|
||||
console.log('FRAMELY_SELF_LAUNCH_OK');localApps=[{id:'terminal.desktop',name:'Desktop terminal',kind:'desktop'},{id:'test/com.example.browser',name:'Android browser',kind:'lepton'}];await until(()=>!document.querySelector('.launch-icon:disabled'));window.dispatchEvent(new Event('framely.launcher.open'));await wait(120);await until(()=>!document.querySelector('.launcher-initializing'));document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelector('.launch-icon[aria-label="Desktop terminal"]'));
|
||||
document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label="Desktop apps"]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector<HTMLButtonElement>('.launch-icon')!.click();await until(()=>calls.some(c=>c.method==='desktop.launch'&&c.params.app==='desktop:terminal.desktop'));
|
||||
await until(()=>!document.querySelector<HTMLButtonElement>('.launch-icon')!.disabled);window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:2}));await until(()=>document.querySelector('.launcher-filters button[aria-label=Lepton]')!.getAttribute('aria-pressed')==='true');document.querySelector<HTMLButtonElement>('.launch-icon')!.click();await until(()=>calls.some(c=>c.method==='desktop.launch'&&c.params.app==='lepton:test/com.example.browser'));
|
||||
// A pending response must not lock a reopened launcher, nor mount a stale frontend action after timeout.
|
||||
await until(()=>!document.querySelector<HTMLButtonElement>('.launch-icon')!.disabled);
|
||||
document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=Plugin]')!.click();await until(()=>!!document.querySelector('.launch-icon[aria-label=Termix]'));
|
||||
const realTimeout=window.setTimeout;window.setTimeout=((f:any,delay?:number,...args:any[])=>realTimeout(f,delay===120000?700:delay,...args)) as typeof window.setTimeout;
|
||||
stalledMethod='plugin.launcher.action';openMenu();await until(()=>!!button('Connect terminal'));button('Connect terminal').click();await until(()=>!!releaseStalled&&document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Termix]')!.disabled);
|
||||
const actionCount=calls.filter(c=>c.method==='plugin.launcher.action').length;button('Connect terminal').click();if(calls.filter(c=>c.method==='plugin.launcher.action').length!==actionCount)throw Error('Pending app accepted a duplicate action');
|
||||
if(document.querySelector<HTMLInputElement>('.launcher-search input')!.disabled||[...document.querySelectorAll<HTMLButtonElement>('.launcher-filters button,.launcher-pages button,.launcher-shortcuts button')].some(b=>b.disabled))throw Error('Pending app locked launcher navigation');
|
||||
const other=document.querySelector<HTMLButtonElement>('.launch-icon[aria-label="Passthrough color"]')!;if(other.disabled)throw Error('Pending app locked another icon');const otherLaunches=calls.filter(c=>c.method==='plugin.launch'&&c.params.plugin==='demo.beta').length;other.click();await until(()=>calls.filter(c=>c.method==='plugin.launch'&&c.params.plugin==='demo.beta').length===otherLaunches+1);await until(()=>!other.disabled);if(!document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Termix]')!.disabled)throw Error('Other completion cleared pending app lock');
|
||||
window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-2}));await until(()=>document.querySelector('.launcher-filters button[aria-label=Steam]')!.getAttribute('aria-pressed')==='true');document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=Plugin]')!.click();await until(()=>!!document.querySelector('.launch-icon[aria-label=Termix]'));await until(()=>!!document.querySelector('.launcher-error')&&!document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Termix]')!.disabled);
|
||||
window.dispatchEvent(new Event('framely.launcher.open'));await wait(100);
|
||||
releaseStalled!(new Response(JSON.stringify({result:{frontend:'actions.js',context:{source:'launcher'}}})));await wait(100);
|
||||
if(document.querySelector('.launcher-action-frame')||document.querySelector<HTMLButtonElement>('.launch-icon')!.disabled)throw Error('Late action response locked or mounted in reopened launcher');
|
||||
releaseStalled=undefined;stalledMethod='desktop.launch';document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label="Desktop apps"]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector<HTMLButtonElement>('.launch-icon')!.click();await until(()=>!!releaseStalled);await until(()=>!document.querySelector<HTMLButtonElement>('.launch-icon')!.disabled);
|
||||
stalledMethod='';releaseStalled!(new Response(JSON.stringify({result:true})));window.setTimeout=realTimeout;await wait(100);
|
||||
console.log('FRAMELY_LAUNCHER_TIMEOUT_RECOVERY_OK');
|
||||
const closes=calls.filter(c=>c.method==='host.launcher.close').length;document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));window.dispatchEvent(new Event('framely.back'));await until(()=>calls.filter(c=>c.method==='host.launcher.close').length===closes+2);
|
||||
settings=true;redraw();await wait();const switches=document.querySelectorAll<HTMLInputElement>('input[type=checkbox]');switches[0].click();await wait();if(!config.primaryTrigger)throw Error('Trigger setting not saved');await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');configureLanguage('en-US',[]);redraw();await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');console.log('FRAMELY_BRIDGE_PASS');
|
||||
settings=true;redraw();await wait();const switches=document.querySelectorAll<HTMLInputElement>('[role=switch]');switches[0].click();await wait();if(!config.primaryTrigger)throw Error('Trigger setting not saved');await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');configureLanguage('en-US',[]);redraw();await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
|
||||
createRoot(document.getElementById('root')!).render(<Fixture/>);wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
|
||||
@@ -33,4 +33,15 @@ vr::RenderModel_ComponentState_t joystick_rest{},joystick_x{},joystick_y{},joyst
|
||||
// Releasing the opening trigger rearms input once, even without mouse events.
|
||||
guard.opened(now);guard.observe(true);guard.observe(false);
|
||||
assert(guard.allows(now+std::chrono::milliseconds(700),true));
|
||||
// Dock dragging and delayed cancel keep the launcher; later background
|
||||
// clicks become dismissible again, and reopening clears the prior gesture.
|
||||
LauncherDockGuard dock;assert(!dock.holds(now));dock.observe(true,true,now);
|
||||
assert(dock.holds(now+std::chrono::milliseconds(60)));
|
||||
dock.observe(false,true,now+std::chrono::seconds(2));
|
||||
assert(dock.holds(now+std::chrono::seconds(2)));
|
||||
dock.observe(false,false,now+std::chrono::seconds(3));
|
||||
assert(dock.holds(now+std::chrono::milliseconds(3200)));
|
||||
assert(!dock.holds(now+std::chrono::milliseconds(3250)));
|
||||
dock.clear();assert(!dock.holds(now+std::chrono::seconds(3)));
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Check routing and migrated settings in the actual manager, not a reproduced page.
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,350));};
|
||||
history.replaceState(null,'','/manager#launcher-settings');let language='en-US';let launcher:any={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
|
||||
window.fetch=async(_url,init)=>{const {method,params}=JSON.parse(String(init?.body));let result:any=true;
|
||||
if(method==='status')result={version:'0.4.3-preview.2',build:'0.4.3-preview.2',agreement:{version:1,accepted:true},database:{plugins:{},sources:[],safeMode:false,language,launcher,networkPanel:{enabled:true,port:15915},proxy:{http:'',github:''}},running:[],notifications:[],inbox:[]};
|
||||
else if(method==='language.list')result={packs:[],invalidFiles:[]};else if(method==='ui.events')result={cursor:0,events:[]};else if(method==='network.status')result={passwordConfigured:true,localOrigin:'http://localhost:15915',ips:[],listener:{}};else if(method==='launcher.settings.save')launcher=params;else if(method==='language.save')language=params.language;
|
||||
return new Response(JSON.stringify({result}));};
|
||||
const nav=(label:string)=>[...document.querySelectorAll<HTMLButtonElement>('.workspace>nav button')].find(b=>b.textContent===label)!;
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
await until(()=>!!document.querySelector('.launcher-settings'));if(nav('Launcher').getAttribute('aria-current')!=='page')throw Error('Hash did not select Launcher');if(document.querySelectorAll('.launcher-settings [role=switch]').length!==6)throw Error('Missing launcher controls');
|
||||
nav('Settings').click();await until(()=>!document.querySelector('.launcher-settings'));nav('Launcher').click();await until(()=>!!document.querySelector('.launcher-settings'));
|
||||
const switcher=document.querySelector<HTMLInputElement>('.launcher-settings [role=switch]')!;switcher.click();await until(()=>launcher.primaryTrigger);await until(()=>!switcher.disabled);
|
||||
const categories=document.querySelectorAll<HTMLInputElement>('.launcher-settings [role=switch]');categories[3].click();await until(()=>!launcher.allCategories.includes('steam'));await until(()=>!categories[3].disabled);
|
||||
await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_MANAGER_EN');const content=document.querySelector<HTMLElement>('.content')!;content.scrollTop=content.scrollHeight;await wait(300);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_CATEGORIES_EN');content.scrollTop=0;language='zh-CN';configureLanguage(language,[]);location.hash='#launcher-settings';window.dispatchEvent(new Event('hashchange'));await until(()=>document.documentElement.lang==='zh-CN'&&!!nav('启动台'));await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_MANAGER_ZH');content.scrollTop=content.scrollHeight;await wait(300);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_CATEGORIES_ZH');
|
||||
const first=document.querySelector('.launcher-settings .switch-row')!.getBoundingClientRect();const second=document.querySelectorAll('.launcher-settings')[1].querySelector('.switch-row')!.getBoundingClientRect();if(Math.abs(first.width-second.width)>1)throw Error('Settings widths differ');console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
|
||||
void import('../ui/src/main');
|
||||
@@ -0,0 +1,71 @@
|
||||
import React,{useState} from 'react';import{createRoot}from'react-dom/client';
|
||||
import{Launcher,LauncherSettings,type LauncherConfig}from'../ui/src/launcher';import{configureLanguage}from'../ui/src/i18n';import type{Plugin}from'../ui/src/main';import{matchesSearch}from'../ui/src/launcher-search';import'../ui/src/style.css';
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<160;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,300));};
|
||||
let config:LauncherConfig={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10};let saved:string[]=[];let order:string[]=[];let steamFavorites:number[]=[42];let settings=false;let redraw:()=>void;let delay=80;const calls:{method:string;params:any}[]=[];
|
||||
const plugins:Record<string,Plugin>={
|
||||
'demo.alpha':{manifest:{id:'demo.alpha',icon:'icon.png',name:'Termix',description:'Terminal',author:'Framely',version:'1.0.0',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:true,order:0},
|
||||
'demo.beta':{manifest:{id:'demo.beta',icon:'icon.png',name:'透视调色',description:'Color',author:'Framely',version:'1.0.0',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:false,order:1},
|
||||
'demo.off':{manifest:{id:'demo.off',name:'Disabled app',description:'',author:'Framely',version:'1.0.0',ui:{windows:{}}},enabled:false,favorite:true,order:2},
|
||||
};
|
||||
const local=[{id:'terminal.desktop',name:'Desktop terminal',kind:'desktop'},{id:'default/sakura',name:'さくら',kind:'lepton'},{id:'default/tokyo',name:'東京',kind:'lepton'}];
|
||||
window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected fetch');const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
|
||||
if(method==='steam.list'){await wait(delay);result=[{id:42,name:'Half-Life: Alyx',icon:'/preview-icons/546560.jpg',iconFit:'cover'}];}
|
||||
else if(method==='desktop.list'){await wait(delay);result=local;}
|
||||
else if(method==='launcher.search.index'){await wait(150);result=Object.fromEntries(params.names.map((name:string)=>[name,{terms:name==='透视调色'?['toushitiaose','tsts']:name==='さくら'?['sakura','skr']:name==='東京'?['toukyou','tuku']:name==='Half-Life: Alyx'?['halflifealyx','hla']:[name.toLowerCase().replace(/\s/g,'')]}]));}
|
||||
else if(method==='launcher.favorite'){const{key,favorite}=params;if(key.startsWith('plugin:'))plugins[key.slice(7)].favorite=favorite;else if(key.startsWith('steam:'))steamFavorites=favorite?[...steamFavorites,+key.slice(6)]:steamFavorites.filter(id=>id!==+key.slice(6));else saved=favorite?[...saved,key]:saved.filter(k=>k!==key);}
|
||||
else if(method==='launcher.settings.save')config=params;
|
||||
return new Response(JSON.stringify({result}));};
|
||||
function Fixture(){const[,render]=useState(0);redraw=()=>render(n=>n+1);return settings?<div className="app manager"><main className="content"><div className="list-heading"><div><h1>{document.documentElement.lang==='zh-CN'?'启动台':'Launcher'}</h1><p>{document.documentElement.lang==='zh-CN'?'启动方式、菜单与分类。':'Launch controls, menus and categories.'}</p></div></div><LauncherSettings config={config} refresh={async()=>redraw()}/></main></div>:<Launcher plugins={{...plugins}} safeMode={false} config={config} favorites={steamFavorites} localFavorites={saved} order={order} refresh={async()=>redraw()}/>;}
|
||||
const category=(name:string)=>document.querySelector<HTMLButtonElement>(`.launcher-filters button[aria-label="${name}"]`)!;
|
||||
const labels=()=>[...document.querySelectorAll('.launch-name')].map(n=>n.textContent);
|
||||
const input=()=>document.querySelector<HTMLInputElement>('.launcher-search input')!;
|
||||
function query(value:string){Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(input(),value);input().dispatchEvent(new Event('input',{bubbles:true}));}
|
||||
const favorite=(name:string)=>document.querySelector<HTMLButtonElement>(`.launch-icon[aria-label="${name}"]`)!.parentElement!.querySelector<HTMLButtonElement>('.launch-favorite')!;
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
configureLanguage('en-US',[]);redraw();await until(()=>!document.querySelector('.launcher-initializing'));
|
||||
if(category('Favorites').getAttribute('aria-pressed')!=='true'||labels().sort().join('|')!=='Half-Life: Alyx|Termix')throw Error('Default favorites / disabled filtering incorrect '+labels().join('|'));
|
||||
const keys=[...document.querySelectorAll('.launcher-filters button')].map(n=>n.getAttribute('aria-label'));if(keys.join('|')!=='Favorites|All|Plugin|Steam|Lepton|Desktop apps')throw Error('Category order '+keys);
|
||||
category('All').click();await until(()=>labels().length===7);for(const [label,page] of [['Plugin library','catalog'],['Settings','settings']]){document.querySelector<HTMLButtonElement>(`.launcher-shortcuts button[aria-label="${label}"]`)!.click();await until(()=>calls.some(c=>c.method==='host.manager.open'&&c.params.page===page));await until(()=>!document.querySelector('.launcher-shortcuts button:disabled'));}const initialGrid=document.querySelector('.launcher-grid')!.getBoundingClientRect();const initialFilters=document.querySelector('.launcher-filters')!.getBoundingClientRect();
|
||||
const stable=()=>{const g=document.querySelector('.launcher-grid')!.getBoundingClientRect(),f=document.querySelector('.launcher-filters')!.getBoundingClientRect();if(g.x!==initialGrid.x||g.y!==initialGrid.y||g.height!==570||f.y!==initialFilters.y)throw Error('Search / category changed fixed layout');};
|
||||
await wait(400);const searchBottom=document.querySelector('.launcher-search')!.getBoundingClientRect().bottom;const artworkTop=document.querySelector('.launch-disc')!.getBoundingClientRect().top;if(artworkTop-searchBottom<72)throw Error('Search is too close to the icon artwork');
|
||||
// Every app kind keeps its position when favorited or unfavorited.
|
||||
const initialOrder=labels().join('|');
|
||||
for(const name of ['Termix','Half-Life: Alyx','透视调色','Framely','Desktop terminal','さくら','東京']){
|
||||
const was=favorite(name).getAttribute('aria-pressed')==='true';
|
||||
for(const checked of [!was,was]){
|
||||
favorite(name).click();await until(()=>favorite(name).getAttribute('aria-pressed')===String(checked));await until(()=>!favorite(name).disabled);
|
||||
if(labels().join('|')!==initialOrder)throw Error('Favorite reordered '+name);
|
||||
if(checked){if(!favorite(name).classList.contains('selected')||favorite(name).querySelector('svg')!.getAttribute('fill')!=='currentColor')throw Error('Favorite state is not selected '+name);await wait(200);if(getComputedStyle(favorite(name)).opacity!=='0')throw Error('Favorite should stay hidden without hover '+name);}
|
||||
}
|
||||
}
|
||||
// Explicit manual ordering survives favorite changes, refresh and reopening.
|
||||
order=[...document.querySelectorAll<HTMLElement>('[data-launch-key]')].map(n=>n.dataset.launchKey!).reverse();redraw();await until(()=>labels().join('|')!==initialOrder);const manualOrder=labels().join('|');
|
||||
favorite('Framely').click();await until(()=>saved.includes('framely'));await until(()=>!favorite('Framely').disabled);if(labels().join('|')!==manualOrder)throw Error('Manual order changed after favorite');
|
||||
window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category('All').getAttribute('aria-pressed')!=='true')throw Error('Reopening changed manual-order category');await until(()=>labels().length===7);if(labels().join('|')!==manualOrder)throw Error('Reopening lost manual order');
|
||||
favorite('Framely').click();await until(()=>!saved.includes('framely'));await until(()=>!favorite('Framely').disabled);if(labels().join('|')!==manualOrder)throw Error('Manual order changed after removing favorite');order=[];redraw();await until(()=>labels().join('|')===initialOrder);
|
||||
favorite('Framely').click();await until(()=>saved.includes('framely'));await until(()=>!document.querySelector('.launch-favorite:disabled'));favorite('Desktop terminal').click();await until(()=>saved.includes('desktop:terminal.desktop'));await until(()=>!document.querySelector('.launch-favorite:disabled'));favorite('さくら').click();await until(()=>saved.includes('lepton:default/sakura'));await until(()=>!document.querySelector('.launch-favorite:disabled'));
|
||||
category('Favorites').click();await until(()=>labels().length===5);if(!labels().includes('Framely')||!labels().includes('Desktop terminal')||!labels().includes('さくら'))throw Error('New types absent from Favorites');
|
||||
await wait(500);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_FAVORITES_EN');
|
||||
configureLanguage('zh-CN',[]);redraw();await wait(500);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_FAVORITES_ZH');configureLanguage('en-US',[]);redraw();await wait();
|
||||
// Search remains in the selected category, and key strokes reuse an index.
|
||||
category('Plugin').click();await until(()=>labels().length===3);await until(()=>calls.some(c=>c.method==='launcher.search.index'));await wait(250);const indexCount=calls.filter(c=>c.method==='launcher.search.index').length;
|
||||
for(const q of ['toushi','tiaose','tsts','TSTS']){query(q);await until(()=>labels().length===1);if(labels()[0]!=='透视调色')throw Error('Pinyin search '+q);stable();}
|
||||
query('sakura');await until(()=>labels().length===0);if(!document.body.innerText.includes('No matching apps'))throw Error('Missing no results');query('');await until(()=>labels().length===3);
|
||||
category('Lepton').click();for(const q of ['sakura','skr','SKR']){query(q);await until(()=>labels().length===1);if(labels()[0]!=='さくら')throw Error('Kana search '+q);}query('toukyou');await until(()=>labels().length===1);if(labels()[0]!=='東京')throw Error('Japanese kanji search');query('');
|
||||
category('Steam').click();query('HLA');await until(()=>labels().length===1);if(labels()[0]!=='Half-Life: Alyx')throw Error('English initials');query('');
|
||||
if(calls.filter(c=>c.method==='launcher.search.index').length!==indexCount)throw Error('Typing rebuilt indexes');
|
||||
input().focus();if(getComputedStyle(input()).outlineStyle!=='none'&&getComputedStyle(input()).outlineWidth!=='0px')throw Error('Search focus still has an outline');await until(()=>calls.some(c=>c.method==='host.keyboard'));window.dispatchEvent(new CustomEvent('framely.keyboard',{detail:true}));(window as any).__framelyCommitKeyboard('HLA');await until(()=>input().value==='HLA');const closeCount=calls.filter(c=>c.method==='host.launcher.close').length;window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:2}));window.dispatchEvent(new Event('framely.back'));document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));await wait();if(category('Steam').getAttribute('aria-pressed')!=='true'||calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('Keyboard allowed launcher dismissal/navigation');window.dispatchEvent(new CustomEvent('framely.keyboard',{detail:false}));input().blur();query('');
|
||||
category('Plugin').click();query('tsts');await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SEARCH_EN');configureLanguage('zh-CN',[]);redraw();await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SEARCH_ZH');configureLanguage('en-US',[]);redraw();query('');
|
||||
config={...config,allCategories:[]};redraw();category('All').click();await until(()=>labels().length===0);category('Favorites').click();await until(()=>labels().length===5);category('Plugin').click();await until(()=>labels().length===3);
|
||||
config={...config,allCategories:['desktop']};redraw();category('All').click();await until(()=>labels().join('|')==='Desktop terminal');query('termix');await until(()=>labels().length===0);query('');stable();
|
||||
window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category('All').getAttribute('aria-pressed')!=='true')throw Error('Opening did not retain All');if(input().value!=='')throw Error('Opening did not clear query');
|
||||
// Late refresh must not override an explicit category selection.
|
||||
delay=350;window.dispatchEvent(new Event('framely.launcher.open'));await wait(30);category('Steam').click();await wait(450);if(category('Steam').getAttribute('aria-pressed')!=='true')throw Error('Refresh overwrote category selection');delay=80;
|
||||
saved=[];steamFavorites=[];plugins['demo.alpha'].favorite=false;redraw();window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category('Steam').getAttribute('aria-pressed')!=='true')throw Error('Favorites changed selected category');category('All').click();await until(()=>labels().join('|')==='Desktop terminal');
|
||||
favorite('Desktop terminal').click();await until(()=>saved.length===1);window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category('All').getAttribute('aria-pressed')!=='true')throw Error('Adding favorite changed selected category');category('Favorites').click();await until(()=>labels().length===1);favorite('Desktop terminal').click();await until(()=>labels().length===0);window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category('Favorites').getAttribute('aria-pressed')!=='true'||!document.body.innerText.includes('No favorites yet'))throw Error('Empty favorites changed selected category');
|
||||
for(const label of ['All','Plugin','Steam','Lepton','Desktop apps','Favorites']){category(label).click();window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category(label).getAttribute('aria-pressed')!=='true')throw Error('Did not retain '+label);}
|
||||
window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-2}));await until(()=>category('All').getAttribute('aria-pressed')==='true');window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category('All').getAttribute('aria-pressed')!=='true')throw Error('Joystick category was not retained');
|
||||
if(!matchesSearch('Termix','termix')||!matchesSearch('École','ecole')||matchesSearch('Alpha','beta')||matchesSearch('ガ','カ'))throw Error('Search normalization');
|
||||
settings=true;redraw();await until(()=>document.querySelectorAll('[role=switch]').length===6);const switches=document.querySelectorAll<HTMLInputElement>('[role=switch]');switches[0].click();await until(()=>config.primaryTrigger);await until(()=>!document.querySelector('[role=switch]:disabled'));switches[2].click();await until(()=>(config.allCategories??[]).includes('plugin'));await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');configureLanguage('zh-CN',[]);redraw();await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e+' '+(e as Error).stack);}};
|
||||
createRoot(document.getElementById('root')!).render(<Fixture/>);wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
|
||||
@@ -0,0 +1,35 @@
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));
|
||||
const until=async(f:()=>any)=>{for(let i=0;i<160;i++){if(f())return;await wait(25);}throw Error('Timeout '+document.body.innerText.slice(0,300));};
|
||||
history.replaceState(null,'','/manager');
|
||||
const plugin={manifest:{id:'demo',name:'Demo plugin',version:'1.0.0',description:'A demo plugin',author:'Community',ui:{windows:{}}},enabled:true,favorite:false,order:0};
|
||||
const source={id:'community',name:'Community',url:'https://example.org/catalog.json',enabled:true,allowHttp:false};
|
||||
const sub={id:'daily',name:'Daily sources',url:'https://example.org/subscription.json',enabled:true,autoRefresh:true,entries:[],pending:[],nextRefresh:0};
|
||||
const database:any={plugins:{demo:plugin},sources:[source],subscriptions:[sub],safeMode:false,language:'en-US',launcher:{primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']},networkPanel:{enabled:true,port:15915,passwordEnabled:true},proxy:{http:'',github:''},updateSource:{url:'https://example.org/update.json'},updateCheck:{enabled:true,intervalHours:6}};
|
||||
const calls:any[]=[];
|
||||
window.fetch=async(_url,init)=>{const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
|
||||
if(method==='status')result={version:'0.4.3-preview.2',agreement:{version:1,accepted:true},database,running:[],notifications:[],inbox:[]};
|
||||
else if(method==='language.list')result={packs:[],invalidFiles:[]};else if(method==='ui.events')result={cursor:0,events:[]};else if(method==='network.status')result={passwordConfigured:true,localOrigin:'http://localhost:15915',ips:[],listener:{}};
|
||||
else if(method==='catalog.start')result={job:'catalog'};else if(method==='job.status')result={id:'catalog',phase:'done',result:[{source,catalog:{name:'Community',plugins:[{...plugin.manifest,apiVersion:1,url:'https://example.org/demo.framely',sha256:'a'.repeat(64)}]}}]};
|
||||
else if(method==='network.save')Object.assign(database.networkPanel,params);else if(method==='safeMode')database.safeMode=params.enabled;else if(method==='sources.save')database.sources=params.sources;else if(method==='subscriptions.change')Object.assign(sub,params);else if(method==='system.check.settings.save')database.updateCheck=params.settings;
|
||||
return new Response(JSON.stringify({result}));};
|
||||
const nav=(label:string)=>[...document.querySelectorAll<HTMLButtonElement>('.workspace>nav button')].find(b=>b.textContent===label)!;
|
||||
const width=(selector:string)=>{const node=document.querySelector<HTMLElement>(selector)!;if(!node)throw Error('Missing '+selector);const parent=node.parentElement!;const style=getComputedStyle(parent);const available=parent.clientWidth-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight);if(Math.abs(node.getBoundingClientRect().width-available)>2)throw Error('Unused width '+selector+' '+node.getBoundingClientRect().width+'/'+available);};
|
||||
const switcher=(label:string)=>document.querySelector<HTMLButtonElement>(`[role=switch][aria-label="${label}"]`)!;
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
await until(()=>!!document.querySelector('.plugin-row'));if(!switcher('Enable Demo plugin').classList.contains('ui-switch'))throw Error('Plugin switch is not shared');width('.plugin-row');
|
||||
nav('Settings').click();await until(()=>!!document.querySelector('.network-password'));for(const selector of ['.language-picker','.network-password','.network-port label','.proxy-fields','.switch-row'])width(selector);
|
||||
if(document.querySelector('.content input[type=checkbox]'))throw Error('Checkbox left in settings');
|
||||
switcher('Safe mode').click();await until(()=>database.safeMode);await until(()=>!switcher('Safe mode').disabled);
|
||||
switcher('Use an access password').click();await until(()=>!document.querySelector('.network-password'));switcher('Use an access password').click();await until(()=>!!document.querySelector('.network-password'));
|
||||
await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_MANAGER_SETTINGS_EN');
|
||||
nav('Sources').click();await until(()=>document.querySelectorAll('.source-row').length===2);width('.source-row');switcher('Refresh daily').click();await until(()=>!sub.autoRefresh);await until(()=>!switcher('Refresh daily').disabled);
|
||||
const add=[...document.querySelectorAll<HTMLButtonElement>('.content button')].find(b=>b.textContent==='Add plugin source')!;add.click();await until(()=>!!document.querySelector('.source-form'));width('.source-form');if(document.querySelector('.content input[type=checkbox]'))throw Error('Source still has a checkbox');
|
||||
nav('Plugin library').click();await until(()=>!!document.querySelector('.store-card'));width('.store-card');document.querySelector<HTMLButtonElement>('.store-title')!.click();await until(()=>!!document.querySelector('.store-detail'));width('.store-detail');width('.detail-summary');
|
||||
nav('Launcher').click();await until(()=>!!document.querySelector('.launcher-settings'));width('.launcher-settings .switch-row');width('.launcher-settings label');
|
||||
nav('Notifications').click();await until(()=>!!document.querySelector('.notification-global-options'));width('.notification-global-options');width('.notification-plugin-list');
|
||||
nav('About').click();await until(()=>!!document.querySelector('.automatic-update-settings'));width('.automatic-update-settings .framely-select');if(!switcher('Check for updates automatically').classList.contains('ui-switch'))throw Error('Update switch is not shared');
|
||||
database.language='zh-CN';configureLanguage('zh-CN',[]);nav('Settings').click();await until(()=>document.documentElement.lang==='zh-CN');nav('设置').click();await until(()=>!!document.querySelector('.network-password'));width('.network-password');await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_MANAGER_SETTINGS_ZH');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e+' '+(e as Error).stack);}};
|
||||
void import('../ui/src/main');
|
||||
@@ -0,0 +1,11 @@
|
||||
#include "page_scroll.h"
|
||||
#include <cassert>
|
||||
int main(){
|
||||
auto t=std::chrono::steady_clock::now();PageScrollStick s;
|
||||
assert(!s.update(0,.1f,true,t).y);assert(!s.update(0,1,false,t).y);
|
||||
int sixty=0;for(int i=1;i<=60;i++)sixty+=s.update(0,1,true,t+std::chrono::microseconds(i*1000000/60)).y;
|
||||
s.clear();int ninety=0;for(int i=1;i<=90;i++)ninety+=s.update(0,1,true,t+std::chrono::microseconds(i*1000000/90)).y;
|
||||
assert(std::abs(sixty-ninety)<10);auto d=s.update(.8f,.9f,true,t+std::chrono::seconds(2));assert(d.x==0&&d.y>0&&d.y<=55);
|
||||
d=s.update(1,.2f,true,t+std::chrono::milliseconds(2017));assert(d.x<0&&d.y==0);
|
||||
assert(!s.update(NAN,1,true,t).y);assert(!s.update(0,0,true,t).y);s.clear();assert(s.last.time_since_epoch().count()==0);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import '../ui/src/style.css';
|
||||
import {installScrollbars} from '../sdk/src/scrollbars';
|
||||
installScrollbars();installScrollbars();
|
||||
const wait=(ms=60)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<100;i++){if(f())return;await wait(30);}throw Error('Timeout');};
|
||||
const root=document.getElementById('root')!;root.className='app';root.innerHTML='<div id="outer" style="position:absolute;left:100px;top:70px;width:700px;height:600px;overflow:auto"><div style="height:1800px;background:#1c2a36;color:white"><button id="click" style="margin:50px">Click</button><div id="inner" style="margin:30px;height:240px;overflow:auto;background:#314758"><div style="height:900px"><button id="nested" style="margin:50px">Nested</button></div></div><input id="input" value="Text"><div id="opt" data-framely-no-scroll-drag>Custom gesture</div></div></div>';
|
||||
const outer=document.getElementById('outer')!,inner=document.getElementById('inner')!,button=document.getElementById('click')!;let clicks=0;button.addEventListener('click',()=>clicks++);
|
||||
const event=(target:Element,type:string,x:number,y:number,buttons=1)=>target.dispatchEvent(new PointerEvent(type,{bubbles:true,cancelable:true,pointerId:1,button:0,buttons,clientX:x,clientY:y}));
|
||||
const click=(target:Element)=>target.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
event(button,'pointerdown',120,160);event(button,'pointermove',123,152);event(button,'pointerup',123,152,0);click(button);if(clicks!==1||outer.scrollTop!==0)throw Error('Jitter lost a normal click');
|
||||
event(button,'pointerdown',120,160);event(button,'pointermove',120,100);event(button,'pointerup',120,80,0);click(button);if(clicks!==1||outer.scrollTop!==80)throw Error('Drag clicked or did not follow hand '+outer.scrollTop);outer.scrollTop=0;
|
||||
const nested=document.getElementById('nested')!;event(nested,'pointerdown',160,340);event(nested,'pointermove',160,240);event(nested,'pointerup',160,240,0);if(inner.scrollTop!==100||outer.scrollTop!==0)throw Error('Inner drag scrolled wrong container');await wait(230);
|
||||
inner.scrollTop=inner.scrollHeight-inner.clientHeight;event(nested,'pointerdown',160,340);event(nested,'pointermove',160,280);event(nested,'pointerup',160,280,0);if(outer.scrollTop!==60)throw Error('Nested boundary did not chain');outer.scrollTop=0;inner.scrollTop=0;
|
||||
for(const id of ['input','opt']){const target=document.getElementById(id)!;event(target,'pointerdown',150,350);event(target,'pointermove',150,250);event(target,'pointerup',150,250,0);if(outer.scrollTop!==0)throw Error('Hijacked '+id);}
|
||||
event(button,'pointerdown',120,160);event(button,'pointermove',200,170);event(button,'pointerup',200,170,0);if(outer.scrollTop!==0)throw Error('Horizontal gesture became vertical scroll');
|
||||
event(button,'pointerdown',120,160);event(button,'pointermove',120,100);event(button,'pointercancel',120,100,0);const cancelled=outer.scrollTop;event(button,'pointermove',120,30);if(outer.scrollTop!==cancelled)throw Error('Cancelled drag remained active');outer.scrollTop=0;
|
||||
// Real CEF mouse/laser input, including a scrollable element away from centre.
|
||||
await wait(200);const r=nested.getBoundingClientRect();console.log(`FRAMELY_PAGE_WHEEL_${Math.round(r.x+15)}_${Math.round(r.y+15)}_0_-120`);await until(()=>inner.scrollTop>0);if(outer.scrollTop!==0)throw Error('Native wheel missed inner container');inner.scrollTop=0;
|
||||
const b=button.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(b.x+20)}_${Math.round(b.y+15)}_0_-90_400`);await until(()=>outer.scrollTop>=80);await wait(500);if(clicks!==1)throw Error('Real drag activated button');outer.scrollTop=0;
|
||||
// An already-installed plugin gets gestures from host bootstrap, independently
|
||||
// of this document's listeners and without rebuilding its plugin bundle.
|
||||
const frame=document.createElement('iframe');frame.style.cssText='position:absolute;left:850px;top:200px;width:300px;height:240px;flex:none';frame.srcdoc='<style>body{margin:0}#scroller{height:180px;overflow:auto}#inside{height:800px}</style><div id="scroller"><div id="inside"><button id="plugin-button" style="margin:40px">Plugin</button></div></div><script src="/bootstrap.js"></script>';root.append(frame);
|
||||
await until(()=>!!frame.contentDocument?.getElementById('framely-scrollbars'));const doc=frame.contentDocument!,scroller=doc.getElementById('scroller')!,pb=doc.getElementById('plugin-button')!;let pluginClicks=0;pb.addEventListener('click',()=>pluginClicks++);
|
||||
event(pb,'pointerdown',60,70);event(pb,'pointermove',60,20);event(pb,'pointerup',60,20,0);click(pb);if(scroller.scrollTop!==50||pluginClicks)throw Error('Old plugin bootstrap did not support drag');scroller.scrollTop=0;await wait(350);
|
||||
const f=frame.getBoundingClientRect(),pr=pb.getBoundingClientRect();console.log(`FRAMELY_PAGE_WHEEL_${Math.round(f.x+pr.x+15)}_${Math.round(f.y+pr.y+15)}_0_-120`);await until(()=>scroller.scrollTop>0);if(outer.scrollTop!==0)throw Error('Iframe wheel scrolled the parent');frame.remove();
|
||||
document.documentElement.classList.add('launcher-view');event(button,'pointerdown',120,160);event(button,'pointermove',120,100);event(button,'pointerup',120,100,0);if(outer.scrollTop!==0)throw Error('Launcher gesture intercepted');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e+' '+(e as Error).stack);}};
|
||||
console.log('FRAMELY_VIEW_READY');
|
||||
@@ -0,0 +1,32 @@
|
||||
// Exercise the real quick panel across status polling and repeated host launches.
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));
|
||||
const until=async(f:()=>any)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,350));};
|
||||
history.replaceState(null,'','/?fixture=1#plugin:test.quick');
|
||||
let polls=0,opens=0;
|
||||
window.fetch=async(_url,init)=>{
|
||||
const {method}=JSON.parse(String(init?.body));let result:any=true;
|
||||
if(method==='status'){polls++;result={version:'0.4.3-preview.2',agreement:{version:1,accepted:true},database:{language:'en-US',plugins:{'test.quick':{manifest:{id:'test.quick',name:'Quick fixture',version:'1',author:'Test',description:'',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:true,order:0}},sources:[],safeMode:false},running:[],notifications:[],inbox:[]};}
|
||||
else if(method==='language.list')result={packs:[],invalidFiles:[]};
|
||||
else if(method==='ui.events')result={cursor:0,events:[]};
|
||||
else if(method==='plugin.open')opens++;
|
||||
return new Response(JSON.stringify({result}));
|
||||
};
|
||||
const back=()=>document.querySelector<HTMLButtonElement>('.page-title button')!;
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
await until(()=>!!back()&&!!document.querySelector('iframe'));
|
||||
if(location.hash||location.search!=='?fixture=1')throw Error('Plugin hash was not consumed correctly');
|
||||
back().click();await until(()=>!!document.querySelector('.workspace'));
|
||||
const before=polls;await until(()=>polls>=before+2);
|
||||
if(back()||document.querySelector('iframe')||opens!==1)throw Error('Status polling reopened plugin');
|
||||
location.hash='#plugin:test.quick';await until(()=>!!back()&&opens===2);
|
||||
window.dispatchEvent(new Event('framely.back'));await until(()=>!!document.querySelector('.workspace'));
|
||||
const second=polls;await until(()=>polls>=second+2);
|
||||
if(back()||location.hash||opens!==2)throw Error('Back event reopened plugin');
|
||||
location.hash='#plugin:test.quick';await until(()=>!!back()&&opens===3);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'}));await until(()=>!!document.querySelector('.workspace'));
|
||||
const third=polls;await until(()=>polls>=third+2);
|
||||
if(back()||location.hash||opens!==3)throw Error('Escape reopened plugin');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}
|
||||
};
|
||||
void import('../ui/src/main');
|
||||
@@ -2,7 +2,7 @@
|
||||
#include <cassert>
|
||||
#include <limits>
|
||||
int main(){
|
||||
assert(browser_frame_rate(72)==72);assert(browser_frame_rate(90)==90);assert(browser_frame_rate(120)==120);
|
||||
assert(browser_frame_rate(0)==90);assert(browser_frame_rate(-1)==90);assert(browser_frame_rate(std::numeric_limits<float>::quiet_NaN())==90);
|
||||
assert(browser_frame_rate(30)==60);assert(browser_frame_rate(1000)==144);
|
||||
assert(browser_frame_rate(72)==60);assert(browser_frame_rate(90)==60);assert(browser_frame_rate(120)==60);
|
||||
assert(browser_frame_rate(0)==60);assert(browser_frame_rate(-1)==60);assert(browser_frame_rate(std::numeric_limits<float>::quiet_NaN())==60);
|
||||
assert(browser_frame_rate(30)==60);assert(browser_frame_rate(1000)==60);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// Run: bash tools/test-install-review.sh /path/to/cef tests/system_updates_fixture.tsx
|
||||
import React,{useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {SystemUpdates} from '../ui/src/updates';
|
||||
import {SystemUpdates,sameSystemVersion} from '../ui/src/updates';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
@@ -43,8 +43,8 @@ async function choose(label:string){(document.querySelector('.framely-select but
|
||||
try{
|
||||
configureLanguage('zh-CN',[]);updateStatus({...status});await wait();
|
||||
if(!document.querySelector('.framely-select button')?.textContent?.includes('正式版'))throw Error('legacy source did not default to stable');
|
||||
const automaticSwitch=()=>document.querySelector('.automatic-update-settings input[type=checkbox]') as HTMLInputElement;
|
||||
if(!automaticSwitch().checked)throw Error('automatic checks did not default to enabled');
|
||||
const automaticSwitch=()=>document.querySelector('.automatic-update-settings [role=switch]') as HTMLButtonElement;
|
||||
if(automaticSwitch().getAttribute('aria-checked')!=='true')throw Error('automatic checks did not default to enabled');
|
||||
automaticSwitch().click();await until(()=>automaticSaves===1);await wait();
|
||||
if(status.database.updateCheck.enabled||(document.querySelector('.automatic-update-settings .framely-select button') as HTMLButtonElement).disabled===false)throw Error('disabled automatic check settings not reflected');
|
||||
automaticSwitch().click();await until(()=>automaticSaves===2);await wait();
|
||||
@@ -52,7 +52,12 @@ async function choose(label:string){(document.querySelector('.framely-select but
|
||||
([...document.querySelectorAll('[role=option]')].find(o=>o.textContent==='每 12 小时') as HTMLButtonElement).click();await until(()=>automaticSaves===3);await wait();
|
||||
if(status.database.updateCheck.intervalHours!==12)throw Error('automatic interval not saved');
|
||||
button('检查更新').click();await until(()=>!!document.querySelector('.release-card'));
|
||||
const releaseDialog=document.querySelector('.update-release-dialog') as HTMLDialogElement;
|
||||
if(!releaseDialog?.open||!releaseDialog.matches(':modal')||document.activeElement!==button('稍后'))throw Error('Available release was not announced in a modal');
|
||||
if(!releaseDialog.contains(document.querySelector('.release-card')))throw Error('Release details remained inline');
|
||||
if(!document.querySelector('.release-card')?.textContent?.includes('较旧版本'))throw Error('channel downgrade not identified');
|
||||
window.dispatchEvent(new Event('framely.back'));await wait();if(document.querySelector('.release-card')||downloads!==0||attempts!==0)throw Error('Later left inline release content or started update');
|
||||
button('检查更新').click();await until(()=>!!document.querySelector('.update-release-dialog'));
|
||||
button('下载并安装').focus();button('下载并安装').click();await wait();
|
||||
const dialog=document.querySelector('.update-confirmation') as HTMLDialogElement|null;
|
||||
if(downloads!==0||attempts!==0||!dialog?.open||!dialog.matches(':modal'))throw Error('update bypassed modal confirmation');
|
||||
@@ -75,11 +80,13 @@ async function choose(label:string){(document.querySelector('.framely-select but
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_UPDATE_STABLE');
|
||||
button('安装已校验版本').click();await wait();button('确认安装').click();await until(()=>attempts===2);await wait();
|
||||
if(downloads!==2)throw Error('installation retry downloaded again');
|
||||
button('稍后').click();await wait();
|
||||
await choose('测试版');
|
||||
if(status.database.updateChannel!=='testing'||document.querySelector('.release-card'))throw Error('channel not saved or stale release retained');
|
||||
button('检查更新').click();await until(()=>!!document.querySelector('.release-card'));
|
||||
if(document.querySelector('.release-card')?.textContent?.includes('较旧版本'))throw Error('new preview reported as downgrade');
|
||||
if([...document.querySelectorAll('button')].some(b=>b.textContent==='安装已校验版本'))throw Error('download reused across channels');
|
||||
await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_UPDATE_AVAILABLE_ZH');await wait(100);
|
||||
hold=true;failApply=false;button('下载并安装').click();await wait();button('确认下载并安装').click();await until(()=>downloads===3);await wait();
|
||||
if(!(document.querySelector('.framely-select button') as HTMLButtonElement).disabled||!automaticSwitch().disabled||!button('处理中…').disabled)throw Error('settings mutable during download');
|
||||
if(attempts!==2)throw Error('installation started before verification');
|
||||
@@ -100,18 +107,24 @@ async function choose(label:string){(document.querySelector('.framely-select but
|
||||
empty=false;configureLanguage('en-US',[]);updateStatus({...status});await wait();
|
||||
if(!document.querySelector('.framely-select button')?.textContent?.includes('Stable'))throw Error('English channel untranslated');
|
||||
await choose('Testing');button('Check for updates').click();await until(()=>!!document.querySelector('.release-card'));
|
||||
await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_UPDATE_AVAILABLE_EN');await wait(100);
|
||||
button('Download and install').click();await wait();
|
||||
if(!document.querySelector('.update-confirmation')?.textContent?.includes('installed automatically'))throw Error('English update flow untranslated');
|
||||
hold=true;button('Confirm download and installation').click();
|
||||
await until(()=>!!document.querySelector('progress[aria-label="Verification progress"]'));
|
||||
if(!document.body.textContent?.includes('Download complete. Verifying')||!document.body.textContent?.includes('Verified 0.5 / 1.0 MiB'))throw Error('English verification progress untranslated');
|
||||
hold=false;await until(()=>installs===2);
|
||||
if(checks!==5||downloads!==4||attempts!==4)throw Error(`unexpected workflow counts ${checks}/${downloads}/${attempts}`);
|
||||
if(checks!==6||downloads!==4||attempts!==4)throw Error(`unexpected workflow counts ${checks}/${downloads}/${attempts}`);
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_UPDATE_ENGLISH');
|
||||
remount();await wait();button('Roll back to previous release').click();await wait();
|
||||
if(rollbacks!==0)throw Error('rollback bypassed confirmation');
|
||||
button('Confirm rollback').click();await until(()=>rollbacks===1);
|
||||
if(downloads!==4)throw Error('rollback downloaded a release');
|
||||
if(!sameSystemVersion('0.4.3-preview.2-16320a685fb1','0.4.3-preview.2-3640daa86d54')||sameSystemVersion('0.4.3-preview.3-16320a685fb1','0.4.3-preview.2-3640daa86d54'))throw Error('Build hash changed version comparison');
|
||||
status={...status,version:'0.4.3-preview.2',build:'0.4.3-preview.2-3640daa86d54'};
|
||||
updateStatus({...status});remount();await wait();
|
||||
button('Check for updates').click();await until(()=>!!document.body.textContent?.includes('No update is needed'));
|
||||
if(document.querySelector('.update-release-dialog')||document.querySelector('.update-confirmation')||downloads!==4||attempts!==4)throw Error('Same version with different hash offered installation');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ class CEFRuntimeTests(unittest.TestCase):
|
||||
'lib/cef/libcef.so': 'browser', 'lib/cef/icudtl.dat': 'icu',
|
||||
'lib/cef/locales/en-US.pak': 'locale', 'share/licenses/cef.txt': 'license',
|
||||
'share/licenses/cef-credits.html': 'credits', 'share/licenses/openvr.txt': 'openvr',
|
||||
'share/search/ipadic.dic.zst': 'dictionary', 'share/search/COPYING': 'dictionary license',
|
||||
'install.sh': 'install', 'uninstall.sh': 'uninstall'}
|
||||
for name, data in files.items():
|
||||
p = stage / name
|
||||
@@ -62,6 +63,12 @@ class CEFRuntimeTests(unittest.TestCase):
|
||||
extracted = bootstrap.extract_archive(archive, folder / archive.name.replace('.tar.gz', ''),
|
||||
runtime=archive.name.startswith('framely-cef-'))
|
||||
subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=extracted, check=True)
|
||||
if not archive.name.startswith('framely-cef-'):
|
||||
self.assertEqual((extracted / 'share/search/ipadic.dic.zst').read_text(), 'dictionary')
|
||||
self.assertEqual((extracted / 'share/search/COPYING').read_text(), 'dictionary license')
|
||||
self.assertIn('share/search/ipadic.dic.zst', (extracted / 'SHA256SUMS').read_text())
|
||||
else:
|
||||
self.assertFalse((extracted / 'share/search').exists())
|
||||
if archive.name == stage.name + '-linux-arm64.tar.gz':
|
||||
self.assertFalse((extracted / 'lib/cef/libcef.so').exists())
|
||||
self.assertFalse((extracted / 'share/licenses/cef-credits.html').exists())
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Run the production reinstall block against retained uninstall state."""
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
class ReinstallStateTests(unittest.TestCase):
|
||||
def test_only_clean_reinstall_clears_uninstall_guard(self):
|
||||
source = (pathlib.Path(__file__).parents[1] / 'packaging/install.sh').read_text()
|
||||
block = source.split('# Clear the uninstall guard retained by older releases', 1)[1].split('ln -s "releases/$version"', 1)[0]
|
||||
block = '# Clear the uninstall guard retained by older releases' + block
|
||||
for repair, old, plugins, expected in [
|
||||
('false', '', {}, False),
|
||||
('true', '', {}, True),
|
||||
('false', 'releases/current', {}, True),
|
||||
('false', '', {'demo': {'enabled': False}}, True),
|
||||
]:
|
||||
with self.subTest(repair=repair, old=old, plugins=plugins), tempfile.TemporaryDirectory() as temp:
|
||||
root = pathlib.Path(temp)
|
||||
path = root / 'state.json'
|
||||
original = {'safeMode': True, 'plugins': plugins, 'language': 'en-US', 'sources': [{'url': 'https://example.org'}]}
|
||||
path.write_text(json.dumps(original))
|
||||
result = subprocess.run(['bash', '-e', '-c', 'root=$1; repair=$2; old=$3\n' + block, 'reinstall', str(root), repair, old], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
expected_state = dict(original, safeMode=expected)
|
||||
self.assertEqual(json.loads(path.read_text()), expected_state)
|
||||
if expected:
|
||||
self.assertEqual(path.read_text(), json.dumps(original))
|
||||
else:
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -11,7 +11,7 @@ npm run build
|
||||
bash tools/build-native.sh "$cef"
|
||||
binary=${CARGO_TARGET_DIR:-target}/release/framely
|
||||
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
build_id="$version-$(find "$binary" target/native ui/dist sdk templates/plugin examples/showcase packaging tools docs assets/branding README.md README.zh-CN.md LICENSE -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)"
|
||||
build_id="$version-$(find "$binary" target/native ui/dist sdk templates/plugin examples/showcase packaging tools docs assets/branding assets/search README.md README.zh-CN.md LICENSE -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)"
|
||||
stage="$base/release/framely-$build_id"
|
||||
[[ ! -e $stage ]] || { echo 'Release already exists.' >&2; exit 1; }
|
||||
mkdir -p "$stage"/{bin,lib/cef,lib/openvr,share/ui,share/licenses,tools}
|
||||
@@ -24,6 +24,9 @@ rm -f "$stage/lib/cef/framely-probe" "$stage/lib/cef/framely-browser-probe"
|
||||
strip --strip-debug "$stage/lib/cef/libcef.so"
|
||||
cp native/vendor/openvr/lib/linuxarm64/libopenvr_api.so "$stage/lib/openvr/"
|
||||
cp -a ui/dist/. "$stage/share/ui/"
|
||||
cp -a assets/search "$stage/share/"
|
||||
cp assets/search/COPYING "$stage/share/licenses/ipadic.txt"
|
||||
cp assets/search/NOTICE "$stage/share/licenses/ipadic-notice.txt"
|
||||
cp -a native/input "$stage/share/"
|
||||
cp "$cef/LICENSE.txt" "$stage/share/licenses/cef.txt"
|
||||
cp native/vendor/openvr/LICENSE "$stage/share/licenses/openvr.txt"
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$base"
|
||||
mkdir -p target/tests
|
||||
for test in menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_pointer paint_buffer render_timing; do
|
||||
for test in menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_pointer page_scroll paint_buffer render_timing; do
|
||||
"${CXX:-g++}" -std=c++17 -O2 -Inative -Inative/vendor/openvr "tests/$test.cpp" -o "target/tests/$test"
|
||||
"target/tests/$test"
|
||||
done
|
||||
+15
-1
@@ -469,6 +469,20 @@
|
||||
"日志已保存到:{0}": "Logs saved to: {0}",
|
||||
"日志包已生成,请查看浏览器下载。": "Log archive created. Check your browser downloads.",
|
||||
"日志包缺少数据": "Log archive data is missing",
|
||||
"导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "Export Framely, plugin and renderer logs with service status. Plugin settings and access password files are excluded."
|
||||
"导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "Export Framely, plugin and renderer logs with service status. Plugin settings and access password files are excluded.",
|
||||
"发现可用发行版本": "Release available",
|
||||
"稍后": "Later",
|
||||
"全部包含的分类": "Categories included in All",
|
||||
"只影响全部分类,其他分类和收藏仍可访问。": "Only affects All. Other categories and Favorites remain available.",
|
||||
"包含 Framely": "Includes Framely",
|
||||
"启动方式、菜单与分类。": "Launch controls, menus and categories.",
|
||||
"没有匹配的应用": "No matching apps",
|
||||
"还没有收藏的应用": "No favorites yet",
|
||||
"收藏分类": "Favorites",
|
||||
"读音搜索暂不可用,仍可使用原文搜索。": "Phonetic search is unavailable. You can still search original names.",
|
||||
"启动方式与菜单": "Launch controls and menus",
|
||||
"当前已是此版本,无需重复更新。": "This version is already installed. No update is needed.",
|
||||
"快捷操作": "Quick actions",
|
||||
"操作等待超时;请确认应用是否已打开后再重试。": "The operation timed out. Check whether the application opened before retrying."
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -469,6 +469,20 @@
|
||||
"日志已保存到:{0}": "日志已保存到:{0}",
|
||||
"日志包已生成,请查看浏览器下载。": "日志包已生成,请查看浏览器下载。",
|
||||
"日志包缺少数据": "日志包缺少数据",
|
||||
"导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。"
|
||||
"导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。",
|
||||
"发现可用发行版本": "发现可用发行版本",
|
||||
"稍后": "稍后",
|
||||
"全部包含的分类": "全部包含的分类",
|
||||
"只影响全部分类,其他分类和收藏仍可访问。": "只影响全部分类,其他分类和收藏仍可访问。",
|
||||
"包含 Framely": "包含 Framely",
|
||||
"启动方式、菜单与分类。": "启动方式、菜单与分类。",
|
||||
"没有匹配的应用": "没有匹配的应用",
|
||||
"还没有收藏的应用": "还没有收藏的应用",
|
||||
"收藏分类": "收藏",
|
||||
"读音搜索暂不可用,仍可使用原文搜索。": "读音搜索暂不可用,仍可使用原文搜索。",
|
||||
"启动方式与菜单": "启动方式与菜单",
|
||||
"当前已是此版本,无需重复更新。": "当前已是此版本,无需重复更新。",
|
||||
"快捷操作": "快捷操作",
|
||||
"操作等待超时;请确认应用是否已打开后再重试。": "操作等待超时;请确认应用是否已打开后再重试。"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import {t} from './i18n';
|
||||
import {installKeyboard} from '../../sdk/src/keyboard';
|
||||
import {installHoverFeedback} from '../../sdk/src/hover';
|
||||
export async function api<T=any>(method:string,params:unknown={}):Promise<T>{const r=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method,params})});if(r.status===401&&r.headers.get('X-Framely-Reauthenticate')==='1')location.replace('/manager');if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));return v.result;}
|
||||
export async function api<T=any>(method:string,params:unknown={},signal?:AbortSignal):Promise<T>{const r=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method,params}),signal});if(r.status===401&&r.headers.get('X-Framely-Reauthenticate')==='1')location.replace('/manager');if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));return v.result;}
|
||||
export function viewKey(){const p=location.pathname.split('/');return p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';}
|
||||
installKeyboard(params=>api('host.keyboard',{view:viewKey(),...params}));
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export type SearchIndex={terms:string[]};
|
||||
export function normalizeSearch(value:string){return value.normalize('NFKC').normalize('NFD').replace(/\p{M}/gu,mark=>mark==='\u3099'||mark==='\u309a'?mark:'').normalize('NFC').toLowerCase().replace(/[\s\p{P}\p{S}]/gu,'');}
|
||||
export function matchesSearch(name:string,query:string,index?:SearchIndex){const needle=normalizeSearch(query);return !needle||[normalizeSearch(name),...(index?.terms??[])].some(term=>term.includes(needle));}
|
||||
+26
-10
@@ -2,17 +2,17 @@
|
||||
Layer order: content 0, categories 2, context menu 3, feedback 4, dialog 5. */
|
||||
.launcher-view,.launcher-view body,.launcher-view #root{background:transparent!important}
|
||||
.launcher-shell{--launch-ink:#eff4f8;--launch-blue:#b4dbf4;--launch-ease:cubic-bezier(.2,.75,.2,1);height:100dvh;min-height:600px;padding:90px 60px 104px;color:var(--launch-ink);background:transparent;display:flex;flex-direction:column;position:relative;overflow:hidden;font-size:20px}
|
||||
.launcher-tools{position:absolute;left:0;top:calc(50% - 7px);transform:translateY(-50%);display:flex;flex-direction:column;gap:5px;padding:7px;border:1px solid #e8f4ff30;border-radius:999px;background:#a7bac72b;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b;z-index:2}
|
||||
.launcher-filters button{position:relative;width:56px;height:56px;min-height:56px;padding:15px;border:0;border-radius:50%;background:transparent;color:#d9e3ec;transition:background .2s var(--launch-ease),color .2s var(--launch-ease)}
|
||||
.launcher-tools{position:absolute;left:0;top:calc(50% + 17px);transform:translateY(-50%);display:flex;flex-direction:column;gap:5px;padding:7px;border:1px solid #e8f4ff30;border-radius:999px;background:#a7bac72b;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b;z-index:2}
|
||||
:is(.launcher-filters,.launcher-shortcuts) button{position:relative;width:56px;height:56px;min-height:56px;padding:15px;border:0;border-radius:50%;background:transparent;color:#d9e3ec;transition:background .2s var(--launch-ease),color .2s var(--launch-ease)}
|
||||
.launcher-category-active{position:absolute;left:7px;top:7px;width:56px;height:56px;border-radius:50%;background:#d3e4ef35;box-shadow:inset 0 1px 0 #eef9ff40,inset 0 0 0 1px #e0f2ff19;pointer-events:none;transition:transform 360ms cubic-bezier(.22,.65,.24,1)}
|
||||
.launcher-filters button.active{background:transparent;box-shadow:none;color:var(--launch-blue)}
|
||||
.launcher-filters button:hover:not(:disabled){background:#e3f1ff26;color:#f3f8fc}
|
||||
:is(.launcher-filters,.launcher-shortcuts) button.active{background:transparent;box-shadow:none;color:var(--launch-blue)}
|
||||
:is(.launcher-filters,.launcher-shortcuts) button:hover:not(:disabled){background:#e3f1ff26;color:#f3f8fc}
|
||||
.launcher-category-label{position:absolute;left:calc(100% + 20px);white-space:nowrap;font-size:15px;font-weight:500;line-height:1.4;padding:7px 13px;border-radius:12px;background:#1d2934eb;color:#eaf4fb;box-shadow:inset 0 0 0 1px #e2f0fa1f;opacity:0;pointer-events:none;transition:opacity .18s}
|
||||
.launcher-filters button:hover .launcher-category-label,.launcher-filters button:focus-visible .launcher-category-label{opacity:1}
|
||||
.launcher-grid{position:absolute;left:50%;top:calc(50% - 7px);height:570px;width:calc(100% - 90px);transform:translate(-50%,-50%);isolation:isolate}
|
||||
:is(.launcher-filters,.launcher-shortcuts) button:hover .launcher-category-label,:is(.launcher-filters,.launcher-shortcuts) button:focus-visible .launcher-category-label{opacity:1}
|
||||
.launcher-grid{position:absolute;left:50%;top:calc(50% + 17px);height:570px;width:calc(100% - 140px);transform:translate(-50%,-50%);isolation:isolate}
|
||||
.launcher-page,.launcher-adjacent-page{position:absolute;inset:0;display:grid;grid-template-columns:repeat(24,minmax(0,1fr));grid-template-rows:repeat(6,minmax(0,1fr));gap:12px}
|
||||
.launch-item{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;min-width:0;padding:4px}
|
||||
.launch-icon{position:relative;width:138px;height:122px;min-height:122px;border:0;background:transparent;padding:13px 21px;border-radius:50%;cursor:pointer;touch-action:none;isolation:isolate}
|
||||
.launch-icon{display:flex;align-items:center;justify-content:center;position:relative;width:138px;height:122px;min-height:122px;border:0;background:transparent;padding:13px 21px;border-radius:50%;cursor:pointer;touch-action:none;isolation:isolate}
|
||||
.launch-icon:hover:not(:disabled){background:transparent}
|
||||
.launch-disc{position:relative;display:flex;align-items:center;justify-content:center;width:96px;height:96px;flex:none;border-radius:50%;background:#283640bd;color:#dcebf6;box-shadow:inset 0 1px 1px #edf7ff38,inset 0 -2px 3px #06122150,0 9px 20px #08162533;overflow:hidden;transition:transform .23s var(--launch-ease),box-shadow .23s var(--launch-ease)}
|
||||
.launch-disc img{position:absolute;inset:10%;display:block;width:80%;height:80%;max-width:none;max-height:none;object-fit:contain;object-position:center;border:0;pointer-events:none;filter:drop-shadow(0 2px 3px #0d162638)}
|
||||
@@ -22,9 +22,9 @@
|
||||
.launch-monogram{font-size:32px;font-weight:520;letter-spacing:-.035em;color:#d9e9f3;line-height:1;pointer-events:none}
|
||||
.launch-icon:hover:not(:disabled) .launch-disc,.launch-icon:focus-visible .launch-disc{transform:translateY(-3px) scale(1.055);box-shadow:inset 0 1px 2px #edf7ff60,0 14px 28px #07172b40}
|
||||
.launch-icon:active:not(:disabled) .launch-disc{transform:scale(.97);box-shadow:inset 0 1px 2px #edf7ff38,0 5px 12px #07172b28}
|
||||
.launch-name{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-align:center;font-size:18px;font-weight:450;line-height:1.28;letter-spacing:.005em;width:100%;max-width:164px;height:47px;color:#eff4f8;text-shadow:0 1px 3px #0c172cd9,0 2px 10px #0c172c70;pointer-events:none}
|
||||
.launch-name{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-align:center;font-size:16px;font-weight:450;line-height:1.28;letter-spacing:.005em;width:100%;max-width:164px;height:47px;color:#eff4f8;text-shadow:0 1px 3px #0c172cd9,0 2px 10px #0c172c70;pointer-events:none}
|
||||
.launch-favorite{position:absolute;top:10px;right:5px;padding:7px;background:#263440ce;border:1px solid #e2effa2c;border-radius:50%;color:#dbe6f0;opacity:0;min-height:32px;width:32px;height:32px;transition:opacity .16s;z-index:1}
|
||||
.launch-item:hover .launch-favorite,.launch-favorite:focus-visible{opacity:1}.launch-favorite.selected{color:var(--launch-blue)}
|
||||
.launch-item:hover .launch-favorite,.launch-favorite:focus-visible{opacity:1}.launch-favorite.selected{color:var(--launch-blue);background:#142b3aee;border-color:#a6d4f280;box-shadow:0 2px 8px #07172b40}.launch-favorite.selected:hover:not(:disabled){background:#25465bee;border-color:#bfe4ff}
|
||||
.launch-item-expanded{z-index:3}
|
||||
.launch-menu{position:absolute;top:calc(100% - 6px);left:50%;transform:translateX(-50%);width:260px;max-height:404px;overflow:auto;padding:7px;background:#24323eef;border:1px solid #e6f3ff30;border-radius:22px;box-shadow:inset 0 1px 0 #ecf6ff25,0 14px 36px #05101f4a;z-index:3;animation:launcher-menu-in .18s var(--launch-ease)}
|
||||
.launch-menu.align-right{left:auto;right:0;transform:none}.launch-menu.above{top:auto;bottom:100%}
|
||||
@@ -44,7 +44,7 @@
|
||||
@keyframes launcher-menu-in{from{opacity:0;translate:0 6px}to{opacity:1;translate:0 0}}
|
||||
@media(max-width:900px){.launcher-shell{padding:65px 22px 80px 96px}.launcher-grid{width:calc(100% - 176px)}.launcher-page,.launcher-adjacent-page{gap:8px}.launch-icon{width:92px;height:92px;min-height:92px}.launch-disc{width:82px;height:82px}.launch-name{font-size:15px;height:39px}.launch-monogram{font-size:26px}.launcher-error,.launcher-shell>.banner{left:96px;right:22px}.launcher-pages{left:50%}.launcher-drag-edge.right{right:88px}}
|
||||
@media(prefers-reduced-transparency:reduce){.launch-disc{background:#273644}.launcher-tools{background:#273644}.launch-menu{background:#20303d}}
|
||||
@media(prefers-reduced-motion:reduce){.launch-disc,.launch-favorite,.launcher-category-label,.launcher-filters button,.launcher-category-active,.launcher-pages button span{transition:none}.launch-menu,.launch-item{animation:none}}
|
||||
@media(prefers-reduced-motion:reduce){.launch-disc,.launch-favorite,.launcher-category-label,:is(.launcher-filters,.launcher-shortcuts) button,.launcher-category-active,.launcher-pages button span{transition:none}.launch-menu,.launch-item{animation:none}}
|
||||
.launcher-settings label{display:flex;align-items:center;justify-content:space-between;padding:16px 0;width:100%;gap:24px;border-bottom:1px solid #ffffff10}.launcher-settings label small{display:block;color:#a1adb7;margin-top:6px}.launcher-settings input[type=checkbox]{width:26px;height:26px}.launcher-settings input[type=number]{width:100px;padding:10px;background:#111a22;color:#fff;border:1px solid #44515c;border-radius:8px;font:inherit}
|
||||
.launcher-settings input[type=number]:not([type=checkbox]){width:100px;margin:0}
|
||||
.launcher-edit-toolbar{position:absolute;top:26px;left:160px;right:70px;display:flex;align-items:center;justify-content:space-between;gap:24px;z-index:4;color:#edf5fb;font-size:17px;text-shadow:0 1px 4px #061320}
|
||||
@@ -60,3 +60,19 @@
|
||||
@media(max-width:900px){.launcher-drag-edge.right{right:88px}}
|
||||
|
||||
.launcher-panning{overflow:hidden}.launcher-adjacent-page{pointer-events:none}.launcher-adjacent-page .launch-icon{display:block}.launcher-panning .launcher-page{will-change:translate}
|
||||
/* Search shares the category capsule material without moving the icon canvas. */
|
||||
.launcher-search{position:absolute;top:20px;left:50%;transform:translateX(-50%);width:420px;height:56px;display:flex;align-items:center;gap:12px;padding:0 18px;border:1px solid #e8f4ff30;border-radius:999px;background:#a7bac72b;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b;z-index:2;color:#d9e3ec}
|
||||
.launcher-search:focus-within{background:#a7bac73b;box-shadow:inset 0 1px 1px #f0f7ff40,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b}
|
||||
.launcher-search input,.launcher-search input:focus,.launcher-search input:focus-visible{flex:1;min-width:0;width:0;height:100%;padding:0;border:0!important;outline:0;background:transparent!important;color:var(--launch-ink);font-size:18px;box-shadow:none!important}
|
||||
.launcher-search input::placeholder{color:#d9e3ec9c}
|
||||
.launcher-search input::-webkit-search-cancel-button{display:none}
|
||||
.launcher-search button{display:flex;align-items:center;justify-content:center;width:32px;height:32px;min-height:32px;padding:0;border:0;border-radius:50%;background:transparent;color:inherit}
|
||||
.launcher-search button:hover{background:#e3f1ff26}
|
||||
.launcher-editing .launcher-search{visibility:hidden}
|
||||
|
||||
.launcher-initializing .launcher-grid,.launcher-initializing .launcher-category-active{visibility:hidden}
|
||||
|
||||
.launcher-settings>label{max-width:none}
|
||||
|
||||
.launcher-shortcuts{left:auto;right:0}
|
||||
.launcher-shortcuts .launcher-category-label{left:auto;right:calc(100% + 20px)}
|
||||
+60
-34
@@ -1,13 +1,15 @@
|
||||
import React,{useEffect,useLayoutEffect,useRef,useState} from 'react';
|
||||
import {SwitchRow} from './switch';
|
||||
import React,{useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
|
||||
import {api} from './api';
|
||||
import {t} from './i18n';
|
||||
import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck} from '@tabler/icons-react';
|
||||
import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck,IconSearch,IconX,IconApps,IconSettings} from '@tabler/icons-react';
|
||||
import {installedIcon} from './store';
|
||||
import {PluginFrame} from './plugin-frame';
|
||||
import type {Plugin} from './main';
|
||||
import './launcher.css';
|
||||
export type LauncherConfig={primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number};
|
||||
const defaults:LauncherConfig={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10};
|
||||
import {matchesSearch,type SearchIndex} from './launcher-search';
|
||||
export type LauncherConfig={primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
|
||||
const defaults:LauncherConfig={primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
|
||||
type SteamApp={id:number;name:string;icon?:string;iconFit?:string;kind?:string};
|
||||
function LauncherArtwork({src,name,kind,fit}:{src?:string|null;name:string;kind:string;fit?:string}){
|
||||
const [failed,setFailed]=useState(false);
|
||||
@@ -21,30 +23,43 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
|
||||
const[busy,setBusy]=useState(false),[error,setError]=useState(''),[seconds,setSeconds]=useState(config.menuTimeoutSeconds);
|
||||
useEffect(()=>setSeconds(config.menuTimeoutSeconds),[config.menuTimeoutSeconds]);
|
||||
async function save(p:Partial<LauncherConfig>){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
return <section className="settings-card launcher-settings"><h2>{t('启动台')}</h2><label><span><b>{t('主扳机打开启动台')}</b><small>{t('开启后,上扳机打开快捷面板。')}</small></span><input type="checkbox" role="switch" checked={config.primaryTrigger} disabled={busy} onChange={e=>void save({primaryTrigger:e.target.checked})}/></label><label><span><b>{t('长按菜单自动关闭')}</b><small>{t('指针离开后开始计时,移入后取消。')}</small></span><input type="checkbox" role="switch" checked={config.menuAutoClose} disabled={busy} onChange={e=>void save({menuAutoClose:e.target.checked})}/></label><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section>;
|
||||
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('主扳机打开启动台')} description={t('开启后,上扳机打开快捷面板。')} checked={config.primaryTrigger} disabled={busy} onChange={primaryTrigger=>void save({primaryTrigger})}/><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section></>;
|
||||
}
|
||||
type LocalApp={id:string;name:string;kind:'desktop'|'lepton';icon?:string};
|
||||
type LaunchItem={key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean};
|
||||
const emptyOrder:string[]=[];
|
||||
const categories=[{key:'all',label:'全部',icon:IconLayoutGrid},{key:'plugin',label:'插件',icon:IconPuzzle},{key:'steam',label:'Steam',icon:IconBrandSteam},{key:'lepton',label:'Lepton',icon:IconBrandAndroid},{key:'desktop',label:'桌面程序',icon:IconDeviceDesktop}];
|
||||
export function Launcher({plugins,safeMode,config=defaults,favorites,order=emptyOrder,refresh}:{plugins:Record<string,Plugin>;safeMode:boolean;config?:LauncherConfig;favorites:number[];order?:string[];refresh:()=>Promise<void>}){
|
||||
const[steam,setSteam]=useState<SteamApp[]>([]),[local,setLocal]=useState<LocalApp[]>([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[page,setPage]=useState(0),[menu,setMenu]=useState<string|null>(null),[busy,setBusy]=useState(false),[uninstall,setUninstall]=useState<(Plugin&{dependents:string[];fingerprint:string})|null>(null),[frontend,setFrontend]=useState<{plugin:string;action:string;context:any}|null>(null),[outside,setOutside]=useState(false),[editing,setEditing]=useState(false),[manual,setManual]=useState(order),[dragging,setDragging]=useState<string|null>(null),[ghost,setGhost]=useState<{item:LaunchItem;x:number;y:number}|null>(null),[edge,setEdge]=useState(0),[panView,setPanView]=useState<{offset:number;direction:number;width:number;settling:boolean}|null>(null);
|
||||
const panTimer=useRef<ReturnType<typeof setTimeout>|null>(null),skipPageAnimation=useRef(false),grid=useRef<HTMLDivElement>(null),pageContent=useRef<HTMLDivElement>(null),swipe=useRef<{x:number;y:number;active?:boolean;dx?:number}|null>(null),shell=useRef<HTMLDivElement>(null),hold=useRef<ReturnType<typeof setTimeout>|null>(null),editHold=useRef<ReturnType<typeof setTimeout>|null>(null),edgeTimer=useRef<ReturnType<typeof setTimeout>|null>(null),long=useRef(false),point=useRef({x:0,y:0}),holdTarget=useRef<string|null>(null),mounted=useRef(true),busyRef=useRef(false),frontTimer=useRef<ReturnType<typeof setTimeout>|null>(null),menuRef=useRef<HTMLDivElement>(null),drag=useRef<{key:string;before:string[];order:string[];moved:boolean;over:string|null;edge:number;lastX:number;lastY:number;startedInEdit:boolean}|null>(null),state=useRef({editing:false,current:0,pages:1,filter:'all',panning:false,items:[] as LaunchItem[],all:[] as LaunchItem[]});
|
||||
const categories=[{key:'favorites',label:'收藏分类',icon:IconStar},{key:'all',label:'全部',icon:IconLayoutGrid},{key:'plugin',label:'插件',icon:IconPuzzle},{key:'steam',label:'Steam',icon:IconBrandSteam},{key:'lepton',label:'Lepton',icon:IconBrandAndroid},{key:'desktop',label:'桌面程序',icon:IconDeviceDesktop}];
|
||||
async function launcherRequest<T=any>(method:string,params:unknown={}):Promise<T>{
|
||||
const controller=new AbortController();
|
||||
const timer=setTimeout(()=>controller.abort(),60000);
|
||||
try{return await api<T>(method,params,controller.signal);}
|
||||
catch(error){if(controller.signal.aborted)throw new Error(t('操作等待超时;请确认应用是否已打开后再重试。'));throw error;}
|
||||
finally{clearTimeout(timer);}
|
||||
}
|
||||
export function Launcher({plugins,safeMode,config=defaults,favorites,localFavorites=emptyOrder,order=emptyOrder,refresh}:{plugins:Record<string,Plugin>;safeMode:boolean;config?:LauncherConfig;favorites:number[];localFavorites?:string[];order?:string[];refresh:()=>Promise<void>}){
|
||||
const remembered=useRef<string|null>(null);
|
||||
const[query,setQuery]=useState(''),[indexes,setIndexes]=useState<Record<string,SearchIndex>>({}),keyboard=useRef(false),opening=useRef(true),props=useRef({plugins,favorites,localFavorites});props.current={plugins,favorites,localFavorites};
|
||||
const[steam,setSteam]=useState<SteamApp[]>([]),[local,setLocal]=useState<LocalApp[]>([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[page,setPage]=useState(0),[menu,setMenu]=useState<string|null>(null),[busyKeys,setBusyKeys]=useState<Set<string>>(new Set()),[uninstall,setUninstall]=useState<(Plugin&{dependents:string[];fingerprint:string})|null>(null),[frontends,setFrontends]=useState<Record<string,{plugin:string;action:string;context:any;ticket:number}>>({}),[outside,setOutside]=useState(false),[editing,setEditing]=useState(false),[manual,setManual]=useState(order),[dragging,setDragging]=useState<string|null>(null),[ghost,setGhost]=useState<{item:LaunchItem;x:number;y:number}|null>(null),[edge,setEdge]=useState(0),[panView,setPanView]=useState<{offset:number;direction:number;width:number;settling:boolean}|null>(null);
|
||||
const panTimer=useRef<ReturnType<typeof setTimeout>|null>(null),skipPageAnimation=useRef(false),grid=useRef<HTMLDivElement>(null),pageContent=useRef<HTMLDivElement>(null),swipe=useRef<{x:number;y:number;active?:boolean;dx?:number}|null>(null),shell=useRef<HTMLDivElement>(null),hold=useRef<ReturnType<typeof setTimeout>|null>(null),editHold=useRef<ReturnType<typeof setTimeout>|null>(null),edgeTimer=useRef<ReturnType<typeof setTimeout>|null>(null),long=useRef(false),point=useRef({x:0,y:0}),holdTarget=useRef<string|null>(null),mounted=useRef(true),operations=useRef(new Map<string,{started:number;expire:()=>void}>()),actionSequence=useRef(0),menuRef=useRef<HTMLDivElement>(null),drag=useRef<{key:string;before:string[];order:string[];moved:boolean;over:string|null;edge:number;lastX:number;lastY:number;startedInEdit:boolean}|null>(null),state=useRef({editing:false,current:0,pages:1,filter:'all',panning:false,items:[] as LaunchItem[],all:[] as LaunchItem[]});
|
||||
useEffect(()=>{if(!drag.current)setManual(order);},[order]);
|
||||
function selectCategory(key:string){remembered.current=key;opening.current=false;setFilter(key);}
|
||||
function cancelHold(){if(hold.current)clearTimeout(hold.current);if(editHold.current)clearTimeout(editHold.current);hold.current=null;editHold.current=null;holdTarget.current=null;}
|
||||
function stopEdge(){if(edgeTimer.current)clearTimeout(edgeTimer.current);edgeTimer.current=null;setEdge(0);if(drag.current)drag.current.edge=0;}
|
||||
function cancelDrag(){if(drag.current)setManual(drag.current.before);drag.current=null;setDragging(null);setGhost(null);stopEdge();}
|
||||
useEffect(()=>{mounted.current=true;let live=true,sequence=0;const load=()=>{cancelHold();cancelDrag();cancelPan();setEditing(false);long.current=false;const current=++sequence;setLoading(true);setMenu(null);setOutside(false);void Promise.allSettled([api<SteamApp[]>('steam.list'),api<LocalApp[]>('desktop.list')]).then(results=>{if(!live||current!==sequence)return;const [steamResult,localResult]=results;if(steamResult.status==='fulfilled')setSteam(steamResult.value);else setError(String(steamResult.reason));if(localResult.status==='fulfilled')setLocal(localResult.value);else setError(String(localResult.reason));setLoading(false);});};load();const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;setMenu(null);setEditing(false);};window.addEventListener('framely.launcher.cancelInput',cancel);window.addEventListener('framely.launcher.open',load);return()=>{live=false;mounted.current=false;window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(frontTimer.current)clearTimeout(frontTimer.current);if(panTimer.current)clearTimeout(panTimer.current);};},[]);
|
||||
useEffect(()=>{mounted.current=true;let live=true,sequence=0;const load=()=>{for(const operation of operations.current.values())if(Date.now()-operation.started>=120000)operation.expire();cancelHold();cancelDrag();cancelPan();setEditing(false);long.current=false;const current=++sequence;opening.current=remembered.current===null;setQuery('');setPage(0);setLoading(true);setMenu(null);setOutside(false);void Promise.allSettled([launcherRequest<SteamApp[]>('steam.list'),launcherRequest<LocalApp[]>('desktop.list')]).then(results=>{if(!live||current!==sequence)return;const [steamResult,localResult]=results;if(steamResult.status==='fulfilled')setSteam(steamResult.value);else setError(String(steamResult.reason));if(localResult.status==='fulfilled')setLocal(localResult.value);else setError(String(localResult.reason));if(opening.current){const known=props.current;const hasFavorite=Object.values(known.plugins).some(p=>p.enabled&&p.favorite)||(steamResult.status==='fulfilled'&&steamResult.value.some(a=>known.favorites.includes(a.id)))||known.localFavorites.includes('framely')||(localResult.status==='fulfilled'&&localResult.value.some(a=>known.localFavorites.includes(`${a.kind}:${a.id}`)));selectCategory(hasFavorite?'favorites':'all');}setLoading(false);});};load();const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;setMenu(null);setEditing(false);};window.addEventListener('framely.launcher.cancelInput',cancel);window.addEventListener('framely.launcher.open',load);return()=>{live=false;mounted.current=false;for(const operation of operations.current.values())operation.expire();for(const key of pending.current.keys())done(key,'Launcher closed');window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(panTimer.current)clearTimeout(panTimer.current);};},[]);
|
||||
useEffect(()=>{if(!drag.current)cancelHold();setMenu(null);setOutside(false);},[filter,page]);
|
||||
useEffect(()=>{if(!menu||!outside||!config.menuAutoClose||busy)return;const timer=setTimeout(()=>setMenu(null),config.menuTimeoutSeconds*1000);return()=>clearTimeout(timer);},[menu,outside,busy,config.menuAutoClose,config.menuTimeoutSeconds]);
|
||||
useEffect(()=>{if(!menu||!outside||!config.menuAutoClose||!!(menu&&busyKeys.has(menu)))return;const timer=setTimeout(()=>setMenu(null),config.menuTimeoutSeconds*1000);return()=>clearTimeout(timer);},[menu,outside,busyKeys,config.menuAutoClose,config.menuTimeoutSeconds]);
|
||||
function cancelPan(){if(panTimer.current)clearTimeout(panTimer.current);panTimer.current=null;swipe.current=null;setPanView(null);}
|
||||
function close(){cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void api('host.launcher.close');}
|
||||
function settleSwipe(gesture:{dx?:number;active?:boolean}){if(!gesture.active)return;const s=state.current,width=grid.current?.clientWidth??1110,dx=gesture.dx??0,direction=dx<0?1:-1,next=s.current+direction,valid=next>=0&&next<s.pages,advance=valid&&Math.abs(dx)>=70;setPanView({offset:advance?-direction*width:0,direction,width,settling:true});long.current=true;panTimer.current=setTimeout(()=>{panTimer.current=null;if(advance){skipPageAnimation.current=true;setPage(next);}setPanView(null);},240);}
|
||||
function close(){if(keyboard.current)return;cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void launcherRequest('host.launcher.close');}
|
||||
function settleSwipe(gesture:{dx?:number;active?:boolean}){if(!gesture.active)return;const s=state.current,width=grid.current?.clientWidth??1300,dx=gesture.dx??0,direction=dx<0?1:-1,next=s.current+direction,valid=next>=0&&next<s.pages,advance=valid&&Math.abs(dx)>=70;setPanView({offset:advance?-direction*width:0,direction,width,settling:true});long.current=true;panTimer.current=setTimeout(()=>{panTimer.current=null;if(advance){skipPageAnimation.current=true;setPage(next);}setPanView(null);},240);}
|
||||
|
||||
useEffect(()=>{const back=()=>close();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')close();};window.addEventListener('framely.back',back);window.addEventListener('keydown',key);const navigate=(e:Event)=>{if(drag.current||busyRef.current||state.current.panning)return;const direction=(e as CustomEvent<number>).detail;const s=state.current;setMenu(null);if(Math.abs(direction)===1)setPage(Math.max(0,Math.min(s.pages-1,s.current+direction)));else if(Math.abs(direction)===2){const index=categories.findIndex(c=>c.key===s.filter);setFilter(categories[Math.max(0,Math.min(categories.length-1,index+(direction>0?-1:1)))].key);setPage(0);}};window.addEventListener('framely.launcher.navigate',navigate);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',key);window.removeEventListener('framely.launcher.navigate',navigate);};},[]);
|
||||
const all:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:false},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:false}))];
|
||||
const rank=new Map(manual.map((key,index)=>[key,index]));all.sort((a,b)=>(rank.get(a.key)??Infinity)-(rank.get(b.key)??Infinity)||Number(b.favorite)-Number(a.favorite)||a.name.localeCompare(b.name));
|
||||
const items=all.filter(a=>filter==='all'||a.kind===filter||(filter==='plugin'&&a.kind==='framely'));
|
||||
useEffect(()=>{const back=()=>close();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')close();};window.addEventListener('framely.back',back);window.addEventListener('keydown',key);const navigate=(e:Event)=>{if(keyboard.current||drag.current||state.current.panning)return;const direction=(e as CustomEvent<number>).detail;const s=state.current;setMenu(null);if(Math.abs(direction)===1)setPage(Math.max(0,Math.min(s.pages-1,s.current+direction)));else if(Math.abs(direction)===2){const index=categories.findIndex(c=>c.key===s.filter);selectCategory(categories[Math.max(0,Math.min(categories.length-1,index+(direction>0?-1:1)))].key);setPage(0);}};window.addEventListener('framely.launcher.navigate',navigate);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',key);window.removeEventListener('framely.launcher.navigate',navigate);};},[]);
|
||||
const all=useMemo(()=>{const entries:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:localFavorites.includes('framely')},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:localFavorites.includes(`${a.kind}:${a.id}`)}))];
|
||||
const rank=new Map(manual.map((key,index)=>[key,index]));entries.sort((a,b)=>(rank.get(a.key)??Infinity)-(rank.get(b.key)??Infinity)||a.name.localeCompare(b.name)||a.key.localeCompare(b.key));return entries;},[plugins,steam,local,favorites,localFavorites,manual]);
|
||||
const indexKey=useMemo(()=>JSON.stringify(all.map(a=>a.name).sort()),[all]);
|
||||
useEffect(()=>{let live=true;const names=Array.from(new Set(all.map(a=>a.name)));if(names.length)void launcherRequest<Record<string,SearchIndex>>('launcher.search.index',{names}).then(v=>{if(live)setIndexes(v);}).catch(()=>{if(live)setError(t('读音搜索暂不可用,仍可使用原文搜索。'));});return()=>{live=false;};},[indexKey]);
|
||||
useEffect(()=>{const changed=(e:Event)=>{keyboard.current=!!(e as CustomEvent<boolean>).detail;if(keyboard.current){cancelPan();cancelHold();setMenu(null);}};window.addEventListener('framely.keyboard',changed);return()=>window.removeEventListener('framely.keyboard',changed);},[]);
|
||||
const items=useMemo(()=>all.filter(a=>(filter==='favorites'?a.favorite:filter==='all'?(config.allCategories??defaults.allCategories!).includes(a.kind==='framely'?'plugin':a.kind):a.kind===filter||(filter==='plugin'&&a.kind==='framely'))&&matchesSearch(a.name,query,indexes[a.name])),[all,filter,config.allCategories,query,indexes]);
|
||||
const pageSize=13,columns=6,pages=Math.max(1,Math.ceil(items.length/pageSize)),current=Math.min(page,pages-1),selected=menu?all.find(a=>a.key===menu):null,selectedPlugin=selected?.kind==='plugin'?plugins[selected.id]:null,visible=items.slice(current*pageSize,current*pageSize+pageSize);
|
||||
const counts=[Math.min(visible.length,4),Math.min(Math.max(visible.length-4,0),5),Math.max(visible.length-9,0)].filter(count=>count>0),rows=Math.max(1,counts.length);
|
||||
const cells=counts.flatMap((count,row)=>Array.from({length:count},(_,col)=>({row,column:(columns-(rows===1?count:row===1?5:4))*2+1+col*4})));
|
||||
@@ -55,30 +70,41 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,order=empty
|
||||
const positions=useRef(new Map<string,{x:number;y:number}>()),layoutPage=useRef('');
|
||||
useLayoutEffect(()=>{const next=new Map<string,{x:number;y:number}>(),pageKey=`${filter}:${current}`;const reduced=window.matchMedia('(prefers-reduced-motion: reduce)').matches;for(const node of Array.from(shell.current?.querySelectorAll<HTMLElement>('[data-launch-key]')??[])){const key=node.dataset.launchKey!,x=node.offsetLeft,y=node.offsetTop;next.set(key,{x,y});const before=positions.current.get(key);if(editing&&!reduced&&pageKey===layoutPage.current&&before&&key!==dragging&&(before.x!==x||before.y!==y))node.animate([{transform:`translate(${before.x-x}px,${before.y-y}px)`},{transform:'translate(0,0)'}],{duration:180,easing:'cubic-bezier(.2,.75,.2,1)'});}positions.current=next;layoutPage.current=pageKey;},[manual,current,filter,editing,dragging,items.map(a=>a.key).join('|')]);
|
||||
|
||||
async function run(f:()=>Promise<unknown>){if(busyRef.current)return;busyRef.current=true;setBusy(true);setError('');try{await f();await refresh();}catch(e){if(mounted.current)setError(String(e));}finally{busyRef.current=false;if(mounted.current)setBusy(false);}}
|
||||
async function run(key:string,f:(active:()=>boolean)=>Promise<unknown>){
|
||||
if(operations.current.has(key))return;setBusyKeys(keys=>new Set(keys).add(key));setError('');
|
||||
const entry={started:Date.now(),expire:()=>{}};operations.current.set(key,entry);
|
||||
const active=()=>mounted.current&&operations.current.get(key)===entry;
|
||||
let timer:ReturnType<typeof setTimeout>|undefined;
|
||||
const deadline=new Promise<never>((_,reject)=>{entry.expire=()=>reject(new Error(t('操作等待超时;请确认应用是否已打开后再重试。')));timer=setTimeout(entry.expire,120000);});
|
||||
try{await Promise.race([(async()=>{await f(active);if(active())await refresh();})(),deadline]);}
|
||||
catch(e){if(active()){done(key,String(e));setError(String(e));}}
|
||||
finally{if(timer)clearTimeout(timer);if(operations.current.get(key)===entry){operations.current.delete(key);if(mounted.current)setBusyKeys(keys=>{const next=new Set(keys);next.delete(key);return next;});}}
|
||||
}
|
||||
function startDrag(item:LaunchItem,x:number,y:number){const before=state.current.all.map(a=>a.key);drag.current={key:item.key,before,order:before,moved:false,over:null,edge:0,lastX:x,lastY:y,startedInEdit:state.current.editing};setDragging(item.key);setGhost({item,x,y});setMenu(null);}
|
||||
function down(e:React.PointerEvent,item:LaunchItem){if(e.button!==0||busy||state.current.panning)return;cancelHold();long.current=false;point.current={x:e.clientX,y:e.clientY};holdTarget.current=item.key;if(!editing)swipe.current={x:e.clientX,y:e.clientY};try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}if(editing){try{shell.current?.setPointerCapture(e.pointerId);}catch{}long.current=true;startDrag(item,e.clientX,e.clientY);return;}hold.current=setTimeout(()=>{long.current=true;setMenu(item.key);setOutside(false);hold.current=null;},400);editHold.current=setTimeout(()=>{long.current=true;setEditing(true);swipe.current=null;try{shell.current?.setPointerCapture(e.pointerId);}catch{}startDrag(item,point.current.x,point.current.y);editHold.current=null;},800);}
|
||||
function down(e:React.PointerEvent,item:LaunchItem){if(e.button!==0||operations.current.has(item.key)||(state.current.editing&&operations.current.has('order'))||state.current.panning)return;cancelHold();long.current=false;point.current={x:e.clientX,y:e.clientY};holdTarget.current=item.key;if(!editing)swipe.current={x:e.clientX,y:e.clientY};try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}if(editing){try{shell.current?.setPointerCapture(e.pointerId);}catch{}long.current=true;startDrag(item,e.clientX,e.clientY);return;}hold.current=setTimeout(()=>{long.current=true;setMenu(item.key);setOutside(false);hold.current=null;},400);editHold.current=setTimeout(()=>{long.current=true;setEditing(true);swipe.current=null;try{shell.current?.setPointerCapture(e.pointerId);}catch{}startDrag(item,point.current.x,point.current.y);editHold.current=null;},800);}
|
||||
function reorder(target:string){const d=drag.current;if(!d||target===d.key||target===d.over)return;const from=d.order.indexOf(d.key),to=d.order.indexOf(target);if(from<0||to<0)return;const next=[...d.order];next.splice(from,1);next.splice(to,0,d.key);d.order=next;d.over=target;d.moved=true;setManual(next);}
|
||||
function turnEdge(direction:number){const d=drag.current;if(!d||d.edge===direction)return;stopEdge();if(!direction)return;d.edge=direction;setEdge(direction);const tick=()=>{const s=state.current,next=s.current+direction;if(!drag.current||next<0||next>=s.pages){stopEdge();return;}const target=s.items[next*pageSize+(direction>0?0:Math.min(pageSize-1,s.items.length-next*pageSize-1))];if(target)reorder(target.key);setPage(next);edgeTimer.current=setTimeout(tick,900);};edgeTimer.current=setTimeout(tick,650);}
|
||||
useEffect(()=>{const move=(e:PointerEvent)=>{const d=drag.current;if(d){setGhost(g=>g?{...g,x:e.clientX,y:e.clientY}:g);const moved=Math.hypot(e.clientX-point.current.x,e.clientY-point.current.y)>36;if(!d.moved&&!moved)return;d.moved=true;const box=grid.current?.getBoundingClientRect(),categories=shell.current?.querySelector('.launcher-filters')?.getBoundingClientRect();const left=box?Math.max(box.left,(categories?.right??box.left)+24):0;const inHeight=!!box&&e.clientY>=box.top&&e.clientY<=box.bottom;const direction=box&&inHeight?(e.clientX>=left&&e.clientX<left+50?-1:e.clientX<=box.right&&e.clientX>box.right-50?1:0):0;turnEdge(direction);if(direction)return;const target=document.elementFromPoint(e.clientX,e.clientY)?.closest<HTMLElement>('[data-launch-key]');if(target&&Math.hypot(e.clientX-d.lastX,e.clientY-d.lastY)>12){reorder(target.dataset.launchKey!);d.lastX=e.clientX;d.lastY=e.clientY;}else if(!target)d.over=null;
|
||||
}else if(swipe.current){const gesture=swipe.current,dx=e.clientX-gesture.x,dy=e.clientY-gesture.y;if(gesture.active||(Math.abs(dx)>36&&Math.abs(dx)>Math.abs(dy)*1.25)){gesture.active=true;gesture.dx=dx;cancelHold();setMenu(null);long.current=true;pageAnimation.current?.cancel();const width=grid.current?.clientWidth??1110,direction=dx<0?1:-1,next=state.current.current+direction,valid=next>=0&&next<state.current.pages;setPanView({offset:valid?Math.max(-width,Math.min(width,dx)):dx*.22,direction,width,settling:false});}else if(holdTarget.current&&Math.hypot(dx,dy)>36){cancelHold();long.current=true;}}};
|
||||
const up=(e:PointerEvent)=>{if(swipe.current){const gesture=swipe.current;swipe.current=null;settleSwipe(gesture);}cancelHold();stopEdge();const d=drag.current;drag.current=null;setDragging(null);setGhost(null);if(d){long.current=true;if(!d.moved&&d.startedInEdit)setEditing(false);if(d.moved)void run(async()=>{try{await api('launcher.order.save',{order:d.order});}catch(e){setManual(d.before);throw e;}});}};
|
||||
}else if(swipe.current){const gesture=swipe.current,dx=e.clientX-gesture.x,dy=e.clientY-gesture.y;if(gesture.active||(Math.abs(dx)>36&&Math.abs(dx)>Math.abs(dy)*1.25)){gesture.active=true;gesture.dx=dx;cancelHold();setMenu(null);long.current=true;pageAnimation.current?.cancel();const width=grid.current?.clientWidth??1300,direction=dx<0?1:-1,next=state.current.current+direction,valid=next>=0&&next<state.current.pages;setPanView({offset:valid?Math.max(-width,Math.min(width,dx)):dx*.22,direction,width,settling:false});}else if(holdTarget.current&&Math.hypot(dx,dy)>36){cancelHold();long.current=true;}}};
|
||||
const up=(e:PointerEvent)=>{if(swipe.current){const gesture=swipe.current;swipe.current=null;settleSwipe(gesture);}cancelHold();stopEdge();const d=drag.current;drag.current=null;setDragging(null);setGhost(null);if(d){long.current=true;if(!d.moved&&d.startedInEdit)setEditing(false);if(d.moved)void run('order',async()=>{try{await launcherRequest('launcher.order.save',{order:d.order});}catch(e){setManual(d.before);throw e;}});}};
|
||||
const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up);window.addEventListener('pointercancel',cancel);return()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up);window.removeEventListener('pointercancel',cancel);};},[]);
|
||||
function launch(a:LaunchItem){return a.kind==='framely'?api('host.manager.open'):a.kind==='plugin'?api('plugin.launch',{plugin:a.id,source:'launcher'}):a.key.startsWith('steam:')?api('steam.launch',{app:+a.id}):api('desktop.launch',{app:a.key});}
|
||||
async function custom(id:string,action:string){await run(async()=>{const v=await api('plugin.launcher.action',{plugin:id,action});if(v.frontend){busyRef.current=true;setBusy(true);await new Promise<void>((resolve,reject)=>{pending.current={resolve,reject};setFrontend({plugin:id,action,context:v.context});frontTimer.current=setTimeout(()=>done('Launcher action timed out'),90000);});}if(v.opened)setMenu(null);});}
|
||||
const pending=useRef<{resolve:()=>void;reject:(e:Error)=>void}|null>(null);
|
||||
function done(error?:string){if(frontTimer.current)clearTimeout(frontTimer.current);const p=pending.current;pending.current=null;setFrontend(null);if(error)p?.reject(new Error(error));else p?.resolve();}
|
||||
return <div ref={shell} onPointerDown={e=>{if(e.button===0&&!(e.target as HTMLElement).closest('button,.launch-menu,.modal,.launcher-error')){if(!editing&&!state.current.panning)swipe.current={x:e.clientX,y:e.clientY};long.current=false;try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}}}} className={`launcher-shell ${editing?'launcher-editing':''}`} onClick={e=>{if(long.current){long.current=false;return;}if(!(e.target as HTMLElement).closest('button,.launch-menu,.modal,.launcher-error'))close();}}>
|
||||
<nav className="launcher-tools launcher-filters" aria-label={t('启动台')}><span className="launcher-category-active" aria-hidden="true" style={{transform:`translateY(${Math.max(0,categories.findIndex(c=>c.key===filter))*61}px)`}}/>{categories.map(({key,label,icon:CategoryIcon})=><button key={key} aria-label={t(label)} title={t(label)} aria-pressed={filter===key} disabled={!!dragging||!!panView} className={filter===key?'active':''} onClick={()=>{if(editing){close();return;}setFilter(key);setPage(0);}}><CategoryIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
|
||||
{editing&&<div className="launcher-edit-toolbar"><span><IconGridDots size={22}/>{t('拖动图标排列,移到两侧翻页')}</span><button disabled={busy||!!dragging} onClick={()=>setEditing(false)}><IconCheck size={22}/>{t('完成')}</button></div>}
|
||||
function launch(a:LaunchItem){return a.kind==='framely'?launcherRequest('host.manager.open'):a.kind==='plugin'?launcherRequest('plugin.launch',{plugin:a.id,source:'launcher'}):a.key.startsWith('steam:')?launcherRequest('steam.launch',{app:+a.id}):launcherRequest('desktop.launch',{app:a.key});}
|
||||
async function custom(id:string,action:string){const key=`plugin:${id}`;await run(key,async active=>{const v=await launcherRequest('plugin.launcher.action',{plugin:id,action});if(!active())return;if(v.frontend){await new Promise<void>((resolve,reject)=>{const ticket=++actionSequence.current;const timer=setTimeout(()=>done(key,'Launcher action timed out',ticket),90000);pending.current.set(key,{resolve,reject,timer,ticket});setFrontends(items=>({...items,[key]:{plugin:id,action,context:v.context,ticket}}));});}if(active()&&v.opened)setMenu(current=>current===key?null:current);});}
|
||||
const pending=useRef(new Map<string,{resolve:()=>void;reject:(e:Error)=>void;timer:ReturnType<typeof setTimeout>;ticket:number}>());
|
||||
function done(key:string,error?:string,ticket?:number){const p=pending.current.get(key);if(!p||(ticket!==undefined&&p.ticket!==ticket))return;clearTimeout(p.timer);pending.current.delete(key);if(mounted.current)setFrontends(items=>{const next={...items};delete next[key];return next;});if(error)p.reject(new Error(error));else p.resolve();}
|
||||
return <div ref={shell} onPointerDown={e=>{if(e.button===0&&!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error')){if(!keyboard.current&&!editing&&!state.current.panning)swipe.current={x:e.clientX,y:e.clientY};long.current=false;try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}}}} className={`launcher-shell ${editing?'launcher-editing':''} ${loading&&opening.current?'launcher-initializing':''}`} onClick={e=>{if(long.current){long.current=false;return;}if(!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error'))close();}}>
|
||||
<div className="launcher-search"><IconSearch size={22} stroke={1.65}/><input type="search" aria-label={t('搜索应用')} placeholder={t('搜索应用')} value={query} disabled={editing} onChange={e=>{cancelPan();cancelHold();setMenu(null);setQuery(e.target.value);setPage(0);}}/>{query&&<button aria-label={t('清除搜索')} onClick={()=>{setQuery('');setPage(0);}}><IconX size={20}/></button>}</div>
|
||||
<nav className="launcher-tools launcher-filters" aria-label={t('启动台')}><span className="launcher-category-active" aria-hidden="true" style={{transform:`translateY(${Math.max(0,categories.findIndex(c=>c.key===filter))*61}px)`}}/>{categories.map(({key,label,icon:CategoryIcon})=><button key={key} aria-label={t(label)} title={t(label)} aria-pressed={filter===key} disabled={!!dragging||!!panView} className={filter===key?'active':''} onClick={()=>{if(editing){close();return;}selectCategory(key);setPage(0);}}><CategoryIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
|
||||
<nav className="launcher-tools launcher-shortcuts" aria-label={t('快捷操作')}>{[{page:'catalog',label:'插件库',icon:IconApps},{page:'settings',label:'设置',icon:IconSettings}].map(({page,label,icon:ActionIcon})=><button key={page} aria-label={t(label)} title={t(label)} disabled={busyKeys.has('framely')||editing||!!dragging||!!panView} onClick={()=>{cancelHold();setMenu(null);void run('framely',()=>launcherRequest('host.manager.open',{page}));}}><ActionIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
|
||||
{editing&&<div className="launcher-edit-toolbar"><span><IconGridDots size={22}/>{t('拖动图标排列,移到两侧翻页')}</span><button disabled={busyKeys.has('order')||!!dragging} onClick={()=>setEditing(false)}><IconCheck size={22}/>{t('完成')}</button></div>}
|
||||
{safeMode&&<p className="banner">{t('安全模式:第三方插件暂停运行。')}</p>}{error&&<div className="launcher-error" role="alert">{error}<button onClick={()=>setError('')}>{t('关闭')}</button></div>}
|
||||
<div ref={grid} className={`launcher-grid ${panView?'launcher-panning':''}`}><div ref={pageContent} className="launcher-page" style={panView?{translate:`${panView.offset}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}:undefined}>{visible.map((a,index)=><div key={a.key} data-launch-key={a.key} style={{gridColumn:`${cells[index].column} / span 4`,gridRow:`${1+cells[index].row*2} / span 2`}} className={`launch-item ${menu===a.key?'launch-item-expanded':''} ${dragging===a.key?'launch-item-dragged':''}`} onPointerEnter={()=>{if(menu===a.key)setOutside(false);}} onPointerLeave={()=>{if(menu===a.key)setOutside(true);}}>
|
||||
<button className="launch-icon" disabled={busy||(a.kind==='plugin'&&safeMode&&!editing)} aria-label={a.name} aria-haspopup="menu" aria-expanded={menu===a.key} onPointerDown={e=>down(e,a)} onClick={()=>{if(long.current){long.current=false;return;}if(editing)return;setMenu(null);void run(()=>launch(a));}} onContextMenu={e=>{e.preventDefault();setMenu(a.key);setOutside(false);}}><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/></button><span className="launch-name" title={a.name}>{a.name}</span>
|
||||
{(a.kind==='plugin'||a.key.startsWith('steam:'))&&!editing&&<button className={`launch-favorite ${a.favorite?'selected':''}`} aria-label={t(a.favorite?'取消收藏':'收藏')} disabled={busy} onClick={()=>void run(()=>api(a.kind==='plugin'?'plugin.favorite':'steam.favorite',a.kind==='plugin'?{plugin:a.id,favorite:!a.favorite}:{app:+a.id,favorite:!a.favorite}))}><IconStar size={16} stroke={1.7}/></button>}
|
||||
<button className="launch-icon" aria-busy={busyKeys.has(a.key)} disabled={busyKeys.has(a.key)||(editing&&busyKeys.has('order'))||(a.kind==='plugin'&&safeMode&&!editing)} aria-label={a.name} aria-haspopup="menu" aria-expanded={menu===a.key} onPointerDown={e=>down(e,a)} onClick={()=>{if(long.current){long.current=false;return;}if(editing)return;setMenu(null);void run(a.key,()=>launch(a));}} onContextMenu={e=>{e.preventDefault();setMenu(a.key);setOutside(false);}}><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/></button><span className="launch-name" title={a.name}>{a.name}</span>
|
||||
{!editing&&<button className={`launch-favorite ${a.favorite?'selected':''}`} aria-label={t(a.favorite?'取消收藏':'收藏')} aria-pressed={a.favorite} title={t(a.favorite?'取消收藏':'收藏')} disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('launcher.favorite',{key:a.key,favorite:!a.favorite}))}><IconStar size={16} stroke={1.7} fill={a.favorite?'currentColor':'none'}/></button>}
|
||||
{menu===a.key&&<div ref={menuRef} role="menu" className={`launch-menu ${cells[index].column>=(columns-2)*4?'align-right':''} ${rows>1&&cells[index].row===rows-1?'above':''}`} onPointerEnter={()=>setOutside(false)} onPointerLeave={()=>setOutside(true)}>
|
||||
{selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=><button role="menuitem" key={action.id} disabled={busy||safeMode} onClick={()=>void custom(a.id,action.id)}>{action.label}</button>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&<hr/>}<button role="menuitem" disabled={busy||safeMode||!selectedPlugin.manifest.ui.quickPage} title={!selectedPlugin.manifest.ui.quickPage?t('插件没有快捷页面'):undefined} onClick={()=>void run(()=>api('plugin.launch',{plugin:a.id,source:'launcher',quickPage:true}))}><IconLayoutGrid size={22} stroke={1.7}/>{t('快捷面板')}</button><button role="menuitem" disabled={busy} onClick={()=>void run(()=>api('host.plugin.manage',{plugin:a.id}))}><IconAdjustments size={22} stroke={1.7}/>{t('管理插件')}</button><button role="menuitem" disabled={busy} className="danger" onClick={()=>void run(async()=>{const plan=await api('plugin.disable.preview',{plugin:a.id});setUninstall({...selectedPlugin,dependents:plan.dependents,fingerprint:plan.fingerprint});})}><IconTrash size={22} stroke={1.7}/>{t('卸载插件')}</button></>:<><button role="menuitem" disabled={busy} onClick={()=>void run(()=>launch(a))}><IconLayoutGrid size={22}/>{t('打开')}</button><button role="menuitem" disabled={busy} onClick={()=>{setMenu(null);setEditing(true);}}><IconGridDots size={22}/>{t('整理图标')}</button></>}
|
||||
{busy&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&¤t+panView.direction>=0&¤t+panView.direction<pages&&<div className="launcher-adjacent-page" aria-hidden="true" style={{translate:`${panView.offset+panView.direction*panView.width}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}}>{items.slice((current+panView.direction)*pageSize,(current+panView.direction+1)*pageSize).map((a,index)=>{const count=Math.min(pageSize,items.length-(current+panView.direction)*pageSize),row=index<4?0:index<9?1:2,column=index-(row===0?0:row===1?4:9),slot=(columns-(count<=4?count:row===1?5:4))*2+1+column*4;return <div key={a.key} className="launch-item" style={{gridColumn:`${slot} / span 4`,gridRow:`${1+row*2} / span 2`}}><span className="launch-icon"><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/></span><span className="launch-name">{a.name}</span></div>;})}</div>}</div>
|
||||
{selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=><button role="menuitem" key={action.id} disabled={busyKeys.has(a.key)||safeMode} onClick={()=>void custom(a.id,action.id)}>{action.label}</button>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&<hr/>}<button role="menuitem" disabled={busyKeys.has(a.key)||safeMode||!selectedPlugin.manifest.ui.quickPage} title={!selectedPlugin.manifest.ui.quickPage?t('插件没有快捷页面'):undefined} onClick={()=>void run(a.key,()=>launcherRequest('plugin.launch',{plugin:a.id,source:'launcher',quickPage:true}))}><IconLayoutGrid size={22} stroke={1.7}/>{t('快捷面板')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.plugin.manage',{plugin:a.id}))}><IconAdjustments size={22} stroke={1.7}/>{t('管理插件')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} className="danger" onClick={()=>void run(a.key,async()=>{const plan=await launcherRequest('plugin.disable.preview',{plugin:a.id});setUninstall({...selectedPlugin,dependents:plan.dependents,fingerprint:plan.fingerprint});})}><IconTrash size={22} stroke={1.7}/>{t('卸载插件')}</button></>:<><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launch(a))}><IconLayoutGrid size={22}/>{t('打开')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>{setMenu(null);setEditing(true);}}><IconGridDots size={22}/>{t('整理图标')}</button></>}
|
||||
{busyKeys.has(a.key)&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&¤t+panView.direction>=0&¤t+panView.direction<pages&&<div className="launcher-adjacent-page" aria-hidden="true" style={{translate:`${panView.offset+panView.direction*panView.width}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}}>{items.slice((current+panView.direction)*pageSize,(current+panView.direction+1)*pageSize).map((a,index)=>{const count=Math.min(pageSize,items.length-(current+panView.direction)*pageSize),row=index<4?0:index<9?1:2,column=index-(row===0?0:row===1?4:9),slot=(columns-(count<=4?count:row===1?5:4))*2+1+column*4;return <div key={a.key} className="launch-item" style={{gridColumn:`${slot} / span 4`,gridRow:`${1+row*2} / span 2`}}><span className="launch-icon"><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/></span><span className="launch-name">{a.name}</span></div>;})}</div>}</div>
|
||||
{ghost&&<div className="launcher-drag-ghost" style={{left:ghost.x,top:ghost.y}}><LauncherArtwork src={ghost.item.icon} name={ghost.item.name} kind={ghost.item.kind} fit={ghost.item.fit}/></div>}{!!edge&&<div className={`launcher-drag-edge ${edge<0?'left':'right'}`} aria-hidden="true"/>}
|
||||
{!items.length&&<p className="launcher-empty">{t('没有可启动的应用')}</p>}<nav className="launcher-pages" aria-label={t('分页')}>{Array.from({length:pages},(_,n)=><button key={n} aria-label={t('第 {0} 页',{0:n+1})} aria-current={current===n?'page':undefined} disabled={busy||!!dragging||!!panView} onClick={()=>{if(editing){close();return;}setPage(n);}}><span/></button>)}</nav>{loading&&<span className="launcher-loading" role="status">{t('正在读取本机应用…')}</span>}
|
||||
{uninstall&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true"><h2>{t('卸载 {0}?',{0:uninstall.manifest.name})}</h2><p>{t('依赖此插件的应用也会停用;插件数据会保留。')}</p>{!!uninstall.dependents.length&&<p>{t('停用:')}{uninstall.dependents.map(id=>plugins[id]?.manifest.name??id).join(', ')}</p>}<button disabled={busy} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busy} className="danger" onClick={()=>void run(async()=>{await api('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{frontend&&<div className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={done}/></div>}</div>;
|
||||
{!loading&&!items.length&&<p className="launcher-empty">{t(query?'没有匹配的应用':filter==='favorites'?'还没有收藏的应用':'没有可启动的应用')}</p>}<nav className="launcher-pages" aria-label={t('分页')}>{Array.from({length:pages},(_,n)=><button key={n} aria-label={t('第 {0} 页',{0:n+1})} aria-current={current===n?'page':undefined} disabled={!!dragging||!!panView} onClick={()=>{if(editing){close();return;}setPage(n);}}><span/></button>)}</nav>{loading&&<span className="launcher-loading" role="status">{t('正在读取本机应用…')}</span>}
|
||||
{uninstall&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true"><h2>{t('卸载 {0}?',{0:uninstall.manifest.name})}</h2><p>{t('依赖此插件的应用也会停用;插件数据会保留。')}</p>{!!uninstall.dependents.length&&<p>{t('停用:')}{uninstall.dependents.map(id=>plugins[id]?.manifest.name??id).join(', ')}</p>}<button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} className="danger" onClick={()=>void run(`plugin:${uninstall.manifest.id}`,async()=>{await launcherRequest('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{Object.entries(frontends).map(([key,frontend])=><div key={`${key}:${frontend.ticket}`} className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={error=>done(key,error,frontend.ticket)}/></div>)}</div>;
|
||||
}
|
||||
+13
-11
@@ -1,8 +1,9 @@
|
||||
import {Switch,SwitchRow} from './switch';
|
||||
import {PluginFrame,pluginLanguagePreference,setPluginLanguagePreference} from './plugin-frame';
|
||||
import {Launcher,LauncherSettings,type LauncherConfig} from './launcher';
|
||||
import {NotificationSettings,PopupPermission,useReadInbox,defaultNotificationConfig,type NotificationConfig} from './notification-settings';
|
||||
import {NotificationCenter,type NotificationEntry} from './notifications';
|
||||
import {IconBell} from '@tabler/icons-react';
|
||||
import {IconBell,IconLayoutGrid} from '@tabler/icons-react';
|
||||
import {t} from './i18n';
|
||||
import {uploadFile} from './upload';
|
||||
import {LanguageSettings} from './language';
|
||||
@@ -20,12 +21,12 @@ import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'rea
|
||||
export type Manifest=Presentation&{id:string;name:string;version:string;author:string;description:string;backend?:{runAs:string;autostart:boolean;memoryLimitMiB?:number};lifecycle?:{runAs?:string};dependencies?:Record<string,unknown>;optionalDependencies?:Record<string,unknown>;conflicts?:Record<string,string>;exclusiveResources?:string[];engines?:{framely?:string};ui:{quickPage?:string;windows:Record<string,unknown>;launch?:Record<string,{type:string;window?:string}>;launcherActions?:{id:string;label:string;target:{type:string;entry?:string;window?:string}}[]}};
|
||||
export type Plugin={manifest:Manifest;enabled:boolean;favorite:boolean;order:number;source?:string;error?:string};
|
||||
type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:boolean};
|
||||
type Status={agreement?:AgreementState;version:string;build?:string;systemUpdate?:any;previousRelease?:string;database:{launcher?:LauncherConfig;steamFavorites?:number[];launcherOrder?:string[];notificationSettings?:NotificationConfig;language?:string;proxy?:ProxyConfig;networkPanel?:NetworkPanel;plugins:Record<string,Plugin>;sources:Source[];subscriptions?:Subscription[];sourceOrigins?:Record<string,{manual:boolean;subscriptions:Record<string,string>}>;safeMode:boolean;updateSource?:{url:string}|null;updateChannel?:'stable'|'testing'};notifications:NotificationEntry[];inbox?:NotificationEntry[];notificationBadge?:number;runtime?:Record<string,{phase:string;detail:any}>;running:string[]};
|
||||
type Status={agreement?:AgreementState;version:string;build?:string;systemUpdate?:any;previousRelease?:string;database:{launcher?:LauncherConfig;launcherFavorites?:string[];steamFavorites?:number[];launcherOrder?:string[];notificationSettings?:NotificationConfig;language?:string;proxy?:ProxyConfig;networkPanel?:NetworkPanel;plugins:Record<string,Plugin>;sources:Source[];subscriptions?:Subscription[];sourceOrigins?:Record<string,{manual:boolean;subscriptions:Record<string,string>}>;safeMode:boolean;updateSource?:{url:string}|null;updateChannel?:'stable'|'testing'};notifications:NotificationEntry[];inbox?:NotificationEntry[];notificationBadge?:number;runtime?:Record<string,{phase:string;detail:any}>;running:string[]};
|
||||
const managerMode=location.pathname==='/manager';
|
||||
const empty:Status={version:'0.4.0',database:{plugins:{},sources:[],safeMode:false},notifications:[],running:[]};
|
||||
|
||||
function App(){
|
||||
const[catalogTarget,setCatalogTarget]=useState<{plugin:string;source?:string}|null>(null);const runtimeLabels:Record<string,string>={starting:t("初始化中"),running:t("运行中"),stopping:t("停止中"),stopped:t("已停止"),recovering:t("等待恢复"),failed:t("失败"),crashed:t("异常退出"),ready:t("已就绪"),installed:t("已安装"),'waiting-dependency':t("等待依赖")};const [status,setStatus]=useState<Status>(empty),[error,setError]=useState(''),[tab,setTab]=useState(managerMode?(location.hash==='#catalog'?'catalog':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':'installed'):'favorites'),[page,setPage]=useState<string|null>(!managerMode&&location.hash.startsWith('#plugin:')?location.hash.slice(8):null),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[manage,setManage]=useState<Plugin|null>(null),[install,setInstall]=useState<any>(null),[confirmRunAs,setConfirmRunAs]=useState(false),[logs,setLogs]=useState<string|null>(null),[importing,setImporting]=useState(false),[transfer,setTransfer]=useState<Job|null>(null),[downloadRequest,setDownloadRequest]=useState<any>(null),[relationAction,setRelationAction]=useState<any>(null),[reviewAgreement,setReviewAgreement]=useState(false),[agreementDeclined,setAgreementDeclined]=useState(false),[languageFiles,setLanguageFiles]=useState<string[]>([]),[logExport,setLogExport]=useState(''),[exportingLogs,setExportingLogs]=useState(false);
|
||||
const[catalogTarget,setCatalogTarget]=useState<{plugin:string;source?:string}|null>(null);const runtimeLabels:Record<string,string>={starting:t("初始化中"),running:t("运行中"),stopping:t("停止中"),stopped:t("已停止"),recovering:t("等待恢复"),failed:t("失败"),crashed:t("异常退出"),ready:t("已就绪"),installed:t("已安装"),'waiting-dependency':t("等待依赖")};const [status,setStatus]=useState<Status>(empty),[error,setError]=useState(''),[tab,setTab]=useState(managerMode?(location.hash==='#catalog'?'catalog':location.hash==='#settings'?'settings':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':location.hash==='#launcher-settings'?'launcher-settings':'installed'):'favorites'),[page,setPage]=useState<string|null>(!managerMode&&location.hash.startsWith('#plugin:')?location.hash.slice(8):null),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[manage,setManage]=useState<Plugin|null>(null),[install,setInstall]=useState<any>(null),[confirmRunAs,setConfirmRunAs]=useState(false),[logs,setLogs]=useState<string|null>(null),[importing,setImporting]=useState(false),[transfer,setTransfer]=useState<Job|null>(null),[downloadRequest,setDownloadRequest]=useState<any>(null),[relationAction,setRelationAction]=useState<any>(null),[reviewAgreement,setReviewAgreement]=useState(false),[agreementDeclined,setAgreementDeclined]=useState(false),[languageFiles,setLanguageFiles]=useState<string[]>([]),[logExport,setLogExport]=useState(''),[exportingLogs,setExportingLogs]=useState(false);
|
||||
useEffect(()=>{const ticket=install?.ticket;return()=>{if(ticket)void api('job.cancel',{job:ticket}).catch(()=>{});};},[install?.ticket]);
|
||||
const localePacks=useRef<LanguagePack[]|null>(null);const localePreference=useRef<string|null>(null);
|
||||
const readError=useReadInbox(!managerMode&&tab==='notifications'&&!page&&!!status.agreement?.accepted,status.inbox??[],refresh);
|
||||
@@ -60,8 +61,8 @@ function App(){
|
||||
else{const plan=await api('plugin.disable.preview',{plugin:id});if(plan.dependents.length){setRelationAction({title:kind==='uninstall'?t("确认卸载及依赖停用"):t("确认停用计划"),enable:[],disable:plan.dependents,method:kind==='uninstall'?'plugin.uninstall':'plugin.enable',params:{plugin:id,enabled:false,purge:false,...extra,approveDependents:true,fingerprint:plan.fingerprint}});return;}await api(kind==='uninstall'?'plugin.uninstall':'plugin.enable',{plugin:id,enabled:false,purge:false,...extra});}
|
||||
setManage(null);
|
||||
}
|
||||
useEffect(()=>{if(!managerMode)return;const navigate=()=>{if(['#catalog','#updates','#notification-settings'].includes(location.hash)){setPage(null);setCatalogTarget(null);setTab(location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':'catalog');}};window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[]);
|
||||
useEffect(()=>{const navigate=()=>{if(location.hash.startsWith('#plugin:')){const id=decodeURIComponent(location.hash.slice(8));if(managerMode){setPage(null);setTab('installed');const plugin=status.database.plugins[id];if(plugin){setManage(plugin);history.replaceState(null,'',location.pathname);}}else setPage(id);}};navigate();window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[status.database.plugins]);
|
||||
useEffect(()=>{if(!managerMode)return;const navigate=()=>{if(['#catalog','#settings','#updates','#notification-settings','#launcher-settings'].includes(location.hash)){setPage(null);setCatalogTarget(null);setTab(location.hash==='#settings'?'settings':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':location.hash==='#launcher-settings'?'launcher-settings':'catalog');}};window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[]);
|
||||
useEffect(()=>{const navigate=()=>{if(location.hash.startsWith('#plugin:')){const id=decodeURIComponent(location.hash.slice(8));if(managerMode){setPage(null);setTab('installed');const plugin=status.database.plugins[id];if(plugin){setManage(plugin);history.replaceState(null,'',location.pathname+location.search);}}else{history.replaceState(null,'',location.pathname+location.search);setPage(id);}}};navigate();window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[status.database.plugins]);
|
||||
async function launchPlugin(p:Plugin){const remote=managerMode&&!!document.querySelector('meta[name=framely-remote]');const result=await api('plugin.launch',{plugin:p.manifest.id,source:managerMode?'manager':'quickPanel',navigate:!remote});if((remote||!managerMode)&&result.target.type==='quickPage')setPage(p.manifest.id);else if(remote&&result.target.type==='window')location.assign(`/window/${p.manifest.id}/${result.target.window}`);}
|
||||
async function loadCatalog(){setCatalogTarget(null);setTab('catalog');}
|
||||
const plugins=Object.values(status.database.plugins).sort((a,b)=>a.order-b.order);
|
||||
@@ -77,29 +78,30 @@ function App(){
|
||||
if(parts[1]==='notifications'&&!status.agreement.accepted)return null;
|
||||
if(!status.agreement.accepted)return <AgreementGate language={{value:status.database.language??'auto',packs:localePacks.current??[]}} key="required" initialDeclined={agreementDeclined} agreement={status.agreement} refresh={refresh}/>;
|
||||
if(reviewAgreement)return <AgreementGate language={{value:status.database.language??'auto',packs:localePacks.current??[]}} key="review" agreement={status.agreement} refresh={refresh} onClose={()=>setReviewAgreement(false)} onRevoked={closed=>{setAgreementDeclined(!closed);setReviewAgreement(false);setPage(null);setManage(null);}}/>;
|
||||
if(parts[1]==='launcher')return <Launcher plugins={status.database.plugins} safeMode={status.database.safeMode} config={status.database.launcher} favorites={status.database.steamFavorites??[]} order={status.database.launcherOrder??[]} refresh={refresh}/>;
|
||||
if(parts[1]==='launcher')return <Launcher plugins={status.database.plugins} safeMode={status.database.safeMode} config={status.database.launcher} favorites={status.database.steamFavorites??[]} localFavorites={status.database.launcherFavorites??[]} order={status.database.launcherOrder??[]} refresh={refresh}/>;
|
||||
if(parts[1]==='notifications')return <NotificationCenter items={status.notifications} plugins={status.database.plugins} safeMode={status.database.safeMode} refresh={refresh}/>;
|
||||
if(windowPlugin){const p=status.database.plugins[windowPlugin];return <div className="app plugin-window">{p?.enabled&&!status.database.safeMode?<PluginFrame plugin={windowPlugin} entry={parts[3]}/>:<div className="empty"><p>{t("插件已停用或正在启动。")}</p><button onClick={()=>void action(()=>api('window.close',{plugin:windowPlugin,window:parts[3]}))}>{t("关闭")}</button></div>}{error&&<p className="error">{error}</p>}</div>;}
|
||||
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong>{managerMode?<><button className="primary" onClick={()=>setImporting(true)}><Icon name="download"/>{t("安装插件")}</button><button className="icon-button" aria-label={t("关闭管理窗口")} onClick={()=>void api('host.manager.close')}><Icon name="close"/></button></>:<><button className="icon-button quick-library-button" aria-label={t("插件库")} onClick={()=>void action(()=>api('host.manager.open',{page:'catalog'}))}><Icon name="store"/><span>{t("插件库")}</span></button><button className="icon-button" aria-label={t("关闭菜单")} onClick={()=>void api('host.menu.close')}><Icon name="close"/></button></>}</header>{error&&<p className="error" role="alert">{error}<button onClick={()=>setError('')}>×</button></p>}{status.database.safeMode&&<p className="banner">{t("安全模式:第三方插件暂停运行。")}</p>}
|
||||
{selected?<><div className="page-title"><button onClick={()=>setPage(null)}>{t("‹ 返回")}</button><strong>{selected.manifest.name}</strong><button onClick={()=>void action(()=>api('plugin.favorite',{plugin:page,favorite:!selected.favorite}))}>{selected.favorite?'★':'☆'}</button>{managerMode&&<button onClick={()=>setManage(selected)}>{t("管理")}</button>}</div>{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?<PluginFrame plugin={page!}/>:<p className="empty">{t("插件已停用、处于安全模式或没有快捷页面。")}</p>}</>:<div className="workspace"><nav aria-label={t("主导航")}>{(managerMode?[['installed',t("已安装"),'plugins'],['catalog',t("插件库"),'download'],['sources',t("插件源导航"),'link'],['settings',t("设置"),'settings'],['notification-settings',t("通知设置导航"),'bell'],['about',t("关于"),'info']]:[['favorites',t("常用"),'star'],['installed',t("已安装"),'plugins'],['notifications',t("通知"),'bell'],['settings',t("设置"),'settings']]).map(([key,label,icon])=><button className={tab===key?'active':''} aria-current={tab===key?'page':undefined} key={key} onClick={()=>key==='catalog'?void loadCatalog():setTab(key)}>{icon==='bell'?<IconBell size={24} stroke={1.8}/>:<Icon name={icon as any}/>}<span>{label}{key==='notifications'&&!!status.notificationBadge&&<small className="notification-count">{status.notificationBadge!>99?'99+':status.notificationBadge}</small>}</span></button>)}{managerMode&&<div className="nav-foot"><span>{plugins.length}{t("个已安装插件")}</span><small>{status.version}</small></div>}</nav><main className="content">
|
||||
{selected?<><div className="page-title"><button onClick={()=>setPage(null)}>{t("‹ 返回")}</button><strong>{selected.manifest.name}</strong><button onClick={()=>void action(()=>api('plugin.favorite',{plugin:page,favorite:!selected.favorite}))}>{selected.favorite?'★':'☆'}</button>{managerMode&&<button onClick={()=>setManage(selected)}>{t("管理")}</button>}</div>{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?<PluginFrame plugin={page!}/>:<p className="empty">{t("插件已停用、处于安全模式或没有快捷页面。")}</p>}</>:<div className="workspace"><nav aria-label={t("主导航")}>{(managerMode?[['installed',t("已安装"),'plugins'],['catalog',t("插件库"),'download'],['sources',t("插件源导航"),'link'],['settings',t("设置"),'settings'],['launcher-settings',t("启动台"),'launcher'],['notification-settings',t("通知设置导航"),'bell'],['about',t("关于"),'info']]:[['favorites',t("常用"),'star'],['installed',t("已安装"),'plugins'],['notifications',t("通知"),'bell'],['settings',t("设置"),'settings']]).map(([key,label,icon])=><button className={tab===key?'active':''} aria-current={tab===key?'page':undefined} key={key} onClick={()=>key==='catalog'?void loadCatalog():setTab(key)}>{icon==='bell'?<IconBell size={24} stroke={1.8}/>:icon==='launcher'?<IconLayoutGrid size={24} stroke={1.8}/>:<Icon name={icon as any}/>}<span>{label}{key==='notifications'&&!!status.notificationBadge&&<small className="notification-count">{status.notificationBadge!>99?'99+':status.notificationBadge}</small>}</span></button>)}{managerMode&&<div className="nav-foot"><span>{plugins.length}{t("个已安装插件")}</span><small>{status.version}</small></div>}</nav><main className="content">
|
||||
{(tab==='favorites'||tab==='installed')&&<>
|
||||
{managerMode&&<div className="list-heading"><div><h1>{t("已安装")}</h1><p>{plugins.length}{t("个插件,")}{plugins.filter(p=>p.enabled).length}{t("个已启用")}</p></div><div className="search-field"><Icon name="search"/><input aria-label={t("搜索已安装插件")} placeholder={t("搜索插件")} value={query} onChange={e=>setQuery(e.target.value)}/></div></div>}
|
||||
{managerMode&&<div className="list-columns"><span>{t("插件")}</span><span>{t("状态")}</span><span>{t("版本")}</span><span>{t("操作")}</span></div>}<div className="plugin-list">{listedPlugins.map(p=><article className={`plugin-row ${!p.enabled?'disabled':''}`} key={p.manifest.id}>
|
||||
<button className="plugin-main" onClick={()=>void action(()=>launchPlugin(p))}><PluginImage src={installedIcon(p)} name={p.manifest.name} size={48}/><span className="plugin-copy"><b>{p.manifest.name}</b>{managerMode&&<small>{p.manifest.description}</small>}<span className="plugin-meta"><i className={`status-dot ${p.error?'failed':!p.enabled?'off':''}`}/>{p.error?t("异常"):!p.enabled?t("已停用"):runtimeLabels[status.runtime?.[p.manifest.id]?.phase??'']??(status.running.includes(p.manifest.id)?t("运行中"):t("已启用"))}{!managerMode&&<span className="version">{p.manifest.version}</span>}</span></span></button>
|
||||
{managerMode&&<><span className="row-state">{p.error?t("异常"):!p.enabled?t("已停用"):runtimeLabels[status.runtime?.[p.manifest.id]?.phase??'']??(status.running.includes(p.manifest.id)?t("运行中"):t("已启用"))}</span><span className="row-version">{p.manifest.version}</span><button className={`plugin-switch ${p.enabled?'on':''}`} role="switch" aria-checked={p.enabled} aria-label={t("启用 {0}", {"0": p.manifest.name})} disabled={busy} onClick={()=>void action(()=>changePlugin(p,'enable'))}><span/></button></>}<button className={`icon-button favorite ${p.favorite?'selected':''}`} aria-label={t("收藏 {0}", {"0": p.manifest.name})} onClick={()=>void action(()=>api('plugin.favorite',{plugin:p.manifest.id,favorite:!p.favorite}))}><Icon name="star" size={23} filled={p.favorite}/></button>
|
||||
{managerMode&&<><span className="row-state">{p.error?t("异常"):!p.enabled?t("已停用"):runtimeLabels[status.runtime?.[p.manifest.id]?.phase??'']??(status.running.includes(p.manifest.id)?t("运行中"):t("已启用"))}</span><span className="row-version">{p.manifest.version}</span><Switch className="plugin-switch" checked={p.enabled} label={t("启用 {0}", {"0": p.manifest.name})} disabled={busy} onChange={()=>void action(()=>changePlugin(p,'enable'))}/></>}<button className={`icon-button favorite ${p.favorite?'selected':''}`} aria-label={t("收藏 {0}", {"0": p.manifest.name})} onClick={()=>void action(()=>api('plugin.favorite',{plugin:p.manifest.id,favorite:!p.favorite}))}><Icon name="star" size={23} filled={p.favorite}/></button>
|
||||
{managerMode&&<button className="icon-button" aria-label={t("管理 {0}", {"0": p.manifest.name})} onClick={()=>setManage(p)}><Icon name="more"/></button>}
|
||||
</article>)}</div>
|
||||
{listedPlugins.length===0&&<div className="empty-state"><span className="empty-symbol"><Icon name={tab==='favorites'?'star':'plugins'} size={46}/></span><h2>{query?t("未找到插件"):tab==='favorites'?t("没有收藏的插件"):onlyDisabledInstalled?t("没有已启用的插件"):t("还没有安装插件")}</h2><p>{query?t("换一个关键词再试。"):tab==='favorites'?t("点击插件旁的星标,将它加入常用。"):onlyDisabledInstalled?t("打开插件管理以启用插件。"):t("打开插件管理以安装插件。")}</p><button className="primary" onClick={()=>query?setQuery(''):tab==='favorites'?setTab('installed'):managerMode?setImporting(true):void api('host.manager.open')}>{query?t("清除搜索"):tab==='favorites'?t("查看已安装"):managerMode?t("安装插件"):t("打开插件管理")}</button></div>}
|
||||
</>}
|
||||
{tab==='notifications'&&!managerMode&&<><NotificationCenter inbox items={status.inbox??[]} plugins={status.database.plugins} safeMode={status.database.safeMode} refresh={refresh} controls={<PopupPermission config={status.database.notificationSettings??defaultNotificationConfig} refresh={refresh}/>}/>{readError&&<p className="error" role="alert">{readError}</p>}</>}
|
||||
{tab==='launcher-settings'&&managerMode&&<><div className="list-heading"><div><h1>{t('启动台')}</h1><p>{t('启动方式、菜单与分类。')}</p></div></div><LauncherSettings config={status.database.launcher} refresh={refresh}/></>}
|
||||
{tab==='notification-settings'&&managerMode&&<NotificationSettings config={status.database.notificationSettings??defaultNotificationConfig} plugins={status.database.plugins} refresh={refresh}/>}
|
||||
{tab==='sources'&&managerMode&&<><div className="list-heading"><div><h1>{t("插件源")}</h1><p>{t("管理插件目录及订阅。")}</p></div></div><section className="settings-card"><Sources origins={status.database.sourceOrigins} sources={status.database.sources} save={async s=>{await api('sources.save',{sources:s});await refresh();}}/></section><Subscriptions subscriptions={status.database.subscriptions??[]} refresh={refresh}/></>}
|
||||
{tab==='about'&&managerMode&&<><div className="list-heading"><div><h1>{t("关于 Framely")}</h1></div></div><SystemUpdates status={status} refresh={refresh}/><section className="settings-card"><h2>{t("用户协议与隐私声明")}</h2><div className="row"><button onClick={()=>setReviewAgreement(true)}>{t("查看协议与隐私声明")}</button></div></section></>}
|
||||
{tab==='settings'&&(!managerMode?<div className="quick-settings"><button className="setting-entry" onClick={()=>void api('host.manager.open')}><span className="setting-symbol"><Icon name="plugins" size={28}/></span><span><b>{t("管理插件")}</b><small>{t("安装、更新与管理插件源")}</small></span><span className="forward">›</span></button><section className="settings-card"><h2>{t("运行模式")}</h2><button className="setting-toggle" onClick={()=>void action(()=>api('safeMode',{enabled:!status.database.safeMode}))}><span><b>{t("安全模式")}</b><small>{t("暂停所有第三方插件")}</small></span><span className={`toggle ${status.database.safeMode?'on':''}`}/></button></section><LanguageSettings selectionOnly value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><button className="setting-entry quick-update-entry" onClick={()=>void action(()=>api('host.manager.open',{page:'updates'}))}><span className="setting-symbol"><Icon name="refresh" size={28}/></span><span><b>{t("检查更新")}</b></span><span className="forward">›</span></button><div className="about"><Brand/><b>Framely</b><small>{t("版本")}{status.build??status.version}</small></div></div>:<><div className="list-heading"><div><h1>{t("设置")}</h1><p>{t("运行模式与诊断。")}</p></div></div><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><LauncherSettings config={status.database.launcher} refresh={refresh}/><NetworkSettings config={status.database.networkPanel??{enabled:true,port:15915}} refresh={refresh}/><ProxySettings config={status.database.proxy??{http:"",github:""}} refresh={refresh}/><section className="settings-card"><h2>{t("恢复与诊断")}</h2><p className="sub">{t("导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。")}</p><div className="row"><button onClick={()=>void action(()=>api('safeMode',{enabled:!status.database.safeMode}))}>{status.database.safeMode?t("退出安全模式"):t("进入安全模式")}</button><button onClick={()=>void action(async()=>setLogs(JSON.stringify(await api('status'),null,2)))}>{t("诊断信息")}</button><button disabled={exportingLogs||busy} onClick={()=>void exportLogs()}>{exportingLogs?t("正在导出日志…"):t("导出日志")}</button></div>{logExport&&<p role="status">{logExport}</p>}</section><section className="settings-card"><h2>{t("运行用户")}</h2><p className="sub">{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}</p></section></>)}
|
||||
{tab==='settings'&&(!managerMode?<div className="quick-settings"><button className="setting-entry" onClick={()=>void api('host.manager.open')}><span className="setting-symbol"><Icon name="plugins" size={28}/></span><span><b>{t("管理插件")}</b><small>{t("安装、更新与管理插件源")}</small></span><span className="forward">›</span></button><section className="settings-card"><h2>{t("运行模式")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/></section><LanguageSettings selectionOnly value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><button className="setting-entry quick-update-entry" onClick={()=>void action(()=>api('host.manager.open',{page:'updates'}))}><span className="setting-symbol"><Icon name="refresh" size={28}/></span><span><b>{t("检查更新")}</b></span><span className="forward">›</span></button><div className="about"><Brand/><b>Framely</b><small>{t("版本")}{status.build??status.version}</small></div></div>:<><div className="list-heading"><div><h1>{t("设置")}</h1><p>{t("运行模式与诊断。")}</p></div></div><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><NetworkSettings config={status.database.networkPanel??{enabled:true,port:15915}} refresh={refresh}/><ProxySettings config={status.database.proxy??{http:"",github:""}} refresh={refresh}/><section className="settings-card"><h2>{t("恢复与诊断")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/><p className="sub">{t("导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。")}</p><div className="row"><button onClick={()=>void action(async()=>setLogs(JSON.stringify(await api('status'),null,2)))}>{t("诊断信息")}</button><button disabled={exportingLogs||busy} onClick={()=>void exportLogs()}>{exportingLogs?t("正在导出日志…"):t("导出日志")}</button></div>{logExport&&<p role="status">{logExport}</p>}</section><section className="settings-card"><h2>{t("运行用户")}</h2><p className="sub">{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}</p></section></>)}
|
||||
{tab==='catalog'&&<Store target={catalogTarget} plugins={status.database.plugins} sources={status.database.sources} onInspect={inspect} onSources={()=>setTab('sources')} busy={busy}/>}
|
||||
</main></div>}
|
||||
{importing&&<div className="modal" role="dialog" aria-modal="true" aria-label={t("安装插件")}><button className="icon-button modal-close" aria-label={t("关闭导入")} onClick={()=>setImporting(false)}><Icon name="close"/></button><InstallInput inspect={async p=>{await inspect(p);setImporting(false);}} busy={busy}/></div>}
|
||||
{manage&&<div className="modal manage-dialog" role="dialog" aria-modal="true" aria-label={t("管理插件")}><h2>{manage.manifest.name}</h2><PluginLinks plugin={manage.manifest}/><p><PluginAuthor plugin={manage.manifest}/> · {manage.manifest.version} · {manage.manifest.backend?.runAs??manage.manifest.lifecycle?.runAs??'steamos'}</p><MemoryLimit manifest={manage.manifest}/>{status.database.plugins[manage.manifest.id]?.error&&<p className="error">{status.database.plugins[manage.manifest.id].error}</p>}<details className="plugin-relations"><summary>{t("插件依赖")}</summary><RelationsInfo plugin={manage.manifest}/></details><div className="row"><button onClick={()=>move(manage,-1)}>{t("上移")}</button><button onClick={()=>move(manage,1)}>{t("下移")}</button></div><div className="manage-grid"><button disabled={busy} onClick={()=>void action(async()=>{await changePlugin(manage,'enable');})}>{manage.enabled?t("停用"):t("启用")}</button><button disabled={busy||!manage.enabled} onClick={()=>void action(()=>api('plugin.restart',{plugin:manage.manifest.id}))}>{t("重启后端")}</button><button disabled={busy} onClick={()=>{setCatalogTarget({plugin:manage.manifest.id,source:manage.source});setManage(null);setPage(null);setTab('catalog');}}>{t("可选安装版本")}</button><button onClick={()=>void action(async()=>setLogs(await api('logs',{plugin:manage.manifest.id})))}>{t("查看日志")}</button><button className="danger" disabled={busy} onClick={()=>void action(async()=>{await changePlugin(manage,'uninstall');if(page===manage.manifest.id)setPage(null);})}>{t("卸载")}</button>{status.database.plugins[manage.manifest.id]?.error?.includes("卸载清理失败")&&<button className="danger" disabled={busy} onClick={()=>void action(async()=>{await changePlugin(manage,'uninstall',{force:true});})}>{t("强制卸载(跳过清理失败)")}</button>}</div><div className="dialog-footer"><button onClick={()=>setManage(null)}>{t("关闭")}</button></div></div>}
|
||||
{manage&&<div className="modal manage-dialog" role="dialog" aria-modal="true" aria-label={t("管理插件")}><h2>{manage.manifest.name}</h2><PluginLinks plugin={manage.manifest}/><p><PluginAuthor plugin={manage.manifest}/> · {manage.manifest.version} · {manage.manifest.backend?.runAs??manage.manifest.lifecycle?.runAs??'steamos'}</p><MemoryLimit manifest={manage.manifest}/>{status.database.plugins[manage.manifest.id]?.error&&<p className="error">{status.database.plugins[manage.manifest.id].error}</p>}<details className="plugin-relations"><summary>{t("插件依赖")}</summary><RelationsInfo plugin={manage.manifest}/></details><div className="row"><button onClick={()=>move(manage,-1)}>{t("上移")}</button><button onClick={()=>move(manage,1)}>{t("下移")}</button></div><SwitchRow label={t("启用 {0}",{0:manage.manifest.name})} checked={manage.enabled} disabled={busy} onChange={()=>void action(async()=>{await changePlugin(manage,'enable');})}/><div className="manage-grid"><button disabled={busy||!manage.enabled} onClick={()=>void action(()=>api('plugin.restart',{plugin:manage.manifest.id}))}>{t("重启后端")}</button><button disabled={busy} onClick={()=>{setCatalogTarget({plugin:manage.manifest.id,source:manage.source});setManage(null);setPage(null);setTab('catalog');}}>{t("可选安装版本")}</button><button onClick={()=>void action(async()=>setLogs(await api('logs',{plugin:manage.manifest.id})))}>{t("查看日志")}</button><button className="danger" disabled={busy} onClick={()=>void action(async()=>{await changePlugin(manage,'uninstall');if(page===manage.manifest.id)setPage(null);})}>{t("卸载")}</button>{status.database.plugins[manage.manifest.id]?.error?.includes("卸载清理失败")&&<button className="danger" disabled={busy} onClick={()=>void action(async()=>{await changePlugin(manage,'uninstall',{force:true});})}>{t("强制卸载(跳过清理失败)")}</button>}</div><div className="dialog-footer"><button onClick={()=>setManage(null)}>{t("关闭")}</button></div></div>}
|
||||
{install&&<InstallReview title={installTitle} info={install.info} busy={busy} error={error} confirmed={confirmRunAs} onConfirmChange={setConfirmRunAs} onChoose={(id,url)=>void resolveChoice(id,url)} onCancel={()=>setInstall(null)} onInstall={()=>void action(async()=>{await runJob('install.start',{ticket:install.ticket,approve:true,approveRunAs:confirmRunAs},setTransfer);setInstall(null);setTransfer(null);})}/>}
|
||||
|
||||
{transfer&&<Transfer job={transfer} retry={downloadRequest&&transfer.kind==='inspect'?()=>void inspect(downloadRequest):undefined} cancel={()=>void api('job.cancel',{job:transfer.id}).then(()=>setTransfer(v=>v?{...v,phase:'cancelled'}:null))} close={()=>setTransfer(null)}/>}
|
||||
@@ -107,6 +109,6 @@ function App(){
|
||||
{logs!==null&&<div className="modal" role="dialog" aria-modal="true" aria-label={t("诊断与日志")}><h2>{t("诊断与日志")}</h2><pre>{logs||t("暂无日志")}</pre><button onClick={()=>setLogs(null)}>{t("关闭")}</button></div>}
|
||||
</div>;
|
||||
}
|
||||
function InstallInput({inspect,busy}:{inspect:(p:any)=>Promise<void>;busy:boolean}){const[url,setUrl]=useState(''),[http,setHttp]=useState(false),[fileError,setFileError]=useState(''),[uploadProgress,setUploadProgress]=useState<number|null>(null);const abort=useRef<AbortController|null>(null);async function upload(file:File){const controller=new AbortController();abort.current=controller;let ticket='';setFileError('');setUploadProgress(0);try{ticket=await uploadFile(file,controller.signal,setUploadProgress,{start:size=>api('upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});setUploadProgress(null);await inspect({upload:ticket});}catch(e){if(!controller.signal.aborted)setFileError(String(e));}finally{if(ticket)await api('upload.abort',{upload:ticket}).catch(()=>{});abort.current=null;setUploadProgress(null);}}useEffect(()=>()=>abort.current?.abort(),[]);return <section><h3>{t("导入插件包")}</h3>{fileError&&<p className="error">{fileError}</p>}<input type="file" accept=".framely" disabled={busy||uploadProgress!==null} onChange={e=>{const f=e.target.files?.[0];e.target.value='';if(f)void upload(f);}}/>{uploadProgress!==null&&<p role="status">{t("正在上传…")} {uploadProgress}% <button onClick={()=>abort.current?.abort()}>{t("取消")}</button></p>}<input placeholder={t("插件包下载 URL")} value={url} onChange={e=>setUrl(e.target.value)}/><label><input type="checkbox" checked={http} onChange={e=>setHttp(e.target.checked)}/>{t("允许测试 HTTP 地址")}</label><button disabled={busy||uploadProgress!==null||!url} onClick={()=>void inspect({url,allowHttp:http})}>{t("检查下载包")}</button><small>{t("在头显里不方便选择文件时,可使用 framely install 命令导入。")}</small></section>}
|
||||
function InstallInput({inspect,busy}:{inspect:(p:any)=>Promise<void>;busy:boolean}){const[url,setUrl]=useState(''),[http,setHttp]=useState(false),[fileError,setFileError]=useState(''),[uploadProgress,setUploadProgress]=useState<number|null>(null);const abort=useRef<AbortController|null>(null);async function upload(file:File){const controller=new AbortController();abort.current=controller;let ticket='';setFileError('');setUploadProgress(0);try{ticket=await uploadFile(file,controller.signal,setUploadProgress,{start:size=>api('upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});setUploadProgress(null);await inspect({upload:ticket});}catch(e){if(!controller.signal.aborted)setFileError(String(e));}finally{if(ticket)await api('upload.abort',{upload:ticket}).catch(()=>{});abort.current=null;setUploadProgress(null);}}useEffect(()=>()=>abort.current?.abort(),[]);return <section><h3>{t("导入插件包")}</h3>{fileError&&<p className="error">{fileError}</p>}<input type="file" accept=".framely" disabled={busy||uploadProgress!==null} onChange={e=>{const f=e.target.files?.[0];e.target.value='';if(f)void upload(f);}}/>{uploadProgress!==null&&<p role="status">{t("正在上传…")} {uploadProgress}% <button onClick={()=>abort.current?.abort()}>{t("取消")}</button></p>}<input placeholder={t("插件包下载 URL")} value={url} onChange={e=>setUrl(e.target.value)}/><SwitchRow checked={http} label={t("允许测试 HTTP 地址")} disabled={busy} onChange={setHttp}/><button disabled={busy||uploadProgress!==null||!url} onClick={()=>void inspect({url,allowHttp:http})}>{t("检查下载包")}</button><small>{t("在头显里不方便选择文件时,可使用 framely install 命令导入。")}</small></section>}
|
||||
class Boundary extends React.Component<React.PropsWithChildren,{error:string|null}>{state={error:null as string|null};static getDerivedStateFromError(e:Error){return {error:e.message}}render(){return this.state.error?<div className="app"><h1>{t("界面发生错误")}</h1><p>{this.state.error}</p><button onClick={()=>location.reload()}>{t("重新加载")}</button></div>:this.props.children;}}
|
||||
createRoot(document.getElementById('root')!).render(<Boundary><App/></Boundary>);console.log('FRAMELY_VIEW_READY');
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import {SwitchRow} from './switch';
|
||||
import {t} from './i18n';
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {api} from './api';
|
||||
@@ -8,5 +9,5 @@ export function NetworkSettings({config,refresh}:{config:NetworkPanel;refresh:()
|
||||
useEffect(()=>{let live=true;const load=()=>void api('network.status').then(value=>{if(live)setInfo(value);}).catch(e=>{if(live)setError(e.message);});load();const timer=setInterval(load,2000);return()=>{live=false;clearInterval(timer);};},[]);
|
||||
const number=Number(port),valid=/^\d+$/.test(port)&&number>=1024&&number<=65535;
|
||||
async function save(){setBusy(true);setError('');setNotice('');try{const result=await api('network.save',{enabled,port:number,passwordEnabled,...(password?{password}:{})});setPassword('');if(result.reauthenticate&&info?.localOrigin&&location.origin!==info.localOrigin&&enabled&&number===config.port){location.replace('/manager');return;}if(info?.localOrigin&&location.origin!==info.localOrigin&&(!enabled||number!==config.port||passwordEnabled!==(config.passwordEnabled??false)||!!password)){setNotice(enabled?t("已保存,请访问 http://{0}:{1} 重新连接。", {"0": location.hostname, "1": number}):t("已关闭网络管理面板,此页面连接会断开。"));}else{await refresh();setNotice(t("已保存,设置立即生效。"));}}catch(e){setError(String((e as Error).message));}finally{setBusy(false);}}
|
||||
return <section className="settings-card"><h2>{t("网络管理面板")}</h2><label className="network-switch"><input type="checkbox" checked={enabled} onChange={e=>setEnabled(e.target.checked)}/>{t("允许通过 IP 和端口访问")}</label><label className="network-switch"><input type="checkbox" checked={passwordEnabled} onChange={e=>setPasswordEnabled(e.target.checked)}/>{t("使用访问密码")}</label>{passwordEnabled&&<label className="network-password">{t("访问密码")}<input type="password" autoComplete="new-password" maxLength={512} value={password} placeholder={info?.passwordConfigured?t("已设置,留空则不修改"):t("设置访问密码")} onChange={e=>setPassword(e.target.value)}/></label>}<div className="row network-port"><label>{t("端口")}<input aria-label={t("网络管理端口")} type="number" min="1024" max="65535" value={port} onChange={e=>setPort(e.target.value)}/></label><button disabled={busy||!valid||(passwordEnabled&&!info?.passwordConfigured&&!password)||(enabled===config.enabled&&number===config.port&&passwordEnabled===(config.passwordEnabled??false)&&!password)} onClick={()=>void save()}>{busy?t("保存中…"):t("保存")}</button></div>{info?.listener?.enabled&&<><p className="sub">{t("让手机或电脑连接同一网络,在浏览器中打开下面的地址。")}</p>{info.ips?.length?<div className="network-addresses">{info.ips.map((ip:string)=><div key={ip}><span>{t("设备 IP:")}{ip}</span><code>http://{ip}:{info.listener.port}</code></div>)}</div>:<p className="sub">{t("未检测到设备 IP,请连接 Wi-Fi 或网线。")}</p>}<p className="sub">{config.passwordEnabled?t("已开启密码验证,访问时输入你设置的密码。"):info.passwordConfigured?t("密码验证未开启,打开上述地址即可进入管理面板。"):t("首次访问网络管理面板时,必须先设置访问密码。")}</p></>}{info?.listener?.error&&<p className="error">{info.listener.error}</p>}{info&&!info.listener?.enabled&&!info.listener?.error&&<p className="sub">{t("网络管理面板已关闭或正在启动。")}</p>}{notice&&<p role="status">{notice}</p>}{error&&<p className="error" role="alert">{error}</p>}</section>;
|
||||
return <section className="settings-card"><h2>{t("网络管理面板")}</h2><SwitchRow checked={enabled} label={t("允许通过 IP 和端口访问")} disabled={busy} onChange={setEnabled}/><SwitchRow checked={passwordEnabled} label={t("使用访问密码")} disabled={busy} onChange={setPasswordEnabled}/>{passwordEnabled&&<label className="network-password">{t("访问密码")}<input type="password" autoComplete="new-password" maxLength={512} value={password} placeholder={info?.passwordConfigured?t("已设置,留空则不修改"):t("设置访问密码")} onChange={e=>setPassword(e.target.value)}/></label>}<div className="row network-port"><label>{t("端口")}<input aria-label={t("网络管理端口")} type="number" min="1024" max="65535" value={port} onChange={e=>setPort(e.target.value)}/></label><button disabled={busy||!valid||(passwordEnabled&&!info?.passwordConfigured&&!password)||(enabled===config.enabled&&number===config.port&&passwordEnabled===(config.passwordEnabled??false)&&!password)} onClick={()=>void save()}>{busy?t("保存中…"):t("保存")}</button></div>{info?.listener?.enabled&&<><p className="sub">{t("让手机或电脑连接同一网络,在浏览器中打开下面的地址。")}</p>{info.ips?.length?<div className="network-addresses">{info.ips.map((ip:string)=><div key={ip}><span>{t("设备 IP:")}{ip}</span><code>http://{ip}:{info.listener.port}</code></div>)}</div>:<p className="sub">{t("未检测到设备 IP,请连接 Wi-Fi 或网线。")}</p>}<p className="sub">{config.passwordEnabled?t("已开启密码验证,访问时输入你设置的密码。"):info.passwordConfigured?t("密码验证未开启,打开上述地址即可进入管理面板。"):t("首次访问网络管理面板时,必须先设置访问密码。")}</p></>}{info?.listener?.error&&<p className="error">{info.listener.error}</p>}{info&&!info.listener?.enabled&&!info.listener?.error&&<p className="sub">{t("网络管理面板已关闭或正在启动。")}</p>}{notice&&<p role="status">{notice}</p>}{error&&<p className="error" role="alert">{error}</p>}</section>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {Switch as BaseSwitch} from './switch';
|
||||
import React,{useState,useEffect} from 'react';
|
||||
import {IconBell,IconSettings,IconSearch} from '@tabler/icons-react';
|
||||
import {api,viewKey} from './api';
|
||||
@@ -8,10 +9,8 @@ import type {NotificationEntry} from './notifications';
|
||||
export type NotificationPreference={popups:boolean;badge:boolean};
|
||||
export type NotificationConfig=NotificationPreference&{plugins:Record<string,NotificationPreference>};
|
||||
export const defaultNotificationConfig:NotificationConfig={popups:true,badge:true,plugins:{}};
|
||||
const Switch=(props:React.ComponentProps<typeof BaseSwitch>)=><BaseSwitch {...props} className="notification-switch"/>;
|
||||
type Installed={manifest:{id:string;name:string;description?:string;icon?:string|null;version:string};enabled:boolean;order:number};
|
||||
function Switch({checked,label,disabled,onChange}:{checked:boolean;label:string;disabled:boolean;onChange:()=>void}){
|
||||
return <button className={`notification-switch ${checked?'on':''}`} role="switch" aria-checked={checked} aria-label={label} disabled={disabled} onClick={onChange}><span/></button>;
|
||||
}
|
||||
function useSave(refresh:()=>Promise<void>){
|
||||
const[busy,setBusy]=useState(false),[error,setError]=useState('');
|
||||
async function run(task:()=>Promise<unknown>){if(busy)return;setBusy(true);setError('');try{await task();await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function PluginFrame({plugin,entry='quick',actionContext,onActionDone}:{p
|
||||
}
|
||||
if(live){setSandbox(flags);setSource(url);}
|
||||
})().catch(e=>{if(live)setError(String(e));});
|
||||
return()=>{live=false;window.removeEventListener('message',message);window.removeEventListener('framely.event',event);window.removeEventListener('framely.language',language);};
|
||||
return()=>{live=false;window.removeEventListener('message',message);window.removeEventListener('framely.event',event);window.removeEventListener('framely.language',language);window.removeEventListener('framely.launch',launched);};
|
||||
},[plugin,entry,attempt]);
|
||||
return error?<div><p className="error">{error}</p><button onClick={()=>setAttempt(v=>v+1)}>{t('重试')}</button></div>:source?<iframe title={t('插件页面')} ref={ref} sandbox={sandbox} src={source} allow="clipboard-read; clipboard-write; fullscreen"/>:<p>{t('正在加载…')}</p>;
|
||||
}
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import {SwitchRow} from './switch';
|
||||
import {t} from './i18n';
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {api} from './api';
|
||||
@@ -7,5 +8,5 @@ export function ProxySettings({config,refresh}:{config:ProxyConfig;refresh:()=>P
|
||||
useEffect(()=>{setHttp(config.http);setGithub(config.github);setHttpEnabled(config.httpEnabled??false);setGithubEnabled(config.githubEnabled??false);},[config.http,config.github,config.httpEnabled,config.githubEnabled]);
|
||||
async function save(){setBusy(true);setError('');try{await api('proxy.save',{http:http.trim(),github:github.trim(),httpEnabled,githubEnabled});await refresh();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
const unchanged=http===config.http&&github===config.github&&httpEnabled===(config.httpEnabled??false)&&githubEnabled===(config.githubEnabled??false);
|
||||
return <section className="settings-card"><h2>{t("代理设置")}</h2><div className="proxy-fields"><div><label className="network-switch"><input type="checkbox" checked={httpEnabled} onChange={e=>setHttpEnabled(e.target.checked)}/>{t("启用 HTTP 代理")}</label><label><input aria-label={t("HTTP 代理地址")} disabled={!httpEnabled} maxLength={2048} type="url" value={http} placeholder="http://192.168.1.2:7890" onChange={e=>setHttp(e.target.value)}/></label></div><div><label className="network-switch"><input type="checkbox" checked={githubEnabled} onChange={e=>setGithubEnabled(e.target.checked)}/>{t("启用 GitHub 代理")}</label><label><input aria-label={t("GitHub 代理地址")} disabled={!githubEnabled} maxLength={2048} type="url" value={github} placeholder="https://gh-proxy.com" onChange={e=>setGithub(e.target.value)}/></label></div></div><div className="row"><button disabled={busy||unchanged||(httpEnabled&&!http.trim())||(githubEnabled&&!github.trim())} onClick={()=>void save()}>{busy?t("保存中…"):t("保存")}</button></div>{error&&<p className="error" role="alert">{error}</p>}</section>;
|
||||
return <section className="settings-card"><h2>{t("代理设置")}</h2><div className="proxy-fields"><div><SwitchRow checked={httpEnabled} label={t("启用 HTTP 代理")} disabled={busy} onChange={setHttpEnabled}/><label><input aria-label={t("HTTP 代理地址")} disabled={!httpEnabled} maxLength={2048} type="url" value={http} placeholder="http://192.168.1.2:7890" onChange={e=>setHttp(e.target.value)}/></label></div><div><SwitchRow checked={githubEnabled} label={t("启用 GitHub 代理")} disabled={busy} onChange={setGithubEnabled}/><label><input aria-label={t("GitHub 代理地址")} disabled={!githubEnabled} maxLength={2048} type="url" value={github} placeholder="https://gh-proxy.com" onChange={e=>setGithub(e.target.value)}/></label></div></div><div className="row"><button disabled={busy||unchanged||(httpEnabled&&!http.trim())||(githubEnabled&&!github.trim())} onClick={()=>void save()}>{busy?t("保存中…"):t("保存")}</button></div>{error&&<p className="error" role="alert">{error}</p>}</section>;
|
||||
}
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import {Switch,SwitchRow} from './switch';
|
||||
import {TooltipIconButton} from './icon-button';
|
||||
import {t} from './i18n';
|
||||
import React,{useEffect,useMemo,useState,useRef} from 'react';
|
||||
@@ -57,5 +58,5 @@ export function Sources({sources,save,origins}:{origins?:Record<string,{manual:b
|
||||
const form=()=>({id:edit??`source-${Date.now()}`,name:name.trim(),url:url.trim(),allowHttp:http,enabled:true});
|
||||
async function change(next:Source[]){setBusy(true);setError('');try{await save(next);return true;}catch(e){setError(String(e));return false;}finally{setBusy(false);}}
|
||||
async function test(source:Source){setTesting(true);setError('');setProbe('');try{const v=await runJob('source.test',{source});setProbe(t("{0} · {1} 个插件,连接正常", {"0": v.result.name, "1": v.result.count}));}catch(e){setError(String(e));}finally{setTesting(false);}}
|
||||
return <>{!sources.length&&<p className="sub">{t("尚未添加插件源。可使用开发者发布的 HTTPS catalog.json 地址。")}</p>}{sources.map(s=><article className="source-row" key={s.id}><div><b>{s.name}</b><span className={`badge ${s.enabled?'accent':''}`}>{s.enabled?t("已启用"):t("已停用")}</span><p className="source-url">{s.url}</p>{!!Object.keys(origins?.[s.id]?.subscriptions??{}).length&&<small>{t("来自源订阅 · 地址变更和移除请在订阅中管理")}</small>}</div><div className="row source-actions"><TooltipIconButton icon="power" label={s.enabled?t("停用"):t("启用")} disabled={busy} onClick={()=>void change(sources.map(q=>q.id===s.id?{...q,enabled:!q.enabled}:q))}/><TooltipIconButton icon="connection" label={t("测试连接")} disabled={testing} onClick={()=>void test(s)}/><TooltipIconButton icon="edit" label={t("编辑")} disabled={!!Object.keys(origins?.[s.id]?.subscriptions??{}).length} onClick={()=>{setEdit(s.id);setName(s.name);setUrl(s.url);setHttp(s.allowHttp);setError('');setProbe('');}}/><TooltipIconButton icon="trash" label={t("删除")} disabled={!!Object.keys(origins?.[s.id]?.subscriptions??{}).length&&!origins?.[s.id]?.manual} className="danger" onClick={()=>setDeleting(s)}/></div></article>)}<button className="source-add" onClick={()=>setFormOpen(v=>!v)}>{formOpen?t("收起添加表单"):t("添加插件源")}</button>{(formOpen||edit)&&<div className="source-form"><h3>{edit?t("编辑插件源"):t("添加插件源")}</h3><label>{t("名称")}<input placeholder={t("例如:我的插件源")} value={name} onChange={e=>setName(e.target.value)}/></label><label>{t("目录地址")}<input type="url" placeholder="https://example.org/catalog.json" value={url} onChange={e=>setUrl(e.target.value)}/></label><details><summary>{t("开发选项")}</summary><label><input type="checkbox" checked={http} onChange={e=>setHttp(e.target.checked)}/>{t("允许测试 HTTP 地址")}</label></details><div className="row"><button disabled={!name.trim()||!url.trim()||testing} onClick={()=>void test(form())}>{testing?t("正在测试…"):t("测试连接")}</button><button className="primary" disabled={busy||!name.trim()||!url.trim()} onClick={()=>void(async()=>{const s=form();if(await change(edit?sources.map(q=>q.id===edit?{...s,enabled:q.enabled}:q):[...sources,s])){setName('');setUrl('');setEdit(null);setHttp(false);setFormOpen(false);setProbe('');}})()}>{busy?t("保存中…"):edit?t("保存修改"):t("添加源")}</button>{edit&&<button onClick={()=>{setEdit(null);setName('');setUrl('');setHttp(false);}}>{t("取消编辑")}</button>}</div></div>}{probe&&<p className="banner">{probe}</p>}{error&&<p className="error">{error}</p>}{deleting&&<div className="confirm-box"><h3>{t("删除“{0}”?",{0:deleting.name})}</h3><p>{t("已安装插件和数据会保留,此源的更新将不再被检查。")}</p><button className="danger" disabled={busy} onClick={()=>void change(sources.filter(s=>s.id!==deleting.id)).then(ok=>{if(ok)setDeleting(null);})}>{t("确认删除源")}</button><button onClick={()=>setDeleting(null)}>{t("取消")}</button></div>}</>;
|
||||
return <>{!sources.length&&<p className="sub">{t("尚未添加插件源。可使用开发者发布的 HTTPS catalog.json 地址。")}</p>}{sources.map(s=><article className="source-row" key={s.id}><div><b>{s.name}</b><span className={`badge ${s.enabled?'accent':''}`}>{s.enabled?t("已启用"):t("已停用")}</span><p className="source-url">{s.url}</p>{!!Object.keys(origins?.[s.id]?.subscriptions??{}).length&&<small>{t("来自源订阅 · 地址变更和移除请在订阅中管理")}</small>}</div><div className="row source-actions"><Switch checked={s.enabled} label={t("启用 {0}",{0:s.name})} disabled={busy} onChange={enabled=>void change(sources.map(q=>q.id===s.id?{...q,enabled}:q))}/><TooltipIconButton icon="connection" label={t("测试连接")} disabled={testing} onClick={()=>void test(s)}/><TooltipIconButton icon="edit" label={t("编辑")} disabled={!!Object.keys(origins?.[s.id]?.subscriptions??{}).length} onClick={()=>{setEdit(s.id);setName(s.name);setUrl(s.url);setHttp(s.allowHttp);setError('');setProbe('');}}/><TooltipIconButton icon="trash" label={t("删除")} disabled={!!Object.keys(origins?.[s.id]?.subscriptions??{}).length&&!origins?.[s.id]?.manual} className="danger" onClick={()=>setDeleting(s)}/></div></article>)}<button className="source-add" onClick={()=>setFormOpen(v=>!v)}>{formOpen?t("收起添加表单"):t("添加插件源")}</button>{(formOpen||edit)&&<div className="source-form"><h3>{edit?t("编辑插件源"):t("添加插件源")}</h3><label>{t("名称")}<input placeholder={t("例如:我的插件源")} value={name} onChange={e=>setName(e.target.value)}/></label><label>{t("目录地址")}<input type="url" placeholder="https://example.org/catalog.json" value={url} onChange={e=>setUrl(e.target.value)}/></label><details><summary>{t("开发选项")}</summary><SwitchRow checked={http} label={t("允许测试 HTTP 地址")} disabled={busy} onChange={setHttp}/></details><div className="row"><button disabled={!name.trim()||!url.trim()||testing} onClick={()=>void test(form())}>{testing?t("正在测试…"):t("测试连接")}</button><button className="primary" disabled={busy||!name.trim()||!url.trim()} onClick={()=>void(async()=>{const s=form();if(await change(edit?sources.map(q=>q.id===edit?{...s,enabled:q.enabled}:q):[...sources,s])){setName('');setUrl('');setEdit(null);setHttp(false);setFormOpen(false);setProbe('');}})()}>{busy?t("保存中…"):edit?t("保存修改"):t("添加源")}</button>{edit&&<button onClick={()=>{setEdit(null);setName('');setUrl('');setHttp(false);}}>{t("取消编辑")}</button>}</div></div>}{probe&&<p className="banner">{probe}</p>}{error&&<p className="error">{error}</p>}{deleting&&<div className="confirm-box"><h3>{t("删除“{0}”?",{0:deleting.name})}</h3><p>{t("已安装插件和数据会保留,此源的更新将不再被检查。")}</p><button className="danger" disabled={busy} onClick={()=>void change(sources.filter(s=>s.id!==deleting.id)).then(ok=>{if(ok)setDeleting(null);})}>{t("确认删除源")}</button><button onClick={()=>setDeleting(null)}>{t("取消")}</button></div>}</>;
|
||||
}
|
||||
+19
-1
@@ -9,7 +9,7 @@ input:not([type=checkbox]):not([type=file]),textarea,select{width:100%;padding:1
|
||||
.settings-card{border-top:1px solid var(--line);padding:26px 0;margin:0 0 16px}.settings-card h2{color:#e5e7eb;font-size:24px;font-weight:550}.settings-card>p,.source-form,.settings-card>label{max-width:860px}.settings-card .sub{line-height:1.6}.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:16px}.row h2{flex:1}.source-row{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;padding:24px 0;border-bottom:1px solid var(--line)}.source-row>div:first-child{flex:1;min-width:0}.source-row .row{margin-top:0;max-width:470px}.source-row b{margin-right:14px;font-weight:550}.source-row p{margin:8px 0}.source-row small{font-size:16px}.source-url{display:block;margin:8px 0;color:var(--muted);font-size:17px;overflow-wrap:anywhere}.source-form{padding-top:28px}.confirm-box,.release-card{padding:22px;border:1px solid #565d68;background:#202328;margin-top:24px;border-radius:4px}.confirm-box p{color:#c9cfd9}.settings-card details{font-size:18px;margin-top:10px}.settings-card summary{cursor:pointer;padding:8px 0}.plugin{padding:18px 0;border-bottom:1px solid var(--line)}.plugin>small{display:block;overflow-wrap:anywhere}
|
||||
.empty-state{min-height:320px;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:38px;text-align:center;gap:16px}.empty-state h2{font-size:26px;margin:0}.empty-state p{color:var(--muted);font-size:19px;max-width:520px;margin:0}.empty-symbol{color:#808994}.empty{padding:36px;text-align:center;color:var(--muted)}.page-title{padding:16px 24px;display:flex;align-items:center;gap:12px;flex:none;border-bottom:1px solid var(--line)}.page-title strong{flex:1;font-size:24px}.quick:has(.page-title) .shell-header{display:none}iframe{width:100%;flex:1;min-height:0;border:0;background:var(--bg)}.error,.banner{padding:14px 16px;margin:12px 0;border-radius:3px;line-height:1.5;overflow-wrap:anywhere;font-size:18px;flex:none;border-left:3px solid #929ca9;background:#252930;color:#d5dbe4}.error{border-color:#db9797;background:#3a292d;color:#f1b8b8}.app>.error,.app>.banner{margin:12px 26px}.error button{float:right;min-height:30px;padding:0 8px;background:transparent;border:0}.banner p{margin:8px 0}
|
||||
.modal{position:absolute;top:50%;bottom:auto;transform:translateY(-50%);max-height:calc(100% - 56px);left:max(24px,calc((100% - 780px)/2));right:max(24px,calc((100% - 780px)/2));padding:30px;background:#202328;border:1px solid #626974;border-radius:6px;overflow:auto;box-shadow:0 0 0 2000px #000a;z-index:20}.modal h2{font-size:28px;margin-bottom:16px}.modal h3{margin-top:24px}.modal button{margin:8px 8px 8px 0}.modal .source-row{display:block}.modal .row{margin:12px 0}.modal code{display:block;font-size:17px;overflow-wrap:anywhere;background:#141619;padding:16px;border-radius:3px}.modal pre{font:17px/1.6 monospace;white-space:pre-wrap;overflow-wrap:anywhere}.modal-close{float:right}.transfer{z-index:30;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:16px}.transfer>p{margin:0}progress{width:100%;height:10px;accent-color:var(--blue)}.download-progress{margin:24px 0}.system-updates>h2{display:none}.system-updates .error small{display:block;margin-top:8px;color:inherit}.manage-dialog>button{min-width:150px}.manage-dialog>.danger{display:block}
|
||||
.update-confirmation{position:fixed;margin:0;width:auto;color:var(--text);box-shadow:none}.update-confirmation::backdrop{background:#000a}
|
||||
.update-confirmation,.update-release-dialog{position:fixed;margin:0;width:auto;color:var(--text);box-shadow:none}.update-confirmation::backdrop,.update-release-dialog::backdrop{background:#000a}
|
||||
html.notification-view,html.notification-view body{background:transparent}.toasts{height:100%;overflow:auto;padding:8px;background:transparent;font-size:18px}.toast{padding:16px;background:#202328;border:1px solid #454b55;border-radius:8px;margin-bottom:8px}.toast-heading{display:flex;align-items:center;gap:10px}.toast-heading>div{flex:1;min-width:0}.toast-heading small{font-size:14px}.toast h2{font-size:21px;line-height:1.3;margin:2px 0 0;overflow-wrap:anywhere}.toast-close{min-width:36px;min-height:36px;padding:6px;border:0;background:transparent;color:var(--muted)}.toast-content{display:flex;align-items:flex-start;gap:12px;margin-top:12px}.toast-content img{width:80px;height:64px;object-fit:contain;flex:none}.toast-content p{margin:0;line-height:1.45;max-height:104px;overflow:auto;overflow-wrap:anywhere;flex:1}.toast-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}.toast-actions button{min-width:44px;min-height:40px;padding:6px 10px;font-size:20px;border-radius:4px}
|
||||
@media(max-width:1000px){.manager .workspace{grid-template-columns:186px minmax(0,1fr)}.manager nav{padding:20px 8px}.manager nav button{padding:12px 10px;font-size:18px}.content{padding:24px}.row-state{width:90px}.row-version{width:85px}.list-columns{grid-template-columns:minmax(0,1fr) 90px 85px 184px}.list-heading{flex-wrap:wrap}.manager .plugin-copy small{white-space:normal}.source-row{flex-direction:column}.store-version{display:none}}
|
||||
@media(max-width:700px){.manager .workspace{display:flex}.manager nav{flex-direction:row;padding:8px;overflow:auto;border-right:0;border-bottom:1px solid var(--line)}.manager nav button{white-space:nowrap}.nav-foot{display:none}.shell-header{padding:14px 18px}.header-label{display:none}.shell-header .primary{font-size:18px}.content{padding:18px}.list-columns,.row-state,.row-version,.manager .plugin-switch{display:none}.manager .plugin-meta{display:flex}.list-heading .search-field{width:100%}.store-card{gap:14px;padding:18px 0}.store-card>.badge{min-width:65px;font-size:15px}.store-card>.plugin-avatar{width:64px!important;height:64px!important}.store-card>svg{display:none}.store-copy p{font-size:17px}.store-copy h2{font-size:22px}.store-filters .search-field{min-width:100%}.detail-heading{flex-wrap:wrap}.detail-heading .primary{width:100%}.modal{left:14px;right:14px;max-height:calc(100% - 28px);padding:22px}}
|
||||
@@ -171,3 +171,21 @@ button{overflow-wrap:anywhere}
|
||||
@media(max-width:700px){.notification-global-options>.notification-option{grid-template-columns:minmax(0,1fr) 60px;gap:4px}}
|
||||
|
||||
.toasts .notification-card button:active:not(:disabled){transform:none}
|
||||
|
||||
.update-release-dialog .release-card{padding:0;margin:0;border:0;background:transparent}.update-release-dialog .prose{max-height:40dvh;overflow:auto;white-space:pre-wrap}
|
||||
|
||||
/* All built-in options use the same switch and full-width setting row. */
|
||||
.ui-switch{display:flex;align-items:center;justify-content:center;flex:none;width:60px;min-width:60px;height:52px;min-height:52px;padding:0 4px;border:0;background:transparent}
|
||||
.ui-switch>span{position:relative;display:block;width:52px;height:30px;border:1px solid #626770;background:#2a2e34;border-radius:999px}
|
||||
.ui-switch>span:after{content:'';position:absolute;left:3px;top:3px;width:22px;height:22px;background:#a4aab3;border-radius:50%;transition:transform .16s,background .16s}
|
||||
.ui-switch.on>span{background:#445b69;border-color:#7697aa}.ui-switch.on>span:after{left:3px;transform:translateX(22px);background:#e5f0f8}
|
||||
.ui-switch:hover:not(:disabled){background:#252a30}.switch-row{width:100%;max-width:none;display:flex;align-items:center;justify-content:space-between;gap:24px;padding:14px 0;min-height:72px}
|
||||
.switch-row>div{flex:1;min-width:0}.switch-row b{display:block;font-size:19px;font-weight:550;overflow-wrap:anywhere}.switch-row small{display:block;margin-top:5px;line-height:1.6}
|
||||
.manager .content{min-width:0}.manager .content>.settings-card,.manager .content>section,.manager .content .store-detail{width:100%;max-width:none}
|
||||
.manager .settings-card>p,.manager .settings-card>label,.manager .source-form,.manager .detail-summary{width:100%;max-width:none}
|
||||
.manager .network-password{width:100%;max-width:none!important}.manager .network-port>label{flex:1;min-width:0}.manager .network-port input[type=number]{width:100%}
|
||||
.manager .proxy-fields{grid-template-columns:minmax(0,1fr)}.manager .language-settings .language-picker{width:100%;flex-basis:100%;max-width:none;min-width:0}
|
||||
.manager .system-updates .framely-select{width:100%;max-width:none}.manager .launcher-settings>label{display:flex;flex-direction:column;align-items:stretch;gap:8px;width:100%;max-width:none}
|
||||
.manager .launcher-settings input[type=number]{width:100%;margin:0}.manager .source-row:has(.switch-row){flex-direction:column}.manager .source-row:has(.switch-row)>div{width:100%;max-width:none}.manager .source-row:has(.switch-row)>.row{gap:8px}
|
||||
.manager .source-row .switch-row{flex-basis:100%}.manager .notification-plugin-list,.manager .notification-global-options{width:100%}
|
||||
.manager .network-port{flex-direction:column;align-items:stretch}.manager .network-port>label{flex:auto;width:100%}.manager .network-port>button{align-self:flex-start}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {SwitchRow} from './switch';
|
||||
import {t} from './i18n';
|
||||
import React,{useState} from 'react';
|
||||
import {api} from './api';import {runJob} from './store';
|
||||
@@ -6,7 +7,7 @@ export function Subscriptions({subscriptions,refresh}:{subscriptions:Subscriptio
|
||||
const[url,setUrl]=useState(''),[preview,setPreview]=useState<any>(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[confirm,setConfirm]=useState<{sub:Subscription;kind:'remove'|'acceptUrls'}|null>(null);
|
||||
async function execute(f:()=>Promise<unknown>){setBusy(true);setError('');try{await f();await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
return <section className="settings-card"><h2>{t("源订阅")}</h2><p className="sub">{t("每天同步订阅中的插件源;不自动安装或更新插件。")}</p>
|
||||
{subscriptions.map(s=><article className="source-row" key={s.id}><div><b>{s.name}</b><span className="badge">{s.enabled?t("已启用"):t("已停用")} · {s.entries.length}{t("个源")}</span><p className="source-url">{s.url}</p><small>{s.lastSuccess?t("上次成功:{0}", {"0": new Date(s.lastSuccess).toLocaleString()}):t("尚未成功刷新")} · {s.autoRefresh?t("每天自动刷新"):t("仅手动刷新")}</small>{s.error&&<p className="error">{s.error}{t(",保留上次成功列表。")}</p>}{!!s.pending.length&&<p className="banner">{s.pending.length}{t("个源地址发生变化,等待确认。")}</p>}<details><summary>{t("包含的源")}</summary>{s.entries.map(e=><p key={e.id}>{e.name}<small className="source-url">{e.url}</small></p>)}</details></div><div className="row"><button disabled={busy} onClick={()=>void execute(()=>api('subscriptions.change',{id:s.id,enabled:!s.enabled}))}>{s.enabled?t("停用"):t("启用")}</button><button disabled={busy||!s.enabled} onClick={()=>void execute(()=>runJob('subscriptions.refresh.start',{id:s.id}))}>{t("立即刷新")}</button><button disabled={busy} onClick={()=>void execute(()=>api('subscriptions.change',{id:s.id,autoRefresh:!s.autoRefresh}))}>{s.autoRefresh?t("关闭自动刷新"):t("开启自动刷新")}</button>{!!s.pending.length&&<button onClick={()=>setConfirm({sub:s,kind:'acceptUrls'})}>{t("查看地址变更")}</button>}<button className="danger" onClick={()=>setConfirm({sub:s,kind:'remove'})}>{t("删除订阅")}</button></div></article>)}
|
||||
{subscriptions.map(s=><article className="source-row" key={s.id}><div><b>{s.name}</b><span className="badge">{s.enabled?t("已启用"):t("已停用")} · {s.entries.length}{t("个源")}</span><p className="source-url">{s.url}</p><small>{s.lastSuccess?t("上次成功:{0}", {"0": new Date(s.lastSuccess).toLocaleString()}):t("尚未成功刷新")} · {s.autoRefresh?t("每天自动刷新"):t("仅手动刷新")}</small>{s.error&&<p className="error">{s.error}{t(",保留上次成功列表。")}</p>}{!!s.pending.length&&<p className="banner">{s.pending.length}{t("个源地址发生变化,等待确认。")}</p>}<details><summary>{t("包含的源")}</summary>{s.entries.map(e=><p key={e.id}>{e.name}<small className="source-url">{e.url}</small></p>)}</details></div><div className="row"><SwitchRow checked={s.enabled} label={t("启用 {0}",{0:s.name})} disabled={busy} onChange={enabled=>void execute(()=>api('subscriptions.change',{id:s.id,enabled}))}/><button disabled={busy||!s.enabled} onClick={()=>void execute(()=>runJob('subscriptions.refresh.start',{id:s.id}))}>{t("立即刷新")}</button><SwitchRow checked={s.autoRefresh} label={t("每天自动刷新")} disabled={busy} onChange={autoRefresh=>void execute(()=>api('subscriptions.change',{id:s.id,autoRefresh}))}/>{!!s.pending.length&&<button onClick={()=>setConfirm({sub:s,kind:'acceptUrls'})}>{t("查看地址变更")}</button>}<button className="danger" onClick={()=>setConfirm({sub:s,kind:'remove'})}>{t("删除订阅")}</button></div></article>)}
|
||||
<label>{t("订阅地址")}<input type="url" value={url} onChange={e=>setUrl(e.target.value)} placeholder="https://example.org/subscription.json"/></label><button disabled={busy||!url.trim()} onClick={()=>void execute(async()=>{const v=await runJob('subscriptions.preview.start',{url:url.trim()});setPreview(v.result);})}>{t("预览订阅")}</button>{error&&<p className="error">{error}</p>}
|
||||
{preview&&<div className="modal"><h2>{t("添加订阅:")}{preview.document.name}</h2><p>{preview.document.sources.length}{t("个源 · 默认每天刷新")}</p>{preview.document.sources.map((s:any)=><p key={s.id}><b>{s.name}</b><small className="source-url">{s.url}</small></p>)}<button className="primary" disabled={busy} onClick={()=>void execute(async()=>{await api('subscriptions.add',{...preview,approve:true});setPreview(null);setUrl('');})}>{t("确认添加")}</button><button disabled={busy} onClick={()=>setPreview(null)}>{t("取消")}</button></div>}
|
||||
{confirm&&<div className="modal"><h2>{confirm.kind==='remove'?t("删除订阅"):t("确认源地址变更")}</h2><p>{confirm.sub.name}</p>{confirm.kind==='remove'?<p>{t("移除此订阅对各源的引用;其他订阅共享的源和手动源会保留。已安装插件保留。")}</p>:confirm.sub.pending.map(next=><div key={next.id}><b>{next.name}</b><p className="source-url">{t("原地址:")}{confirm.sub.entries.find(e=>e.id===next.id)?.url}</p><p className="source-url">{t("新地址:")}{next.url}</p></div>)}<button disabled={busy} className="primary" onClick={()=>void execute(async()=>{await api('subscriptions.change',{id:confirm.sub.id,[confirm.kind]:true,approve:true});setConfirm(null);})}>{t("确认")}</button><button disabled={busy} onClick={()=>setConfirm(null)}>{t("取消")}</button></div>}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
export type SwitchProps={checked:boolean;label:string;disabled?:boolean;onChange:(checked:boolean)=>void;className?:string};
|
||||
export function Switch({checked,label,disabled=false,onChange,className=''}:SwitchProps){
|
||||
return <button type="button" className={`ui-switch ${checked?'on':''} ${className}`} role="switch" aria-checked={checked} aria-label={label} disabled={disabled} onClick={()=>onChange(!checked)}><span aria-hidden="true"/></button>;
|
||||
}
|
||||
export function SwitchRow({description,...props}:SwitchProps&{description?:string}){
|
||||
return <div className="switch-row"><div><b>{props.label}</b>{description&&<small>{description}</small>}</div><Switch {...props}/></div>;
|
||||
}
|
||||
+30
-9
@@ -1,3 +1,4 @@
|
||||
import {SwitchRow} from './switch';
|
||||
import {t} from './i18n';
|
||||
import React,{useEffect,useId,useLayoutEffect,useRef,useState} from 'react';
|
||||
import {api} from './api';
|
||||
@@ -5,11 +6,21 @@ import {runJob,Job,compareVersions} from './store';
|
||||
import {Select} from './localized-select';
|
||||
type UpdateChannel='stable'|'testing';
|
||||
type UpdateSource={url:string};
|
||||
export function sameSystemVersion(target:string,current:string){
|
||||
const version=(value:string)=>value.replace(/-[a-f0-9]{12}$/i,'');
|
||||
return compareVersions(version(target),version(current))===0;
|
||||
}
|
||||
export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<void>}){
|
||||
const source:UpdateSource|null=status.database.updateSource??null;
|
||||
const channel:UpdateChannel=status.database.updateChannel??'stable';
|
||||
const automatic=status.database.updateCheck??{enabled:true,intervalHours:6};
|
||||
const[error,setError]=useState(''),[busy,setBusy]=useState(false),[job,setJob]=useState<Job|null>(null),[release,setRelease]=useState<any>(null),[ready,setReady]=useState(false),[checked,setChecked]=useState(false),[confirm,setConfirm]=useState<'apply'|'rollback'|null>(null),[installing,setInstalling]=useState(false);
|
||||
const[error,setError]=useState(''),[busy,setBusy]=useState(false),[job,setJob]=useState<Job|null>(null),[release,setRelease]=useState<any>(null),[ready,setReady]=useState(false),[checked,setChecked]=useState(false),[showRelease,setShowRelease]=useState(false),[confirm,setConfirm]=useState<'apply'|'rollback'|null>(null),[installing,setInstalling]=useState(false);
|
||||
const releaseDialog=useRef<HTMLDialogElement>(null),dismissRelease=useRef<HTMLButtonElement>(null),releaseTitle=useId();
|
||||
useLayoutEffect(()=>{
|
||||
const dialog=releaseDialog.current;if(!showRelease||!dialog)return;
|
||||
const previous=document.activeElement as HTMLElement|null;dialog.showModal();dismissRelease.current?.focus();
|
||||
return()=>{dialog.close();if(previous?.isConnected)previous.focus();};
|
||||
},[showRelease]);
|
||||
const confirmation=useRef<HTMLDialogElement>(null),cancelConfirmation=useRef<HTMLButtonElement>(null),confirmationTitle=useId(),confirmationDescription=useId();
|
||||
useLayoutEffect(()=>{
|
||||
const dialog=confirmation.current;if(!confirm||!dialog)return;
|
||||
@@ -17,10 +28,12 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
|
||||
dialog.showModal();cancelConfirmation.current?.focus();
|
||||
return()=>{dialog.close();if(previous?.isConnected)previous.focus();};
|
||||
},[confirm]);
|
||||
function reset(){setRelease(null);setReady(false);setChecked(false);setConfirm(null);}
|
||||
useEffect(()=>{if(!confirm&&!showRelease)return;const back=(e:Event)=>{e.stopImmediatePropagation();if(confirm)setConfirm(null);else setShowRelease(false);};window.addEventListener('framely.back',back,true);return()=>window.removeEventListener('framely.back',back,true);},[confirm,showRelease]);
|
||||
function reset(){setShowRelease(false);setRelease(null);setReady(false);setChecked(false);setConfirm(null);}
|
||||
useEffect(reset,[source?.url,channel]);
|
||||
const progress=job?.kind==='system.download'?job:status.systemUpdate;
|
||||
const older=release&&compareVersions(release.targetVersion??release.version,status.version)===-1;
|
||||
const sameVersion=release&&sameSystemVersion(release.version,status.build??status.version);
|
||||
async function switchChannel(value:string){
|
||||
if(!source||busy||installing||value===channel)return;
|
||||
setBusy(true);setError('');reset();
|
||||
@@ -43,6 +56,7 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
|
||||
if(!confirm||busy||installing)return;
|
||||
const action=confirm,version=release?.version;
|
||||
setConfirm(null);setBusy(true);setError('');
|
||||
setShowRelease(false);
|
||||
let started=false;
|
||||
try{
|
||||
if(action==='apply'&&!ready){
|
||||
@@ -52,7 +66,7 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
|
||||
setInstalling(true);
|
||||
await api(action==='apply'?'system.apply':'system.rollback',{approve:true,version});
|
||||
started=true;
|
||||
}catch(e){setError(String(e));setInstalling(false);}
|
||||
}catch(e){setError(String(e));setInstalling(false);if(action==='apply')setShowRelease(true);}
|
||||
finally{
|
||||
setBusy(false);setJob(null);
|
||||
if(!started)await refresh().catch(()=>{});
|
||||
@@ -66,11 +80,11 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
|
||||
<Select label={t('更新渠道')} value={channel} disabled={busy||installing||!source} onChange={value=>void switchChannel(value)} options={[{value:'stable',label:t('正式版')},{value:'testing',label:t('测试版')}]}/>
|
||||
<p className="sub">{channel==='stable'?t('仅检查正式版。'):t('仅检查测试版(Preview、Beta、RC 等)。')}</p>
|
||||
<div className="row">
|
||||
<button className="primary" disabled={busy||installing||!source} onClick={()=>{reset();void run('system.check.start',{},v=>{setRelease(v.release?{...v.release,targetVersion:v.version}:null);setReady(false);setChecked(true);});}}>{busy?t('处理中…'):t('检查更新')}</button>
|
||||
<button className="primary" disabled={busy||installing||!source} onClick={()=>{reset();void run('system.check.start',{},v=>{setRelease(v.release?{...v.release,targetVersion:v.version}:null);setReady(false);setChecked(true);setShowRelease(!!v.release&&!sameSystemVersion(v.release.version,status.build??status.version));});}}>{busy?t('处理中…'):t('检查更新')}</button>
|
||||
<button disabled={busy||installing||!status.previousRelease} onClick={()=>setConfirm('rollback')}>{t('回滚上一版本')}</button>
|
||||
</div>
|
||||
<div className="automatic-update-settings">
|
||||
<label><input type="checkbox" role="switch" checked={automatic.enabled} disabled={busy||installing||!source} onChange={e=>void saveAutomatic({...automatic,enabled:e.target.checked})}/><span>{t('自动检查更新')}</span></label>
|
||||
<SwitchRow checked={automatic.enabled} label={t('自动检查更新')} disabled={busy||installing||!source} onChange={enabled=>void saveAutomatic({...automatic,enabled})}/>
|
||||
<label>{t('检查间隔')}</label><Select label={t('检查间隔')} value={String(automatic.intervalHours)} disabled={busy||installing||!source||!automatic.enabled} onChange={value=>void saveAutomatic({...automatic,intervalHours:Number(value)})} options={[...new Set([1,6,12,24,automatic.intervalHours])].sort((a,b)=>a-b).map(hours=>({value:String(hours),label:hours===1?t('每小时'):t('每 {0} 小时',{0:hours})}))}/>
|
||||
<p className="sub">{t('发现新版本时发送通知,您可以忽略提醒或打开更新页面。')}</p>
|
||||
{status.automaticUpdate?.checking&&<p className="sub" role="status">{t('正在自动检查更新…')}</p>}
|
||||
@@ -82,13 +96,20 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
|
||||
{progress?.phase==='verifying'&&<div className="download-progress"><progress aria-label={t('校验进度')} max={progress.total??1} value={progress.verified??0}/><p>{t('已校验 {0} / {1} MiB',{'0':((progress.verified??0)/1024/1024).toFixed(1),'1':((progress.total??0)/1024/1024).toFixed(1)})}</p></div>}
|
||||
{progress?.phase==='failed'&&<p className="error">{t('上次更新失败:')}{progress.error}<small>{t('诊断日志:/var/lib/framely/logs/update.log')}</small></p>}
|
||||
{progress?.phase==='done'&&<p className="banner">{t('上次更新操作已完成。')}</p>}
|
||||
{release&&<div className="release-card">
|
||||
{checked&&sameVersion&&<p className="banner" role="status">{t('当前已是此版本,无需重复更新。')}</p>}
|
||||
{showRelease&&release&&<dialog ref={releaseDialog} className="modal update-release-dialog" aria-labelledby={releaseTitle} onCancel={e=>{e.preventDefault();setShowRelease(false);}} onKeyDown={e=>{if(e.key==='Escape'){e.stopPropagation();setShowRelease(false);}}}>
|
||||
<h2 id={releaseTitle}>{t('发现可用发行版本')}</h2>
|
||||
<div className="release-card">
|
||||
<h3>{release.version}</h3>
|
||||
<p>{release.version===(status.build??status.version)?t('当前已是此发行版本'):t('发行包大小:{0} MiB',{'0':(release.size/1024/1024).toFixed(1)})}</p>
|
||||
<p>{sameVersion?t('当前已是此版本,无需重复更新。'):t('发行包大小:{0} MiB',{'0':(release.size/1024/1024).toFixed(1)})}</p>
|
||||
{older&&<p className="banner">{t('所选版本早于当前版本,安装将切换到较旧版本。')}</p>}
|
||||
<p className="prose">{release.changelog||t('发布者未提供更新说明。')}</p>
|
||||
{release.version!==(status.build??status.version)&&<button className="primary" disabled={busy||installing} onClick={()=>setConfirm('apply')}>{ready?t('安装已校验版本'):t('下载并安装')}</button>}
|
||||
</div>}
|
||||
{error&&<p className="error" role="alert">{error}</p>}
|
||||
<div className="row">
|
||||
{!sameVersion&&<button className="primary" disabled={busy||installing} onClick={()=>setConfirm('apply')}>{ready?t('安装已校验版本'):t('下载并安装')}</button>}
|
||||
<button ref={dismissRelease} disabled={busy||installing} onClick={()=>setShowRelease(false)}>{t('稍后')}</button>
|
||||
</div>
|
||||
</div></dialog>}
|
||||
{error&&<p className="error" role="alert">{error}</p>}
|
||||
{installing&&<p className="banner">{t('正在切换版本。Framely 入口会在服务恢复后重新出现。')}</p>}
|
||||
{confirm&&<dialog ref={confirmation} className="modal update-confirmation" aria-labelledby={confirmationTitle} aria-describedby={confirmationDescription} onCancel={e=>{e.preventDefault();setConfirm(null);}} onKeyDown={e=>{if(e.key==='Escape'){e.stopPropagation();setConfirm(null);}}}>
|
||||
|
||||
Reference in new issue
Block a user