mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 08:01:30 +02:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f102d78804 | ||
|
|
7719ddd9a8 | ||
|
|
a04c445210 | ||
|
|
57d70f807b | ||
|
|
142fd2a345 | ||
|
|
bf73601f9c | ||
|
|
dd990dd401 | ||
|
|
66fe29b024 | ||
|
|
85017b2c54 | ||
|
|
5e0bb582f0 | ||
|
|
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.4-preview.1"
|
||||
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.4-preview.1"
|
||||
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.4-preview.1
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -35,6 +49,7 @@ Start at the [documentation index](docs/README.md):
|
||||
|
||||
- [Install, update, repair, roll back, and uninstall](docs/user-guide/installation.md)
|
||||
- [Basic usage](docs/user-guide/basic-usage.md) · [Install and manage plugins](docs/user-guide/plugins.md)
|
||||
- [APK Manager](docs/user-guide/apk-manager.md) · [Space launcher](docs/launcher.md)
|
||||
- [Network management panel](docs/user-guide/network-panel.md)
|
||||
- [Plugin development through publishing](docs/developer-guide/README.md) · [Publish plugins and sources](docs/developer-guide/publishing.md)
|
||||
- [SDK API](docs/developer-guide/sdk.md) · [Manifest configuration](docs/developer-guide/manifest.md) · [Lifecycle](docs/plugin-lifecycle.md)
|
||||
|
||||
+18
-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.4-preview.1
|
||||
```
|
||||
|
||||
首次安装使用包含 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`。未配置密码时首次访问必须设置并确认密码,保存后登录。设备密码与面板密码相互独立。
|
||||
|
||||
@@ -35,6 +49,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
|
||||
- [安装、更新、修复、回滚与卸载](docs/zh-CN/user-guide/installation.md)
|
||||
- [基本使用](docs/zh-CN/user-guide/basic-usage.md) · [插件安装与管理](docs/zh-CN/user-guide/plugins.md)
|
||||
- [APK 管理](docs/zh-CN/user-guide/apk-manager.md) · [空间启动台](docs/zh-CN/launcher.md)
|
||||
- [网络管理面板](docs/zh-CN/user-guide/network-panel.md)
|
||||
- [插件开发到发布完整流程](docs/zh-CN/developer-guide/README.md) · [发布插件和插件源](docs/zh-CN/developer-guide/publishing.md)
|
||||
- [SDK API](docs/zh-CN/developer-guide/sdk.md) · [Manifest 配置](docs/zh-CN/developer-guide/manifest.md) · [生命周期](docs/zh-CN/plugin-lifecycle.md)
|
||||
|
||||
@@ -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.
|
||||
@@ -10,6 +10,7 @@ Framely manages plugins on Steam Frame through device services, a SteamVR interf
|
||||
2. [Basic usage](user-guide/basic-usage.md): Dock, manager, enabling plugins, favorites and safe mode.
|
||||
3. [Install and manage plugins](user-guide/plugins.md): plugin library, sources, subscriptions, local packages and updates.
|
||||
4. [Network panel](user-guide/network-panel.md): address, first password setup, login and phone/desktop access.
|
||||
5. [APK Manager](user-guide/apk-manager.md): sideload installation, updates, containers, retained data and cleanup.
|
||||
|
||||
## Plugin development and publishing
|
||||
|
||||
|
||||
@@ -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 native CEF host installs vertical trigger-drag scrolling whenever a frame finishes loading, including existing plugin pages, nested frames and `localWeb` windows without the SDK. The shared document guard prevents duplicate installation by the host bootstrap or SDK. Laser-targeted stick scrolling also works in these windows through native wheel input. 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`.
|
||||
@@ -0,0 +1,80 @@
|
||||
# APK Manager
|
||||
|
||||
[简体中文](../zh-CN/user-guide/apk-manager.md)
|
||||
|
||||
[Documentation](../README.md) · [Launcher](../launcher.md)
|
||||
|
||||
APK Manager installs and manages sideloaded Lepton applications on Steam Frame. Use the header switch to move between Plugin Manager and APK Manager in the headset or authenticated network panel. Visiting the device IP opens Plugin Manager by default. APK Manager has its own Apps, Containers and Data cleanup navigation. It is a file-based manager: it has no store, recommendations, download sources, or automatic APK updates.
|
||||
|
||||
## Requirements and discovery
|
||||
|
||||
Install Lepton through Steam first. Framely uses the Steam session user's Lepton and Podman; it does not install Android globally or operate as root. Lepton compatibility varies: Google services, vendor APIs and DRM may be unavailable.
|
||||
|
||||
Open **APK Manager → Refresh**. Discovery reads the standard Lepton context directories, saved Framely records and running containers' compatibility-data labels. It does not start containers during scanning. Steam-managed APKs appear in the container view; use Steam for their updates and removal.
|
||||
|
||||
For data stored elsewhere, open **Containers → Additional data locations** and add the `baked` directory containing `data_overlay/system/packages.xml`. Enter the original Lepton context name when it differs from the enclosing directory. Names cannot conflict with containers belonging to other data locations. Paths must belong to the session user. Removing a scan location does not delete its data. Custom tools can use layouts Framely cannot discover; missing locations must be registered explicitly.
|
||||
|
||||
Applications are identified by context plus package name. The same package in two containers is treated as two applications. A context name or location change can therefore create a different identity.
|
||||
|
||||
## Install and update
|
||||
|
||||
Choose **Install APK**, select an individual `.apk`, and review its name, package, version, minimum Android SDK and target container. Uploads from the network panel are staged on Frame's persistent filesystem, not its limited `/tmp`. Transfer shows actual bytes; subsequent work shows named stages instead of invented percentages.
|
||||
|
||||
Inside Frame, file inputs in both management panels use Framely’s built-in file picker, including APKs, plugin packages and language files. It opens Downloads by default, remembers the last selected folder, and provides a lazily loaded folder tree for Home, temporary files and mounted storage, with folder navigation, path entry, search, hidden files and file-type filtering. Select a file and confirm; B/Escape cancels the picker without closing the panel. External browsers continue using their system file picker. The launcher’s **Install APK** shortcut opens APK Manager.
|
||||
|
||||
New apps default to independent containers. Existing containers are offered only when they can accept the target package without overwriting another app's shared APK mount. Lepton recommends one application per container because Android apps do not have the usual enforced isolation inside its containers.
|
||||
|
||||
After installation, choose **Open** if desired; installation does not automatically launch the APK. An app with no detected launch entry remains manageable but does not appear in the launcher.
|
||||
|
||||
To update, open the app's management dialog and choose **Install newer APK**. Package identity and version are checked; Android enforces signing compatibility during replacement. Downgrades are refused, and signature failures never trigger an uninstall/reinstall workaround. Same-package replacement retains data. Updating may stop the target container, affecting its other applications.
|
||||
|
||||
Before replacing an existing installation, Framely saves a container-data snapshot while it is stopped. Scratch overlay work directories, runtime sockets and FIFOs are excluded. Backups include a SHA-256 integrity inventory checked before restoration. Backup failure aborts replacement. Snapshots are recovery material, not a guarantee that every Android data migration is reversible. One-click restore supports sideloaded containers with a single recorded application and a matching data location, including external containers; shared snapshots require manual recovery. Restoration creates another snapshot of the current state and requires launching the app to verify saved data afterward.
|
||||
|
||||
Once a modifying operation starts, cancellation is disabled until its actual outcome is known. Uploads and preparation can be cancelled. Switching panels does not stop a submitted task; reopening APK Manager resumes its progress display. Interrupted restores are recovered from a local transaction journal before the next modifying operation. After interruption, use **Reconcile installed state** before retrying or deleting data. An unreadable container displays Installation state unknown, preserves the previous record and disables launching, reinstallation and deletion until its actual state is reconciled.
|
||||
|
||||
## Launch and window display
|
||||
|
||||
The launcher uses declared MAIN/LAUNCHER, television launcher and recognized VR entries, plus the running Android package manager's resolved launcher entry. Disabled packages/components are excluded. A launch entry does not guarantee that an app will run successfully.
|
||||
|
||||
Window handling is automatic by default. **Advanced settings → Show Android window** is a troubleshooting override, applied on container restart. It does not convert a flat application into VR or a VR application into a flat application. Lepton development contexts may mount their APK at a different path after a restart. Framely re-registers the same signed APK when Android can no longer resolve it, preserving data and reporting failures. Advanced settings also allow selecting an enabled, declared Activity when a nonstandard app needs a different entry.
|
||||
|
||||
Launcher favorites, search and manual ordering continue to use stable context/package identities. Updating and refreshing do not reorder icons. Uninstalling hides an icon without deleting its favorite/order record; reinstalling in the same context restores it. Long-press a local Lepton icon to open APK management or removal confirmation.
|
||||
|
||||
## Close, uninstall and clean data
|
||||
|
||||
**Close app** force-stops only the selected package. **Stop container** affects every app inside it. All non-Steam sideloaded containers with a verified data location support deletion after confirmation of affected apps and permanent data loss. Standard Lepton context directories are removed as a whole; registered external locations remove only the baked directory, preserving unrelated files in the parent. Uninstalling an app, even with Delete app data enabled, does not delete its container.
|
||||
|
||||
Uninstallation retains saved data by default. Enable **Delete app data** to remove it. Retained apps appear under **Uninstalled, data retained**, where you can reinstall or remove retained data.
|
||||
|
||||
Android may require the original signed APK to remove an already uninstalled package's retained state. Framely keeps the original APK when it manages an uninstall. If an externally uninstalled app has no recoverable APK, supply its original APK through reinstallation before removing its retained state. Framely does not bypass signing checks or delete arbitrary directories to simulate success.
|
||||
|
||||
**Data cleanup** lists retained data, old APKs, recovery snapshots and expired temporary files. Saved data and backups are not selected by default. Unidentified data is displayed without an automatic deletion action. Each selected item is checked again before removal; a reinstalled application cannot be cleaned as an uninstalled one. Batch results report actual released file bytes and individual failures. File-byte counts may differ from physical disk usage due to compression, reflinks and shared storage.
|
||||
|
||||
## Logs and recovery without the panel
|
||||
|
||||
Use the app's **Logs → Export logs** to save its operation log. Detailed Android errors remain in logs. Review logs before sharing them.
|
||||
|
||||
As the Steam session user, the installed binary also provides these commands:
|
||||
|
||||
```bash
|
||||
/home/.framely/current/bin/framely apk list
|
||||
/home/.framely/current/bin/framely apk inspect --file /path/to/app.apk
|
||||
/home/.framely/current/bin/framely apk install '{"ticket":"REVIEW_TICKET","approve":true}'
|
||||
/home/.framely/current/bin/framely apk launch '{"app":"CONTEXT/PACKAGE"}'
|
||||
/home/.framely/current/bin/framely apk logs '{"app":"CONTEXT/PACKAGE"}'
|
||||
/home/.framely/current/bin/framely apk cleanup-list
|
||||
```
|
||||
|
||||
Reviews expire after 15 minutes. APKs, operation records and recovery material are stored in `~/.local/share/framely/apk-manager`; managed Lepton data lives under `~/.local/share/lepton/contexts`. Framely uninstallation retains these user-owned directories. Back them up before removing Framely; the APK CLI requires a compatible Framely binary, while Lepton remains independently installed through Steam.
|
||||
|
||||
For a Framely-managed context, a manual Lepton launch must preserve data:
|
||||
|
||||
```bash
|
||||
LEPTON_NO_CLEANUP=true ~/.local/share/Steam/steamapps/common/Lepton/lepton start CONTEXT
|
||||
```
|
||||
|
||||
Do not invoke Lepton's global cleanup commands as an application-specific uninstall.
|
||||
|
||||
## Supported formats
|
||||
|
||||
The initial implementation supports individual APKs. Split APK bundles, XAPK/APKM, OBB import, Google Play installation and automatic Steam shortcut creation are outside this version. Unsupported bundles are rejected rather than treated as ordinary APKs.
|
||||
@@ -56,3 +56,12 @@ 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.
|
||||
|
||||
|
||||
Launcher blank-space clicks only dismiss the icon menu. Click outside the launcher window or press B to dismiss it (B exits arrangement first). Steam’s move/depth controls keep the window open and pause stick navigation until the stick returns to neutral. While dragging an icon in arrangement mode, horizontal stick input changes pages; vertical input does not change categories. The boundary rails remain an alternative: hold the pointer beyond a rail for 650 ms to move to the adjacent page.
|
||||
@@ -12,6 +12,7 @@ Framely 是 Steam Frame 的插件管理器,包含设备端服务、SteamVR 界
|
||||
2. [基本使用](user-guide/basic-usage.md):Dock 入口、管理窗口、启停、收藏、安全模式。
|
||||
3. [安装与管理插件](user-guide/plugins.md):插件库、来源、订阅、本地包和更新。
|
||||
4. [网络管理面板](user-guide/network-panel.md):地址、首次密码、登录、手机/电脑使用。
|
||||
5. [APK 管理](user-guide/apk-manager.md):侧载安装、更新、容器、保留数据与清理。
|
||||
|
||||
## 插件开发与发布
|
||||
|
||||
|
||||
@@ -157,3 +157,7 @@ const off = framely.ui.onVisibilityChanged(state => {
|
||||
## 启动台操作与本体兼容
|
||||
|
||||
[启动台操作与本体兼容](launcher.md)
|
||||
|
||||
### 本体页面滚动
|
||||
|
||||
原生 CEF 宿主在每个页面加载完成时安装主扳机纵向拖动滚动,覆盖现有插件页面、嵌套页面,以及不使用 SDK 的 `localWeb` 窗口。共用的页面标记防止本体桥接或 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`,同时包含于本体更新包和完整离线包。
|
||||
|
||||
已收藏应用的右上角显示常驻的蓝色实心星标,再次点击即可取消收藏;收藏状态变化不会改变图标排序。
|
||||
@@ -0,0 +1,84 @@
|
||||
# APK 管理
|
||||
|
||||
[English](../../user-guide/apk-manager.md)
|
||||
|
||||
[文档首页](../README.md) · [启动台](../launcher.md)
|
||||
|
||||
APK 管理用于安装和管理 Steam Frame 上的 Lepton 侧载应用,本地管理面板与登录后的网络面板均可使用。它只管理用户提供的文件,不提供商店、推荐、下载源或自动更新。
|
||||
|
||||
通过顶部切换器可在插件管理与 APK 管理面板之间切换。直接访问设备 IP 默认进入插件管理;APK 管理有独立的应用、容器和数据清理导航。
|
||||
|
||||
## 环境与发现
|
||||
|
||||
先通过 Steam 安装 Lepton。Framely 使用 Steam 会话用户的 Lepton 与 Podman,不以 root 操作,也不全局安装 Android。应用兼容性取决于 Lepton;Google 服务、设备专用接口和 DRM 可能不可用。
|
||||
|
||||
打开“APK 管理 → 刷新”,扫描标准 Lepton 容器目录、Framely 已保存记录及运行中容器的兼容数据标签。扫描不会启动容器。Steam 管理的 APK 在容器视图中标识,更新和卸载仍通过 Steam。
|
||||
|
||||
其他工具使用自定义位置时,在“容器 → 其他数据位置”添加包含 `data_overlay/system/packages.xml` 的 `baked` 目录。如果原容器名称与上级目录不同,还需填写原容器名称;名称不能与其他数据目录对应的容器冲突。目录必须属于会话用户;移除扫描位置不会删除数据。无法自动发现的自定义布局需手动登记。
|
||||
|
||||
应用按“容器+包名”识别,同包在不同容器里分别管理。改变容器名称或数据位置可能改变应用身份。
|
||||
|
||||
## 安装与更新
|
||||
|
||||
点击“安装 APK”,选择单个 `.apk`,核对名称、包名、版本、最低 Android SDK 和目标容器。网络上传暂存于 Frame 持久化文件系统,避免占满较小的 `/tmp`。上传显示真实字节进度;后续显示处理阶段,不使用假百分比。
|
||||
|
||||
在 Frame 内,两个管理面板的文件选择入口统一使用 Framely 内置文件选择器,包括 APK、插件包和语言文件。默认打开下载目录,记住上次选择的目录;左侧为按需加载的文件夹树,覆盖主目录、临时目录和已挂载存储,支持逐级展开、收起和当前路径定位,提供目录导航、手动输入路径、搜索、隐藏文件和文件类型筛选。选中文件后确认;B/Escape 只取消选择器,不关闭管理面板。外部浏览器继续使用系统文件选择器。启动台右侧的“安装APK”按钮打开 APK 管理面板。
|
||||
|
||||
新应用默认独立容器。现有容器只有在不会覆盖其他应用的共享 APK 挂载层时才可作为目标。Lepton 建议每个应用独立容器,因为容器内没有通常 Android 应用之间的强制隔离。
|
||||
|
||||
安装不会自动打开应用,完成后可以点击“打开”。没有检测到启动入口的 APK 仍可管理,但不会放进启动台。
|
||||
|
||||
更新时进入应用管理,点击“安装新版 APK”。先核对包名与版本,再由 Android 在覆盖安装时执行签名兼容校验。默认拒绝降级;签名失败不会自动卸载重装。同包覆盖安装保留数据。更新可能停止目标容器,影响其中其他应用。
|
||||
|
||||
替换已有安装前,会停止容器并保存数据快照,排除临时 overlay 工作目录、运行时套接字和 FIFO,保留删除标记。恢复前核对 SHA-256 文件清单;校验失败不会替换数据。备份失败则中止替换。快照是恢复材料,不保证所有 Android 数据迁移都可逆。仅包含一个应用、且数据目录与备份一致的侧载容器支持一键恢复,包括外部容器;共享容器需手动恢复。一键恢复先备份当前状态,恢复后需启动应用核对存档。
|
||||
|
||||
修改操作开始后等待实际结果,不能安全中断时不提供取消。上传与准备阶段可取消。异常中断后先“核对实际安装状态”,再重试或清理数据;容器读不到不代表应用已卸载;界面显示“安装状态未知”,保留之前记录,禁用启动、重新安装和删除操作,先核对实际状态。
|
||||
|
||||
## 启动入口与窗口
|
||||
|
||||
后台识别清单中的 MAIN/LAUNCHER、电视启动入口和已知 VR 入口,并结合运行中的 Android 包管理器解析结果。停用的包与组件排除。有入口不代表一定能正常运行。
|
||||
|
||||
默认自动处理窗口显示。“高级设置 → 显示安卓窗口”用于排查,并在重启容器后生效。它不会把二维应用转成 VR,也不会把 VR 应用转成二维。Lepton 开发容器重启后可能改变 APK 挂载路径;Android 无法识别时,Framely 会重新登记同签名 APK,保留数据并报告错误。非标准应用还可从已启用、已声明的 Activity 中指定启动入口。
|
||||
|
||||
启动台继续按稳定的容器与包名保存收藏、搜索和排序。更新或刷新不重排图标;卸载隐藏图标,保留收藏与位置记录,在原容器重新安装后恢复。长按本地 Lepton 图标可进入管理或卸载确认。
|
||||
|
||||
## 关闭、卸载与清理
|
||||
|
||||
“关闭应用”只停止目标包;“停止容器”影响其中全部应用。所有可确认数据位置的非 Steam 侧载容器均支持删除,并需确认受影响应用及永久数据删除。标准 Lepton 目录中的容器会整体删除;手动登记的外部位置只删除 baked 目录,保留父目录中的其他文件。卸载应用、包括选择删除应用数据,都不会删除容器。
|
||||
|
||||
卸载默认保留存档与设置,开启“删除应用数据”才彻底删除。保留数据的应用显示在“已卸载但保留数据”中,可以重新安装或清除保留数据。
|
||||
|
||||
Android 清除已卸载包的保留状态时可能需要原签名 APK。Framely 管理卸载时会保存原 APK。如果其他工具已卸载应用且找不到原 APK,需要先提供原 APK 重新安装,再清除保留数据;不会绕过签名或删除任意目录假装成功。
|
||||
|
||||
“数据清理”列出保留数据、旧 APK、恢复快照及过期临时文件。存档与备份默认不选中;未知归属数据只展示,不自动删除。删除前重新核验,已重新安装的应用不会被当作残留清理。批量结果显示实际释放的文件字节及各项失败原因。由于压缩、reflink 和共享存储,文件字节数与物理磁盘占用可能不同。
|
||||
|
||||
切换面板不会中止已提交的任务;重新打开 APK 管理会继续显示进度。恢复流程使用本地事务记录,意外中断后会在下次修改操作前恢复到完整状态。
|
||||
|
||||
## 日志与面板外恢复
|
||||
|
||||
在应用的“日志 → 导出日志”保存操作记录。Android 详细错误保留在日志中,分享前应检查内容。
|
||||
|
||||
以 Steam 会话用户执行:
|
||||
|
||||
```bash
|
||||
/home/.framely/current/bin/framely apk list
|
||||
/home/.framely/current/bin/framely apk inspect --file /path/to/app.apk
|
||||
/home/.framely/current/bin/framely apk install '{"ticket":"REVIEW_TICKET","approve":true}'
|
||||
/home/.framely/current/bin/framely apk launch '{"app":"CONTEXT/PACKAGE"}'
|
||||
/home/.framely/current/bin/framely apk logs '{"app":"CONTEXT/PACKAGE"}'
|
||||
/home/.framely/current/bin/framely apk cleanup-list
|
||||
```
|
||||
|
||||
校验票据有效期为 15 分钟。APK、管理记录和恢复材料位于 `~/.local/share/framely/apk-manager`,托管 Lepton 数据位于 `~/.local/share/lepton/contexts`。卸载 Framely 保留这些用户目录;卸载前应备份。APK 命令仍需兼容的 Framely 二进制,Lepton 本身由 Steam 独立安装。
|
||||
|
||||
手动启动 Framely 托管容器时,必须保留数据:
|
||||
|
||||
```bash
|
||||
LEPTON_NO_CLEANUP=true ~/.local/share/Steam/steamapps/common/Lepton/lepton start CONTEXT
|
||||
```
|
||||
|
||||
不要将 Lepton 的全局清理命令用作单应用卸载。
|
||||
|
||||
## 格式范围
|
||||
|
||||
首版支持单个 APK。不包含分包、XAPK/APKM、OBB 导入、Google Play 安装及自动创建 Steam 快捷方式;不支持的包格式会明确拒绝。
|
||||
@@ -60,3 +60,12 @@ 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 页面滚动
|
||||
|
||||
激光指向页面后,拨动任意手柄的摇杆即可滚动,无需先点击或聚焦页面。滚动作用于激光下的元素,也支持插件页面的内层滚动区域。键盘打开时暂停页面摇杆滚动;启动台仍使用原有的摇杆分类与翻页操作。
|
||||
|
||||
按住主扳机并上下移动激光,可拖动可滚动的页面内容。小幅抖动仍视为普通点击;纵向拖动开始后,松开不会触发原先按下的按钮。文本输入、滑块及明确声明的自定义拖拽区域保留自身交互。启动台保留图标排列和横向拖动翻页操作。
|
||||
|
||||
|
||||
启动台窗口内部的空白点击只收起图标菜单。点击窗口外或按 B 关闭;调整模式下 B 先退出调整。Steam 的移动、推远和拉近控件会保持窗口打开,并暂停摇杆导航,摇杆回中后恢复。调整模式拖拽图标时可以用摇杆左右翻页,上下不会切换分类;也可将指针移过分页线并停留 650 毫秒跨页。
|
||||
+94
-27
@@ -1,3 +1,4 @@
|
||||
#include "include/capi/cef_dialog_handler_capi.h"
|
||||
#include "include/capi/cef_app_capi.h"
|
||||
#include "include/capi/cef_image_capi.h"
|
||||
#include "include/capi/cef_parser_capi.h"
|
||||
@@ -9,7 +10,11 @@
|
||||
#include "menu_geometry.h"
|
||||
#include "menu_input.h"
|
||||
#include "launcher_input.h"
|
||||
#include "launcher_fade.h"
|
||||
#include "launcher_pointer.h"
|
||||
#include "page_scroll.h"
|
||||
#include "page_scroll_injection.h"
|
||||
#include "include/capi/cef_load_handler_capi.h"
|
||||
#include <filesystem>
|
||||
#include "overlay_placement.h"
|
||||
#include "render_resolution.h"
|
||||
@@ -49,12 +54,18 @@ static GLXContext glcontext=nullptr;
|
||||
static GLXPbuffer glsurface=0;
|
||||
static std::atomic<unsigned> notification_badge_count{0};
|
||||
static bool launcher_open=false;
|
||||
static LauncherFade launcher_fade;
|
||||
static std::chrono::steady_clock::time_point launcher_close_at{};
|
||||
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 +94,8 @@ 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{};
|
||||
cef_file_dialog_callback_t* file_dialog=nullptr;std::string file_dialog_id;bool file_multiple=false,file_directory=false;
|
||||
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;
|
||||
};
|
||||
@@ -90,9 +103,13 @@ static std::map<std::string,std::unique_ptr<View>> views;
|
||||
static View* find_browser(cef_browser_t* b){int id=b->get_identifier(b);for(auto& [key,v]:views)if(v->browser_id==id)return v.get();return nullptr;}
|
||||
static Handler<cef_client_t> client;
|
||||
static Handler<cef_render_handler_t> render_handler;
|
||||
static Handler<cef_dialog_handler_t> dialog_handler;
|
||||
static unsigned long long file_dialog_sequence=0;
|
||||
static void cancel_file_dialog(View& v){if(!v.file_dialog)return;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();callback->cancel(callback);callback->base.release(&callback->base);}
|
||||
static Handler<cef_life_span_handler_t> life_handler;
|
||||
static Handler<cef_display_handler_t> display_handler;
|
||||
static Handler<cef_request_handler_t> request_handler;
|
||||
static Handler<cef_load_handler_t> load_handler;
|
||||
static void str(cef_string_t& o,const std::string& s){cef_string_utf8_to_utf16(s.data(),s.size(),&o);}
|
||||
static std::string text(const cef_string_t* s){cef_string_utf8_t o{};cef_string_utf16_to_utf8(s->str,s->length,&o);std::string r(o.str,o.length);cef_string_utf8_clear(&o);return r;}
|
||||
static void execute(cef_frame_t* f,const std::string& code){if(!f||!f->is_valid(f))return;cef_string_t script{},url{};str(script,code);f->execute_java_script(f,&script,&url,1);cef_string_utf16_clear(&script);}
|
||||
@@ -164,7 +181,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 +193,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;
|
||||
@@ -187,7 +205,7 @@ static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_o
|
||||
}else {keyboard_done=keyboard_done||e.eventType==vr::VREvent_KeyboardDone;keyboard_close_pending=true;}
|
||||
return true;
|
||||
}
|
||||
static void close_view(View& v){if(v.closing)return;if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
|
||||
static void close_view(View& v){if(v.closing)return;cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
|
||||
static void anchor_to_head(View& v,float width,float x=0,float y=0,float z=-1.35f){vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);if(!poses[0].bPoseIsValid)return;auto m=poses[0].mDeviceToAbsoluteTracking;for(int r=0;r<3;r++)m.m[r][3]+=m.m[r][0]*x+m.m[r][1]*y+m.m[r][2]*z;overlays->SetOverlayWidthInMeters(v.overlay,width);overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&m);}
|
||||
static void anchor_notification(View& v){
|
||||
// Let the compositor follow the HMD's render pose; a captured standing-space
|
||||
@@ -203,7 +221,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 +237,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 +256,31 @@ 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(views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay))
|
||||
overlays->ShowDashboard("valve.steam.gamepadui.main");
|
||||
if(keyboard_view=="launcher")close_keyboard();
|
||||
const bool was_open=launcher_open;launcher_open=false;launcher_selected=false;launcher_dock.clear();launcher_stick={};
|
||||
if(was_open&&views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay)){
|
||||
auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);
|
||||
execute(frame,"window.dispatchEvent(new Event('framely.launcher.closing'))");frame->base.release(&frame->base);
|
||||
auto now=std::chrono::steady_clock::now();launcher_fade.close(now);launcher_close_at=now+std::chrono::milliseconds(launcher_fade.reduced?0:480);
|
||||
}
|
||||
}
|
||||
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,10 +293,14 @@ 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_close_at={};launcher_dock.clear();launcher_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={};
|
||||
// Steam must see a visible dashboard surface before activating it. Zero
|
||||
// alpha can prevent activation, so never wait for selection to start a fade.
|
||||
overlays->SetOverlayAlpha(view.overlay,1.f);
|
||||
overlays->ShowDashboard("launcher");
|
||||
launcher_fade.open(std::chrono::steady_clock::now());
|
||||
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 +315,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 +370,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){const bool blocked=keyboard_open||launcher_dock_interaction();int direction=launcher_stick.update(blocked?0:stick_x,blocked?0:stick_y,!blocked&&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=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="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=="apk"||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"||page=="apk"){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_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;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()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="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];}
|
||||
@@ -354,14 +409,22 @@ int main(int argc,char** argv){
|
||||
render_handler.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t* b,cef_screen_info_t* info){auto* v=find_browser(b);return render_screen_info(info,v?v->width:600,v?v->height:840,v?v->render_scale:1);};
|
||||
render_handler.api.on_paint=[](cef_render_handler_t*,cef_browser_t* b,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){auto* v=find_browser(b);if(!v||v->closing||type!=PET_VIEW||w!=v->width*v->render_scale||h!=v->height*v->render_scale)return;merge_paint(v->pixels,data,w,h,dirty_count,dirty,true);for(auto& damage:v->texture_damage)damage.add(w,h,dirty_count,dirty);v->dirty=true;};
|
||||
life_handler.api.on_before_popup=[](cef_life_span_handler_t*,cef_browser_t*,cef_frame_t*,int,const cef_string_t*,const cef_string_t*,cef_window_open_disposition_t,int,const cef_popup_features_t*,cef_window_info_t*,cef_client_t**,cef_browser_settings_t*,cef_dictionary_value_t**,int*){return 1;};
|
||||
life_handler.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t* b){auto* v=find_browser(b);if(v){v->closed=true;v->host->base.release(&v->host->base);v->host=nullptr;v->browser->base.release(&v->browser->base);v->browser=nullptr;}};
|
||||
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
|
||||
life_handler.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t* b){auto* v=find_browser(b);if(v){cancel_file_dialog(*v);v->closed=true;v->host->base.release(&v->host->base);v->host=nullptr;v->browser->base.release(&v->browser->base);v->browser=nullptr;}};
|
||||
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);if(v&&v->key=="launcher"){const auto value=text(message);if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=1")launcher_fade.reduced=true;else if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=0")launcher_fade.reduced=false;}std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
|
||||
load_handler.api.on_load_end=[](cef_load_handler_t*,cef_browser_t*,cef_frame_t* frame,int){inject_page_scroll(frame);};
|
||||
client.api.get_load_handler=[](cef_client_t*){return load_handler.acquire();};
|
||||
request_handler.api.on_before_browse=[](cef_request_handler_t*,cef_browser_t* b,cef_frame_t* f,cef_request_t* r,int,int){auto* uri=r->get_url(r);auto s=text(uri);cef_string_userfree_utf16_free(uri);auto* v=find_browser(b);return int(!allow_plugin_navigation(s,origin,!f||f->is_main(f),v&&!v->plugin.empty()));};
|
||||
request_handler.api.on_render_process_terminated=[](cef_request_handler_t*,cef_browser_t* b,cef_termination_status_t,int,const cef_string_t*){auto* v=find_browser(b);if(v){std::cerr<<"Renderer crashed: "<<v->key<<"\n";close_view(*v);if(v->key=="menu")running=0;if(v->key=="launcher")launcher_open=false;}};
|
||||
client.api.get_render_handler=[](cef_client_t*){return render_handler.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life_handler.acquire();};client.api.get_display_handler=[](cef_client_t*){return display_handler.acquire();};client.api.get_request_handler=[](cef_client_t*){return request_handler.acquire();};
|
||||
dialog_handler.api.on_file_dialog=[](cef_dialog_handler_t*,cef_browser_t* b,cef_file_dialog_mode_t mode,const cef_string_t*,const cef_string_t* default_path,cef_string_list_t filters,cef_string_list_t extensions,cef_string_list_t,cef_file_dialog_callback_t* callback){
|
||||
auto* v=find_browser(b);if(!v||v->key!="framely.manager"||mode==FILE_DIALOG_SAVE)return 0;
|
||||
cancel_file_dialog(*v);callback->base.add_ref(&callback->base);v->file_dialog=callback;v->file_multiple=mode==FILE_DIALOG_OPEN_MULTIPLE;v->file_directory=mode==FILE_DIALOG_OPEN_FOLDER;v->file_dialog_id=std::to_string(++file_dialog_sequence)+"-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
|
||||
json accept=json::array();for(auto list:{filters,extensions}){if(!list)continue;for(size_t i=0;i<cef_string_list_size(list);++i){cef_string_t value{};cef_string_list_value(list,i,&value);auto filter=text(&value);cef_string_utf16_clear(&value);if(!filter.empty())accept.push_back(filter);}}
|
||||
auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.fileDialog',{detail:"+json({{"id",v->file_dialog_id},{"multiple",v->file_multiple},{"directory",v->file_directory},{"accept",accept},{"defaultPath",default_path?text(default_path):std::string{}}}).dump()+"}))");frame->base.release(&frame->base);return 1;
|
||||
};
|
||||
client.api.get_dialog_handler=[](cef_client_t*){return dialog_handler.acquire();};client.api.get_render_handler=[](cef_client_t*){return render_handler.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life_handler.acquire();};client.api.get_display_handler=[](cef_client_t*){return display_handler.acquire();};client.api.get_request_handler=[](cef_client_t*){return request_handler.acquire();};
|
||||
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);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 +442,16 @@ 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;
|
||||
if(launcher_close_at.time_since_epoch().count()&&now>=launcher_close_at){launcher_close_at={};if(!launcher_open&&views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay))overlays->ShowDashboard("valve.steam.gamepadui.main");}
|
||||
// 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||launcher_close_at.time_since_epoch().count())overlays->SetOverlayAlpha(views.at("launcher")->overlay,launcher_open?std::max(.02f,launcher_fade.value(now)):launcher_fade.value(now));
|
||||
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 +466,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);}}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
// Compositor alpha fades even when a cached CEF texture has not repainted yet.
|
||||
struct LauncherFade {
|
||||
bool reduced=false;float from=1,to=1;int duration=0;
|
||||
std::chrono::steady_clock::time_point started{};
|
||||
float value(std::chrono::steady_clock::time_point now)const{
|
||||
if(reduced||!duration)return to;
|
||||
float t=std::clamp(std::chrono::duration<float,std::milli>(now-started).count()/duration,0.f,1.f);
|
||||
float eased=t*t*(3-2*t);return from+(to-from)*eased;
|
||||
}
|
||||
void open(std::chrono::steady_clock::time_point now){from=0;to=1;duration=580;started=now;}
|
||||
void close(std::chrono::steady_clock::time_point now){from=value(now);to=0;duration=440;started=now;}
|
||||
};
|
||||
@@ -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;}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
#include "include/capi/cef_frame_capi.h"
|
||||
#include "native_scroll_script.h"
|
||||
// Run in every newly loaded frame; no network request, SDK or bridge required.
|
||||
inline void inject_page_scroll(cef_frame_t* frame){
|
||||
if(!frame||!frame->is_valid(frame))return;
|
||||
cef_string_t script{},source{};
|
||||
cef_string_utf8_to_utf16(native_scroll_script,sizeof(native_scroll_script)-1,&script);
|
||||
frame->execute_java_script(frame,&script,&source,1);
|
||||
cef_string_utf16_clear(&script);
|
||||
}
|
||||
@@ -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,4 @@
|
||||
// Embedded into the native host, including localWeb and nested plugin frames.
|
||||
// Shares the document guard with the SDK bootstrap to avoid duplicate gestures.
|
||||
import {installScrollGestures} from './scroll-gestures';
|
||||
installScrollGestures();
|
||||
@@ -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=`
|
||||
|
||||
+2500
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,530 @@
|
||||
//! Bounded Android binary XML/resource reader. No APK code is executed.
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, fs::File, io::Read, path::Path};
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Metadata {
|
||||
pub package: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub version_code: u64,
|
||||
pub min_sdk: u32,
|
||||
pub activities: Vec<String>,
|
||||
pub declared_activities: Vec<String>,
|
||||
pub icon: Option<String>,
|
||||
pub abis: Vec<String>,
|
||||
pub vr: bool,
|
||||
}
|
||||
fn u16at(b: &[u8], p: usize) -> Result<u16> {
|
||||
Ok(u16::from_le_bytes(
|
||||
b.get(p..p + 2)
|
||||
.context("Truncated APK metadata")?
|
||||
.try_into()?,
|
||||
))
|
||||
}
|
||||
fn u32at(b: &[u8], p: usize) -> Result<u32> {
|
||||
Ok(u32::from_le_bytes(
|
||||
b.get(p..p + 4)
|
||||
.context("Truncated APK metadata")?
|
||||
.try_into()?,
|
||||
))
|
||||
}
|
||||
fn string_len(b: &[u8], p: &mut usize, utf8: bool) -> Result<usize> {
|
||||
if utf8 {
|
||||
let a = *b.get(*p).context("Invalid string length")?;
|
||||
*p += 1;
|
||||
if a & 128 != 0 {
|
||||
let v =
|
||||
((a as usize & 127) << 8) | *b.get(*p).context("Invalid string length")? as usize;
|
||||
*p += 1;
|
||||
Ok(v)
|
||||
} else {
|
||||
Ok(a as usize)
|
||||
}
|
||||
} else {
|
||||
let a = u16at(b, *p)?;
|
||||
*p += 2;
|
||||
if a & 0x8000 != 0 {
|
||||
let v = ((a as usize & 0x7fff) << 16) | u16at(b, *p)? as usize;
|
||||
*p += 2;
|
||||
Ok(v)
|
||||
} else {
|
||||
Ok(a as usize)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn strings(b: &[u8]) -> Result<Vec<String>> {
|
||||
let count = u32at(b, 8)? as usize;
|
||||
ensure!(count <= 262144, "APK string pool is too large");
|
||||
let utf8 = u32at(b, 16)? & 256 != 0;
|
||||
let start = u32at(b, 20)? as usize;
|
||||
let header = u16at(b, 2)? as usize;
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let mut p = start
|
||||
.checked_add(u32at(b, header + i * 4)? as usize)
|
||||
.context("Invalid string offset")?;
|
||||
let n = string_len(b, &mut p, utf8)?;
|
||||
if utf8 {
|
||||
let bytes = string_len(b, &mut p, true)?;
|
||||
Ok(
|
||||
std::str::from_utf8(b.get(p..p + bytes).context("Invalid APK string")?)?
|
||||
.to_owned(),
|
||||
)
|
||||
} else {
|
||||
let bytes = b.get(p..p + n * 2).context("Invalid UTF16 string")?;
|
||||
Ok(String::from_utf16(
|
||||
&bytes
|
||||
.chunks_exact(2)
|
||||
.map(|s| u16::from_le_bytes([s[0], s[1]]))
|
||||
.collect::<Vec<_>>(),
|
||||
)?)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn chunks(b: &[u8], mut p: usize) -> Result<Vec<&[u8]>> {
|
||||
let mut out = Vec::new();
|
||||
while p < b.len() {
|
||||
let n = u32at(b, p + 4)? as usize;
|
||||
ensure!(n >= 8 && n <= b.len() - p, "Invalid APK chunk size");
|
||||
out.push(&b[p..p + n]);
|
||||
p += n;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[derive(Clone)]
|
||||
enum Val {
|
||||
Text(String),
|
||||
Ref(u32),
|
||||
Int(u32),
|
||||
}
|
||||
fn val(b: &[u8], p: usize, s: &[String]) -> Result<Val> {
|
||||
let typ = *b.get(p + 3).context("Invalid value")?;
|
||||
let v = u32at(b, p + 4)?;
|
||||
Ok(match typ {
|
||||
3 => Val::Text(
|
||||
s.get(v as usize)
|
||||
.context("Invalid string reference")?
|
||||
.clone(),
|
||||
),
|
||||
1 => Val::Ref(v),
|
||||
_ => Val::Int(v),
|
||||
})
|
||||
}
|
||||
fn resources(b: &[u8]) -> Result<BTreeMap<u32, Val>> {
|
||||
let mut map = BTreeMap::new();
|
||||
let mut pool = Vec::new();
|
||||
for c in chunks(b, u16at(b, 2)? as usize)? {
|
||||
match u16at(c, 0)? {
|
||||
1 => pool = strings(c)?,
|
||||
0x200 => {
|
||||
let pkg = u32at(c, 8)?;
|
||||
for t in chunks(c, u16at(c, 2)? as usize)? {
|
||||
if u16at(t, 0)? != 0x201 {
|
||||
continue;
|
||||
}
|
||||
let id = *t.get(8).context("Invalid resource type")? as u32;
|
||||
let flags = t[9];
|
||||
if flags != 0 {
|
||||
continue;
|
||||
}
|
||||
let count = u32at(t, 12)? as usize;
|
||||
let start = u32at(t, 16)? as usize;
|
||||
let h = u16at(t, 2)? as usize;
|
||||
ensure!(count <= 65536, "Too many APK resources");
|
||||
for i in 0..count {
|
||||
let offset = u32at(t, h + i * 4)?;
|
||||
if offset == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
let e = start + offset as usize;
|
||||
let size = u16at(t, e)? as usize;
|
||||
if u16at(t, e + 2)? & 1 != 0 {
|
||||
continue;
|
||||
}
|
||||
let key = (pkg << 24) | (id << 16) | i as u32;
|
||||
let v = val(t, e + size, &pool)?;
|
||||
map.entry(key).or_insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
fn text(v: Option<&Val>, r: &BTreeMap<u32, Val>) -> String {
|
||||
let mut v = v;
|
||||
for _ in 0..16 {
|
||||
match v {
|
||||
Some(Val::Text(s)) => return s.clone(),
|
||||
Some(Val::Int(n)) => return n.to_string(),
|
||||
Some(Val::Ref(id)) => v = r.get(id),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
fn numeric(v: Option<&Val>) -> u32 {
|
||||
match v {
|
||||
Some(Val::Int(n)) => *n,
|
||||
Some(Val::Text(s)) => s.parse().unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
pub fn valid_package(s: &str) -> bool {
|
||||
s.len() <= 255
|
||||
&& s.contains('.')
|
||||
&& s.split('.').all(|p| {
|
||||
!p.is_empty()
|
||||
&& p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
&& !p.as_bytes()[0].is_ascii_digit()
|
||||
})
|
||||
}
|
||||
fn activity(p: &str, n: &str) -> String {
|
||||
if n.starts_with('.') {
|
||||
format!("{p}{n}")
|
||||
} else if !n.contains('.') {
|
||||
format!("{p}.{n}")
|
||||
} else {
|
||||
n.into()
|
||||
}
|
||||
}
|
||||
fn xml(b: &[u8], r: &BTreeMap<u32, Val>) -> Result<Metadata> {
|
||||
ensure!(u16at(b, 0)? == 3, "APK manifest must be Android binary XML");
|
||||
let mut m = Metadata::default();
|
||||
let mut pool = Vec::new();
|
||||
let mut stack: Vec<String> = Vec::new();
|
||||
let mut current = None::<(String, bool, bool, bool, bool)>;
|
||||
let mut label = None;
|
||||
let mut icon = None;
|
||||
let mut app_enabled = true;
|
||||
for c in chunks(b, u16at(b, 2)? as usize)? {
|
||||
match u16at(c, 0)? {
|
||||
1 => pool = strings(c)?,
|
||||
0x102 => {
|
||||
let tag = pool
|
||||
.get(u32at(c, 20)? as usize)
|
||||
.context("Invalid XML element")?
|
||||
.clone();
|
||||
let start = 16 + u16at(c, 24)? as usize;
|
||||
let stride = u16at(c, 26)? as usize;
|
||||
let count = u16at(c, 28)? as usize;
|
||||
ensure!(stride >= 20 && count <= 4096, "Invalid XML attributes");
|
||||
let mut attrs = BTreeMap::new();
|
||||
for i in 0..count {
|
||||
let p = start + i * stride;
|
||||
let name = pool
|
||||
.get(u32at(c, p + 4)? as usize)
|
||||
.context("Invalid XML attribute")?
|
||||
.clone();
|
||||
attrs.insert(name, val(c, p + 12, &pool)?);
|
||||
}
|
||||
match tag.as_str() {
|
||||
"manifest" => {
|
||||
m.package = text(attrs.get("package"), r);
|
||||
m.version = text(attrs.get("versionName"), r);
|
||||
m.version_code = numeric(attrs.get("versionCode")) as u64
|
||||
| ((numeric(attrs.get("versionCodeMajor")) as u64) << 32);
|
||||
}
|
||||
"uses-sdk" => m.min_sdk = numeric(attrs.get("minSdkVersion")),
|
||||
"application" => {
|
||||
label = attrs.get("label").cloned();
|
||||
icon = attrs.get("icon").cloned();
|
||||
app_enabled = attrs.get("enabled").is_none_or(|v| numeric(Some(v)) != 0);
|
||||
}
|
||||
"activity" | "activity-alias" => {
|
||||
let n = activity(&m.package, &text(attrs.get("name"), r));
|
||||
let enabled = app_enabled
|
||||
&& attrs.get("enabled").is_none_or(|v| numeric(Some(v)) != 0)
|
||||
&& attrs.get("exported").is_none_or(|v| numeric(Some(v)) != 0);
|
||||
m.declared_activities.push(n.clone());
|
||||
current = Some((n, enabled, false, false, false));
|
||||
}
|
||||
"intent-filter" => {
|
||||
if let Some(a) = current.as_mut() {
|
||||
a.2 = false;
|
||||
a.3 = false;
|
||||
}
|
||||
}
|
||||
"action" => {
|
||||
if let Some(a) = current.as_mut() {
|
||||
if text(attrs.get("name"), r) == "android.intent.action.MAIN" {
|
||||
a.2 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
"category" => {
|
||||
let name = text(attrs.get("name"), r);
|
||||
if let Some(a) = current.as_mut() {
|
||||
if [
|
||||
"android.intent.category.LAUNCHER",
|
||||
"android.intent.category.LEANBACK_LAUNCHER",
|
||||
"com.oculus.intent.category.VR",
|
||||
]
|
||||
.contains(&name.as_str())
|
||||
{
|
||||
a.3 = true;
|
||||
}
|
||||
}
|
||||
if name == "com.oculus.intent.category.VR" {
|
||||
m.vr = true;
|
||||
}
|
||||
}
|
||||
"meta-data" => {
|
||||
let name = text(attrs.get("name"), r);
|
||||
if name == "com.samsung.android.vr.application.mode"
|
||||
|| name == "com.oculus.supportedDevices"
|
||||
{
|
||||
m.vr = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
stack.push(tag);
|
||||
}
|
||||
0x103 => {
|
||||
let tag = stack.pop().context("Unbalanced APK XML")?;
|
||||
if tag == "intent-filter" {
|
||||
if let Some(a) = current.as_mut() {
|
||||
a.4 |= a.2 && a.3;
|
||||
}
|
||||
}
|
||||
if tag == "activity" || tag == "activity-alias" {
|
||||
if let Some((n, enabled, main, launcher, matched)) = current.take() {
|
||||
if enabled && (matched || main && launcher) {
|
||||
m.activities.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ensure!(valid_package(&m.package), "Invalid APK package name");
|
||||
m.name = text(label.as_ref(), r);
|
||||
if m.name.is_empty() {
|
||||
m.name = m.package.clone();
|
||||
}
|
||||
if m.version.is_empty() {
|
||||
m.version = m.version_code.to_string();
|
||||
}
|
||||
let path = text(icon.as_ref(), r);
|
||||
if path.starts_with("res/") {
|
||||
m.icon = Some(path);
|
||||
}
|
||||
m.activities.sort();
|
||||
m.activities.dedup();
|
||||
Ok(m)
|
||||
}
|
||||
fn entry(z: &mut zip::ZipArchive<File>, name: &str, max: u64) -> Result<Vec<u8>> {
|
||||
let mut f = z.by_name(name)?;
|
||||
ensure!(f.size() <= max, "APK metadata exceeds size limit");
|
||||
let mut b = Vec::new();
|
||||
f.by_ref().take(max + 1).read_to_end(&mut b)?;
|
||||
ensure!(b.len() as u64 <= max, "APK metadata exceeds size limit");
|
||||
Ok(b)
|
||||
}
|
||||
pub fn read(path: &Path) -> Result<Metadata> {
|
||||
let mut z = zip::ZipArchive::new(File::open(path)?).context("Invalid APK archive")?;
|
||||
ensure!(z.len() <= 65536, "Too many APK files");
|
||||
let r = match entry(&mut z, "resources.arsc", 64 * 1024 * 1024) {
|
||||
Ok(b) => resources(&b)?,
|
||||
Err(e) => {
|
||||
if z.by_name("resources.arsc").is_ok() {
|
||||
return Err(e);
|
||||
}
|
||||
BTreeMap::new()
|
||||
}
|
||||
};
|
||||
let b = entry(&mut z, "AndroidManifest.xml", 8 * 1024 * 1024)?;
|
||||
let mut m = xml(&b, &r)?;
|
||||
if let Some(path) = m.icon.take() {
|
||||
let mime = if path.ends_with(".png") {
|
||||
Some("image/png")
|
||||
} else if path.ends_with(".webp") {
|
||||
Some("image/webp")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(mime) = mime {
|
||||
if let Ok(b) = entry(&mut z, &path, 2 * 1024 * 1024) {
|
||||
m.icon = Some(format!("data:{mime};base64,{}", STANDARD.encode(b)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..z.len() {
|
||||
let f = z.by_index(i)?;
|
||||
if let Some(p) = f.name().strip_prefix("lib/") {
|
||||
if let Some((abi, _)) = p.split_once('/') {
|
||||
if !m.abis.contains(&abi.to_string()) {
|
||||
m.abis.push(abi.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !m.abis.is_empty()
|
||||
&& !m
|
||||
.abis
|
||||
.iter()
|
||||
.any(|a| a == "arm64-v8a" || a == "armeabi-v7a")
|
||||
{
|
||||
bail!("APK has no compatible ARM libraries");
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn package_and_activity_validation() {
|
||||
assert!(valid_package("com.example.app"));
|
||||
for p in ["../x", "com..app", "com.1app", "app", "com.app;rm"] {
|
||||
assert!(!valid_package(p));
|
||||
}
|
||||
assert_eq!(activity("com.a", ".Main"), "com.a.Main");
|
||||
}
|
||||
#[test]
|
||||
fn malformed_chunks_and_resource_cycles_fail_safely() {
|
||||
assert!(chunks(&[0; 8], 0).is_err());
|
||||
let r = BTreeMap::from([(1, Val::Ref(2)), (2, Val::Ref(1))]);
|
||||
assert_eq!(text(Some(&Val::Ref(1)), &r), "");
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fixture(path: &Path, launch: bool, version: u32) {
|
||||
use std::io::Write;
|
||||
fn chunk(kind: u16, header: u16, body: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
b.extend(kind.to_le_bytes());
|
||||
b.extend(header.to_le_bytes());
|
||||
b.extend(((body.len() + 8) as u32).to_le_bytes());
|
||||
b.extend(body);
|
||||
b
|
||||
}
|
||||
let names = [
|
||||
"manifest",
|
||||
"package",
|
||||
"com.example.app",
|
||||
"versionCode",
|
||||
"versionName",
|
||||
"1.0",
|
||||
"application",
|
||||
"label",
|
||||
"Example",
|
||||
"activity",
|
||||
"name",
|
||||
".Main",
|
||||
"intent-filter",
|
||||
"action",
|
||||
"android.intent.action.MAIN",
|
||||
"category",
|
||||
"android.intent.category.LAUNCHER",
|
||||
"enabled",
|
||||
];
|
||||
let mut data = Vec::new();
|
||||
let mut offsets = Vec::new();
|
||||
for s in names {
|
||||
offsets.push(data.len() as u32);
|
||||
data.push(s.len() as u8);
|
||||
data.push(s.len() as u8);
|
||||
data.extend(s.as_bytes());
|
||||
data.push(0);
|
||||
}
|
||||
while data.len() % 4 != 0 {
|
||||
data.push(0)
|
||||
}
|
||||
let mut pool = Vec::new();
|
||||
pool.extend((names.len() as u32).to_le_bytes());
|
||||
pool.extend(0u32.to_le_bytes());
|
||||
pool.extend(256u32.to_le_bytes());
|
||||
pool.extend((28 + names.len() as u32 * 4).to_le_bytes());
|
||||
pool.extend(0u32.to_le_bytes());
|
||||
for o in offsets {
|
||||
pool.extend(o.to_le_bytes())
|
||||
}
|
||||
pool.extend(data);
|
||||
let mut body = chunk(1, 28, &pool);
|
||||
fn start(tag: u32, attrs: &[(u32, u8, u32)]) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
b.extend(1u32.to_le_bytes());
|
||||
b.extend(u32::MAX.to_le_bytes());
|
||||
b.extend(u32::MAX.to_le_bytes());
|
||||
b.extend(tag.to_le_bytes());
|
||||
b.extend(20u16.to_le_bytes());
|
||||
b.extend(20u16.to_le_bytes());
|
||||
b.extend((attrs.len() as u16).to_le_bytes());
|
||||
b.extend([0; 6]);
|
||||
for (name, typ, value) in attrs {
|
||||
b.extend(u32::MAX.to_le_bytes());
|
||||
b.extend(name.to_le_bytes());
|
||||
b.extend(u32::MAX.to_le_bytes());
|
||||
b.extend(8u16.to_le_bytes());
|
||||
b.push(0);
|
||||
b.push(*typ);
|
||||
b.extend(value.to_le_bytes());
|
||||
}
|
||||
chunk(0x102, 16, &b)
|
||||
}
|
||||
fn end(tag: u32) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
b.extend(1u32.to_le_bytes());
|
||||
b.extend(u32::MAX.to_le_bytes());
|
||||
b.extend(u32::MAX.to_le_bytes());
|
||||
b.extend(tag.to_le_bytes());
|
||||
chunk(0x103, 16, &b)
|
||||
}
|
||||
body.extend(start(0, &[(1, 3, 2), (3, 0x10, version), (4, 3, 5)]));
|
||||
body.extend(start(6, &[(7, 3, 8)]));
|
||||
body.extend(start(9, &[(10, 3, 11)]));
|
||||
if launch {
|
||||
body.extend(start(12, &[]));
|
||||
body.extend(start(13, &[(10, 3, 14)]));
|
||||
body.extend(end(13));
|
||||
body.extend(start(15, &[(10, 3, 16)]));
|
||||
body.extend(end(15));
|
||||
body.extend(end(12));
|
||||
}
|
||||
body.extend(end(9));
|
||||
body.extend(end(6));
|
||||
body.extend(end(0));
|
||||
let xml = chunk(3, 8, &body);
|
||||
let mut z = zip::ZipWriter::new(File::create(path).unwrap());
|
||||
z.start_file(
|
||||
"AndroidManifest.xml",
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
z.write_all(&xml).unwrap();
|
||||
z.finish().unwrap();
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod manifest_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn launchers_labels_versions_and_services() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("a.apk");
|
||||
fixture(&p, true, 42);
|
||||
let m = read(&p).unwrap();
|
||||
assert_eq!(m.package, "com.example.app");
|
||||
assert_eq!(m.name, "Example");
|
||||
assert_eq!(m.version_code, 42);
|
||||
assert_eq!(m.activities, vec!["com.example.app.Main"]);
|
||||
fixture(&p, false, 43);
|
||||
assert!(read(&p).unwrap().activities.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn corrupt_zip_and_xml_are_rejected() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("a.apk");
|
||||
std::fs::write(&p, b"not an APK").unwrap();
|
||||
assert!(read(&p).is_err());
|
||||
for n in 0..100 {
|
||||
assert!(xml(&vec![0; n], &BTreeMap::new()).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-158
@@ -1,11 +1,10 @@
|
||||
//! User-session applications: XDG desktop entries and installed Lepton packages.
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -19,12 +18,8 @@ pub struct App {
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
enum Target {
|
||||
ManagedLepton(String),
|
||||
Desktop(PathBuf),
|
||||
Lepton {
|
||||
runner: PathBuf,
|
||||
context: String,
|
||||
package: String,
|
||||
},
|
||||
}
|
||||
fn entries(input: &str) -> BTreeMap<String, String> {
|
||||
let mut active = false;
|
||||
@@ -223,76 +218,22 @@ fn desktop_apps(home: &Path, dirs: &[PathBuf], steam: &BTreeSet<u32>) -> Vec<App
|
||||
}
|
||||
apps
|
||||
}
|
||||
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
let rest = tag.split_once(&format!("{name}=\""))?.1;
|
||||
Some(rest.split_once('"')?.0)
|
||||
}
|
||||
fn safe_component(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& !s.starts_with('-')
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
|
||||
}
|
||||
fn lepton_apps(home: &Path) -> Vec<App> {
|
||||
let runner = [
|
||||
home.join(".local/share/Steam/steamapps/common/Lepton/lepton"),
|
||||
home.join(".steam/steam/steamapps/common/Lepton/lepton"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| p.is_file());
|
||||
let Some(runner) = runner else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut apps = Vec::new();
|
||||
let root = data_home(home).join("lepton/contexts");
|
||||
let Ok(contexts) = fs::read_dir(root) else {
|
||||
return apps;
|
||||
};
|
||||
for context in contexts.flatten() {
|
||||
let name = context.file_name().to_string_lossy().into_owned();
|
||||
if !safe_component(&name) || name.starts_with("steamlaunch-") {
|
||||
continue;
|
||||
}
|
||||
let data = context.path().join("baked/data_overlay");
|
||||
let Ok(xml) = fs::read_to_string(data.join("system/packages.xml")) else {
|
||||
continue;
|
||||
};
|
||||
if xml.len() > 8 * 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
for tag in xml.split('<').filter(|t| t.starts_with("package ")) {
|
||||
let Some(package) = attr(tag, "name").filter(|s| safe_component(s)) else {
|
||||
continue;
|
||||
};
|
||||
let Some(code) = attr(tag, "codePath").and_then(|s| s.strip_prefix("/data/")) else {
|
||||
continue;
|
||||
};
|
||||
// Android system packages are not launcher apps. A removed package's stale
|
||||
// metadata is ignored; validate that its installed APK is still present.
|
||||
let path = data.join(code);
|
||||
if code.split('/').any(|s| s == "..") || !path.join("base.apk").is_file() {
|
||||
continue;
|
||||
}
|
||||
let icon = icon_file(
|
||||
&data
|
||||
.join("system_ce/0/shortcut_service/bitmaps")
|
||||
.join(format!("{package}.png")),
|
||||
);
|
||||
apps.push(App {
|
||||
id: format!("{name}/{package}"),
|
||||
name: package.rsplit('.').next().unwrap_or(package).into(),
|
||||
crate::apk::launcher(home)
|
||||
.into_iter()
|
||||
.filter_map(|v| {
|
||||
let id = v["id"].as_str()?.to_owned();
|
||||
Some(App {
|
||||
id: id.clone(),
|
||||
name: v["name"].as_str()?.into(),
|
||||
kind: "lepton",
|
||||
icon,
|
||||
target: Target::Lepton {
|
||||
runner: runner.clone(),
|
||||
context: name.clone(),
|
||||
package: package.into(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
apps
|
||||
icon: v["icon"].as_str().map(str::to_owned),
|
||||
target: Target::ManagedLepton(id),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn discover(home: &Path) -> Vec<App> {
|
||||
let ids = crate::steam::discover(home)
|
||||
.into_iter()
|
||||
@@ -312,86 +253,18 @@ pub fn launch(home: &Path, id: &str) -> Result<()> {
|
||||
}
|
||||
fn launch_target(target: Target) -> Result<()> {
|
||||
match target {
|
||||
Target::ManagedLepton(id) => crate::apk::launch_app(&crate::steam::home()?, &id)?,
|
||||
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)
|
||||
);
|
||||
}
|
||||
Target::Lepton {
|
||||
runner,
|
||||
context,
|
||||
package,
|
||||
} => {
|
||||
// Use the same session-owned context and Android launcher intent; never
|
||||
// reinstall the APK or accept an arbitrary shell command from the UI.
|
||||
let running = crate::process::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"
|
||||
});
|
||||
let child = if running {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
std::process::Command::new(&runner)
|
||||
.args(["start", &context])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.context("Cannot start Lepton context")?,
|
||||
)
|
||||
};
|
||||
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([
|
||||
"exec",
|
||||
&format!("lepton-{context}"),
|
||||
"monkey",
|
||||
"-p",
|
||||
&package,
|
||||
"-c",
|
||||
"android.intent.category.LAUNCHER",
|
||||
"1",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.output();
|
||||
if output.is_ok_and(|o| o.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"
|
||||
);
|
||||
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:#}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -436,14 +309,14 @@ mod tests {
|
||||
.join(".local/share/lepton/contexts/test/baked/data_overlay");
|
||||
fs::create_dir_all(data.join("system")).unwrap();
|
||||
fs::create_dir_all(data.join("app/example")).unwrap();
|
||||
fs::write(data.join("app/example/base.apk"), "apk").unwrap();
|
||||
crate::apk_metadata::fixture(&data.join("app/example/base.apk"), true, 1);
|
||||
fs::write(data.join("system/packages.xml"),"<packages><package name=\"com.example.app\" codePath=\"/data/app/example\"/><package name=\"com.android.system\" codePath=\"/system/app/system\"/><package name=\"com.removed.app\" codePath=\"/data/app/missing\"/></packages>").unwrap();
|
||||
let apps = lepton_apps(dir.path());
|
||||
assert_eq!(apps.len(), 1);
|
||||
assert_eq!(apps[0].id, "test/com.example.app");
|
||||
}
|
||||
#[test]
|
||||
fn launch_handoffs_use_literal_arguments_and_reuse_live_lepton_context() {
|
||||
fn desktop_launch_handoff_uses_literal_arguments() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tools = dir.path().join("tools");
|
||||
@@ -459,13 +332,6 @@ mod tests {
|
||||
fs::read_to_string(dir.path().join("gio-args")).unwrap(),
|
||||
format!("launch\n{}\n", path.display())
|
||||
);
|
||||
launch_target(Target::Lepton {
|
||||
runner: dir.path().join("must-not-start"),
|
||||
context: "existing".into(),
|
||||
package: "com.example.app".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(fs::read_to_string(dir.path().join("podman-args")).unwrap(),"exec\nlepton-existing\nmonkey\n-p\ncom.example.app\n-c\nandroid.intent.category.LAUNCHER\n1\n");
|
||||
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = None);
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
time::UNIX_EPOCH,
|
||||
};
|
||||
|
||||
pub fn roots(home: &Path) -> Vec<(String, PathBuf)> {
|
||||
let mut roots = vec![
|
||||
("下载".into(), home.join("Downloads")),
|
||||
("主目录".into(), home.into()),
|
||||
("桌面".into(), home.join("Desktop")),
|
||||
("文档".into(), home.join("Documents")),
|
||||
("临时目录".into(), PathBuf::from("/tmp")),
|
||||
];
|
||||
if let Some(user) = home.file_name() {
|
||||
if let Ok(devices) = fs::read_dir(Path::new("/run/media").join(user)) {
|
||||
roots.extend(
|
||||
devices
|
||||
.flatten()
|
||||
.filter(|e| e.path().is_dir())
|
||||
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())),
|
||||
);
|
||||
}
|
||||
}
|
||||
roots
|
||||
.into_iter()
|
||||
.filter_map(|(name, path)| {
|
||||
path.canonicalize()
|
||||
.ok()
|
||||
.filter(|p| p.is_dir())
|
||||
.map(|p| (name, p))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn checked_roots(roots: &[(String, PathBuf)], path: &Path) -> Result<PathBuf> {
|
||||
let path = path
|
||||
.canonicalize()
|
||||
.context("文件或目录不存在,或无权访问")?;
|
||||
ensure!(
|
||||
roots.iter().any(|(_, root)| path.starts_with(root)),
|
||||
"只能选择用户目录、临时目录或已挂载存储中的文件"
|
||||
);
|
||||
Ok(path)
|
||||
}
|
||||
pub fn checked(home: &Path, path: &Path) -> Result<PathBuf> {
|
||||
checked_roots(&roots(home), path)
|
||||
}
|
||||
pub fn list(home: &Path, params: &Value) -> Result<Value> {
|
||||
let roots = roots(home);
|
||||
let default = roots.first().context("没有可访问的目录")?.1.clone();
|
||||
let path = checked_roots(
|
||||
&roots,
|
||||
Path::new(
|
||||
params["path"]
|
||||
.as_str()
|
||||
.filter(|p| !p.is_empty())
|
||||
.unwrap_or(default.to_str().context("Invalid file path")?),
|
||||
),
|
||||
)?;
|
||||
ensure!(path.is_dir(), "请选择目录");
|
||||
let query = params["query"].as_str().unwrap_or("").to_lowercase();
|
||||
ensure!(query.len() <= 1024, "搜索内容过长");
|
||||
let hidden = params["hidden"] == true;
|
||||
let extensions: Vec<String> = params["extensions"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str().map(str::to_lowercase))
|
||||
.collect();
|
||||
ensure!(
|
||||
extensions.len() <= 128
|
||||
&& extensions
|
||||
.iter()
|
||||
.all(|s| s.starts_with('.') && s.len() <= 128),
|
||||
"Invalid file filters"
|
||||
);
|
||||
let offset = params["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let mut entries = vec![];
|
||||
let mut skipped = 0;
|
||||
for (index, entry) in fs::read_dir(&path).context("无法读取目录")?.enumerate() {
|
||||
ensure!(index < 50000, "目录文件过多,请选择更具体的目录");
|
||||
let Ok(entry) = entry else {
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if (!hidden && name.starts_with('.')) || !name.to_lowercase().contains(&query) {
|
||||
continue;
|
||||
}
|
||||
let Ok(target) = checked_roots(&roots, &entry.path()) else {
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
let Ok(meta) = fs::metadata(&target) else {
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if !meta.is_file() && !meta.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if !meta.is_dir()
|
||||
&& (params["directoryOnly"] == true
|
||||
|| (!extensions.is_empty()
|
||||
&& !extensions
|
||||
.iter()
|
||||
.any(|ext| name.to_lowercase().ends_with(ext))))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
entries.push(json!({"name":name,"path":entry.path(),"directory":meta.is_dir(),"size":meta.len(),"modified":meta.modified().ok().and_then(|t|t.duration_since(UNIX_EPOCH).ok()).map(|t|t.as_secs())}));
|
||||
}
|
||||
entries.sort_by(|a, b| {
|
||||
b["directory"]
|
||||
.as_bool()
|
||||
.cmp(&a["directory"].as_bool())
|
||||
.then_with(|| {
|
||||
a["name"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_lowercase()
|
||||
.cmp(&b["name"].as_str().unwrap().to_lowercase())
|
||||
})
|
||||
.then_with(|| a["name"].as_str().cmp(&b["name"].as_str()))
|
||||
});
|
||||
let total = entries.len();
|
||||
let parent = path.parent().and_then(|p| checked_roots(&roots, p).ok());
|
||||
Ok(
|
||||
json!({"path":path,"parent":parent,"roots":roots.iter().map(|(name,path)|json!({"name":name,"path":path})).collect::<Vec<_>>(),"entries":entries.into_iter().skip(offset).take(200).collect::<Vec<_>>(),"total":total,"offset":offset,"skipped":skipped}),
|
||||
)
|
||||
}
|
||||
pub fn selection(home: &Path, params: &Value) -> Result<Vec<PathBuf>> {
|
||||
let paths = params["paths"]
|
||||
.as_array()
|
||||
.context("Missing selected files")?;
|
||||
ensure!(paths.len() <= 64, "选择的文件过多");
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let path = checked(
|
||||
home,
|
||||
Path::new(p.as_str().context("Invalid selected file")?),
|
||||
)?;
|
||||
if params["directory"] == true {
|
||||
ensure!(path.is_dir(), "请选择目录");
|
||||
} else {
|
||||
ensure!(path.is_file(), "请选择文件");
|
||||
fs::File::open(&path).context("无法读取所选文件")?;
|
||||
}
|
||||
Ok(path)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn browsing_filters_sorts_and_keeps_selection_within_roots() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(home.path().join("Downloads")).unwrap();
|
||||
fs::create_dir(home.path().join("Downloads/Z folder")).unwrap();
|
||||
fs::write(home.path().join("Downloads/a.apk"), b"apk").unwrap();
|
||||
fs::write(home.path().join("Downloads/.hidden"), b"hidden").unwrap();
|
||||
std::os::unix::fs::symlink("/etc/passwd", home.path().join("Downloads/escape")).unwrap();
|
||||
let listed = list(home.path(), &json!({})).unwrap();
|
||||
assert_eq!(listed["total"], 2);
|
||||
assert_eq!(listed["entries"][0]["name"], "Z folder");
|
||||
assert_eq!(
|
||||
list(home.path(), &json!({"query":"A.APK"})).unwrap()["total"],
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
list(home.path(), &json!({"hidden":true})).unwrap()["total"],
|
||||
3
|
||||
);
|
||||
assert!(selection(
|
||||
home.path(),
|
||||
&json!({"paths":[home.path().join("Downloads/escape")]})
|
||||
)
|
||||
.is_err());
|
||||
assert!(selection(
|
||||
home.path(),
|
||||
&json!({"paths":[home.path().join("Downloads/a.apk")]})
|
||||
)
|
||||
.is_ok());
|
||||
assert!(selection(
|
||||
home.path(),
|
||||
&json!({"paths":[home.path().join("Downloads")]})
|
||||
)
|
||||
.is_err());
|
||||
assert!(selection(
|
||||
home.path(),
|
||||
&json!({"paths":[home.path().join("Downloads")],"directory":true})
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
+31
-2
@@ -110,7 +110,7 @@ impl Jobs {
|
||||
fn create(&self, kind: &str) -> Result<(String, Cancellation, bool)> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::expire(&mut state);
|
||||
if kind.starts_with("catalog:") {
|
||||
if kind.starts_with("catalog:") || kind == "apk:list" {
|
||||
if let Some((id, job)) = state.jobs.iter().find(|(_, j)| {
|
||||
j.active && j.public["kind"] == kind && !j.cancel.flag.load(Ordering::Acquire)
|
||||
}) {
|
||||
@@ -157,11 +157,29 @@ impl Jobs {
|
||||
.public
|
||||
.clone())
|
||||
}
|
||||
pub fn active_apk(&self) -> Value {
|
||||
let state = self.state.lock().unwrap();
|
||||
state
|
||||
.jobs
|
||||
.iter()
|
||||
.find(|(_, job)| {
|
||||
job.public["kind"] == "apk:operation"
|
||||
&& !matches!(
|
||||
job.public["phase"].as_str(),
|
||||
Some("done" | "failed" | "cancelled")
|
||||
)
|
||||
})
|
||||
.map(|(id, _)| json!({"job":id}))
|
||||
.unwrap_or(json!({"job":null}))
|
||||
}
|
||||
pub fn cancel(&self, id: &str) -> Result<Value> {
|
||||
let cancel = {
|
||||
let state = self.state.lock().unwrap();
|
||||
let j = state.jobs.get(id).context("任务不存在")?;
|
||||
ensure!(j.public["kind"] != "install", "安装已经开始,不能中途取消");
|
||||
ensure!(
|
||||
j.public["kind"] != "install" && j.public["cancellable"] != false,
|
||||
"安装已经开始,不能中途取消"
|
||||
);
|
||||
j.cancel.clone()
|
||||
};
|
||||
// Serialize cancellation with state-changing commits, never with the jobs lock held.
|
||||
@@ -615,6 +633,17 @@ mod tests {
|
||||
panic!("job timeout");
|
||||
}
|
||||
#[test]
|
||||
fn submitted_apk_operations_reject_cancellation_without_waiting_for_worker() {
|
||||
let jobs = Jobs::default();
|
||||
let (id, _, _) = jobs.create("apk:operation").unwrap();
|
||||
assert_eq!(jobs.active_apk()["job"], id);
|
||||
jobs.patch(&id, json!({"cancellable":false,"phase":"installing"}));
|
||||
assert!(jobs.cancel(&id).is_err());
|
||||
assert_eq!(jobs.status(&id).unwrap()["phase"], "installing");
|
||||
jobs.patch(&id, json!({"phase":"done"}));
|
||||
assert_eq!(jobs.active_apk()["job"], Value::Null);
|
||||
}
|
||||
#[test]
|
||||
fn cancellation_and_limits_do_not_allow_unreviewed_install() {
|
||||
let jobs = Jobs::default();
|
||||
let (a, _, _) = jobs.create("inspect").unwrap();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+43
@@ -1,9 +1,13 @@
|
||||
mod apk;
|
||||
mod apk_metadata;
|
||||
mod auth;
|
||||
mod desktop;
|
||||
mod diagnostics;
|
||||
mod http;
|
||||
mod file_browser;
|
||||
mod ipc;
|
||||
mod jobs;
|
||||
mod launcher_search;
|
||||
mod localization;
|
||||
mod model;
|
||||
mod package;
|
||||
@@ -34,6 +38,15 @@ struct Cli {
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Manage sideloaded APKs as the Steam session user (also works without the panel).
|
||||
Apk {
|
||||
operation: String,
|
||||
#[arg(default_value = "{}")]
|
||||
params: String,
|
||||
/// Local APK file for inspection; file paths are never accepted by the web API.
|
||||
#[arg(long)]
|
||||
file: Option<PathBuf>,
|
||||
},
|
||||
/// Export logs even when the manager service is unavailable.
|
||||
ExportLogs {
|
||||
#[arg(long, default_value = "/var/lib/framely")]
|
||||
@@ -139,6 +152,36 @@ enum Cmd {
|
||||
}
|
||||
fn run() -> Result<()> {
|
||||
match Cli::parse().command {
|
||||
Cmd::Apk {
|
||||
operation,
|
||||
params,
|
||||
file,
|
||||
} => {
|
||||
let home = steam::home()?;
|
||||
let p: Value = serde_json::from_str(¶ms)?;
|
||||
let result = match operation.as_str() {
|
||||
"metadata" => json!(apk_metadata::read(
|
||||
&file.context("Use --file for APK inspection")?
|
||||
)?),
|
||||
"list" => apk::list(&home)?,
|
||||
"cleanup-list" => apk::cleanup_list(&home)?,
|
||||
"logs" => apk::logs(&home, p["app"].as_str().context("Missing app")?)?,
|
||||
"inspect" => apk::inspect(
|
||||
&home,
|
||||
&file.context("Use --file for APK inspection")?,
|
||||
&jobs::Cancellation::default(),
|
||||
)?,
|
||||
_ => apk::operate(
|
||||
&home,
|
||||
&operation,
|
||||
&p,
|
||||
jobs::Cancellation::default(),
|
||||
std::sync::Arc::new(|p| eprintln!("{p}")),
|
||||
)?,
|
||||
};
|
||||
println!("{}", serde_json::to_string(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
Cmd::ExportLogs { state, output } => {
|
||||
let archive = diagnostics::collect(&state)?;
|
||||
let bytes = STANDARD.decode(archive["data"].as_str().context("Missing archive")?)?;
|
||||
|
||||
+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)]
|
||||
|
||||
+5
-1
@@ -28,7 +28,11 @@ impl Drop for Staged {
|
||||
}
|
||||
impl Staged {
|
||||
pub fn create() -> Result<Self> {
|
||||
let directory = PathBuf::from("/tmp").join(format!(
|
||||
Self::create_in(Path::new("/tmp"))
|
||||
}
|
||||
/// Session-local staging; privileged IPC continues to require /tmp snapshots.
|
||||
pub fn create_in(parent: &Path) -> Result<Self> {
|
||||
let directory = parent.join(format!(
|
||||
"framely-package-{}",
|
||||
hex::encode(rand::random::<[u8; 24]>())
|
||||
));
|
||||
|
||||
+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()
|
||||
|
||||
+220
-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)]
|
||||
@@ -335,6 +336,12 @@ impl Agent {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if remote && r.method() == &Method::Get && path == "/" {
|
||||
r.respond(
|
||||
Response::empty(StatusCode(302)).with_header(header("Location", "/manager")),
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
if path == "/host/poll" {
|
||||
ensure!(!remote && self.native(&r), "Native authentication failed");
|
||||
if r.method() == &Method::Post {
|
||||
@@ -512,6 +519,29 @@ impl Agent {
|
||||
},
|
||||
"Session authentication failed"
|
||||
);
|
||||
if path.starts_with("/host/files/") {
|
||||
ensure!(!remote && r.method() == &Method::Post, "File browser is only available inside Frame");
|
||||
ensure!(r.headers().iter().any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin), "Cross-origin file browser request denied");
|
||||
ensure!(self.core("agreement.status", json!({}))?["accepted"] == true, "请先同意用户协议和隐私声明");
|
||||
let mut bytes = Vec::new();
|
||||
r.as_reader().take(65537).read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.len() <= 65536, "File browser request too large");
|
||||
let result = (|| -> Result<Value> {
|
||||
let params: Value = serde_json::from_slice(&bytes)?;
|
||||
let home = crate::steam::home()?;
|
||||
match path.as_str() {
|
||||
"/host/files/list" => crate::file_browser::list(&home, ¶ms),
|
||||
"/host/files/complete" => {
|
||||
let id = params["id"].as_str().filter(|s| !s.is_empty() && s.len() <= 128).context("Invalid file dialog")?;
|
||||
let paths = crate::file_browser::selection(&home, ¶ms)?;
|
||||
push(&self.commands, json!({"kind":"file.dialog.complete","id":id,"paths":paths}));
|
||||
Ok(json!(true))
|
||||
}
|
||||
_ => anyhow::bail!("Unknown file browser operation"),
|
||||
}
|
||||
})();
|
||||
return send_json(r, match result { Ok(v) => json!({"result":v}), Err(e) => json!({"error":e.to_string()}) });
|
||||
}
|
||||
if let Some(path) = path.strip_prefix("/api/upload/") {
|
||||
ensure!(r.method() == &Method::Post, "Invalid upload method");
|
||||
let origin = r
|
||||
@@ -617,6 +647,84 @@ 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)
|
||||
}
|
||||
"apk.upload.start" => {
|
||||
let size = p["size"].as_u64().context("Missing APK upload size")?;
|
||||
let root = crate::apk::upload_dir(&crate::steam::home()?, size)?;
|
||||
self.jobs.uploads.start_in(size, &root)
|
||||
}
|
||||
"apk.selection" => {
|
||||
let app = p["app"].as_str().context("Missing APK application")?;
|
||||
ensure!(app.len() <= 512, "Invalid APK application");
|
||||
self.events.lock().unwrap().append(
|
||||
json!({"kind":"apk.selection","app":app,"uninstall":p["uninstall"]==true}),
|
||||
);
|
||||
Ok(json!(true))
|
||||
}
|
||||
"apk.operation.active" => Ok(self.jobs.active_apk()),
|
||||
"apk.list.start" => {
|
||||
let home = crate::steam::home()?;
|
||||
self.jobs.task("apk:list", move |cancel| {
|
||||
cancel.check()?;
|
||||
crate::apk::list(&home)
|
||||
})
|
||||
}
|
||||
"apk.inspect.start" => {
|
||||
let home = crate::steam::home()?;
|
||||
let staged = self
|
||||
.jobs
|
||||
.uploads
|
||||
.take(p["upload"].as_str().context("Missing APK upload")?)?;
|
||||
self.jobs.task("apk:inspect", move |cancel| {
|
||||
crate::apk::inspect(&home, &staged.path, &cancel)
|
||||
})
|
||||
}
|
||||
"apk.review.drop" => crate::apk::reviews_drop(
|
||||
&crate::steam::home()?,
|
||||
p["ticket"].as_str().context("Missing APK review")?,
|
||||
),
|
||||
"apk.cleanup.list" => crate::apk::cleanup_list(&crate::steam::home()?),
|
||||
"apk.logs" => crate::apk::logs(
|
||||
&crate::steam::home()?,
|
||||
p["app"].as_str().context("Missing APK application")?,
|
||||
),
|
||||
"apk.operation.start" => {
|
||||
let home = crate::steam::home()?;
|
||||
let kind = p["operation"]
|
||||
.as_str()
|
||||
.context("Missing APK operation")?
|
||||
.to_owned();
|
||||
self.jobs
|
||||
.task_progress("apk:operation", move |cancel, progress| {
|
||||
crate::apk::operate(&home, &kind, &p, cancel, progress)
|
||||
})
|
||||
}
|
||||
"desktop.list" => Ok(json!(crate::desktop::discover(&crate::steam::home()?))),
|
||||
"desktop.launch" => {
|
||||
let id = p["app"].as_str().context("Missing desktop app")?;
|
||||
@@ -770,7 +878,14 @@ 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"
|
||||
| "apk"
|
||||
)
|
||||
)
|
||||
{
|
||||
command["page"] = p["page"].clone();
|
||||
@@ -903,6 +1018,7 @@ impl Agent {
|
||||
| "plugin.launcher.action"
|
||||
| "launcher.settings.save"
|
||||
| "launcher.order.save"
|
||||
| "launcher.favorite"
|
||||
| "steam.favorite"
|
||||
| "window.open"
|
||||
| "window.close"
|
||||
@@ -1212,6 +1328,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 +1713,7 @@ mod tests {
|
||||
commands: Mutex::default(),
|
||||
events: Mutex::default(),
|
||||
jobs: crate::jobs::Jobs::default(),
|
||||
launcher_search: Mutex::default(),
|
||||
catalog_cache: Arc::default(),
|
||||
})
|
||||
}
|
||||
@@ -1613,6 +1731,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 +1822,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 +1838,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() {
|
||||
@@ -1870,6 +2081,9 @@ mod tests {
|
||||
assert!(ureq::get(&format!("{origin}/boot/{}", agent.web_key))
|
||||
.call()
|
||||
.is_err());
|
||||
for path in ["/host/files/list", "/host/files/complete"] {
|
||||
assert!(matches!(ureq::post(&format!("{origin}{path}")).set("Origin", &origin).send_json(json!({})), Err(ureq::Error::Status(403, _))));
|
||||
}
|
||||
assert!(ureq::get(&format!("{origin}/host/poll"))
|
||||
.set("X-Framely-Native", &agent.native_key)
|
||||
.call()
|
||||
@@ -1953,6 +2167,9 @@ mod tests {
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap();
|
||||
let landing = client.get(&origin).set("Cookie", cookie).call().unwrap();
|
||||
assert_eq!(landing.status(), 302);
|
||||
assert_eq!(landing.header("Location"), Some("/manager"));
|
||||
let result: Value = client
|
||||
.post(&format!("{origin}/api"))
|
||||
.set("Cookie", cookie)
|
||||
|
||||
+16
-12
@@ -5,7 +5,6 @@ use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -120,7 +119,7 @@ fn root<'a>(v: &'a BTreeMap<String, Value>, key: &str) -> Option<&'a BTreeMap<St
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn discover(home: &Path) -> Vec<App> {
|
||||
pub(crate) fn library_roots(home: &Path) -> BTreeSet<PathBuf> {
|
||||
let steam = home.join(".local/share/Steam");
|
||||
let steam = if steam.exists() {
|
||||
steam
|
||||
@@ -147,6 +146,16 @@ pub fn discover(home: &Path) -> Vec<App> {
|
||||
}
|
||||
}
|
||||
}
|
||||
libraries
|
||||
}
|
||||
pub fn discover(home: &Path) -> Vec<App> {
|
||||
let steam = home.join(".local/share/Steam");
|
||||
let steam = if steam.exists() {
|
||||
steam
|
||||
} else {
|
||||
home.join(".steam/steam")
|
||||
};
|
||||
let libraries = library_roots(home);
|
||||
let mut apps = BTreeMap::new();
|
||||
for library in libraries {
|
||||
let Ok(entries) = fs::read_dir(library.join("steamapps")) else {
|
||||
@@ -302,16 +311,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();
|
||||
|
||||
+25
-1
@@ -27,12 +27,15 @@ impl Uploads {
|
||||
}
|
||||
}
|
||||
pub fn start(&self, total: u64) -> Result<Value> {
|
||||
self.start_in(total, std::path::Path::new("/tmp"))
|
||||
}
|
||||
pub fn start_in(&self, total: u64, parent: &std::path::Path) -> Result<Value> {
|
||||
ensure!(total > 0, "Empty package");
|
||||
let mut slot = self.0.lock().unwrap();
|
||||
Self::expire(&mut slot);
|
||||
ensure!(slot.is_none(), "An upload is already running");
|
||||
let id = hex::encode(rand::random::<[u8; 24]>());
|
||||
let package = crate::package::Staged::create()?;
|
||||
let package = crate::package::Staged::create_in(parent)?;
|
||||
let file = OpenOptions::new().write(true).open(&package.path)?;
|
||||
*slot = Some(Upload {
|
||||
id: id.clone(),
|
||||
@@ -125,6 +128,27 @@ mod tests {
|
||||
assert!(uploads.take(&id).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn persistent_uploads_are_private_and_removed_on_cancel_or_drop() {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let uploads = Uploads::default();
|
||||
let started = uploads.start_in(3, parent.path()).unwrap();
|
||||
let id = started["upload"].as_str().unwrap();
|
||||
uploads.append(id, 0, b"apk").unwrap();
|
||||
let staged = uploads.take(id).unwrap();
|
||||
assert!(staged.path.starts_with(parent.path()));
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(&staged.path).unwrap().permissions().mode() & 0o777,
|
||||
0o400
|
||||
);
|
||||
let path = staged.path.clone();
|
||||
drop(staged);
|
||||
assert!(!path.exists());
|
||||
let started = uploads.start_in(1, parent.path()).unwrap();
|
||||
uploads.abort(started["upload"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(fs::read_dir(parent.path()).unwrap().count(), 0);
|
||||
}
|
||||
#[test]
|
||||
fn cancellation_expiry_and_chunk_bound() {
|
||||
let uploads = Uploads::default();
|
||||
let id = uploads.start((CHUNK + 1) as u64).unwrap()["upload"]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';import{createRoot}from'react-dom/client';
|
||||
import{ApkManager}from'../ui/src/apk-manager';import{configureLanguage,t}from'../ui/src/i18n';import{Brand,Icon}from'../ui/src/icons';import'../ui/src/style.css';
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unknown)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(-500));};
|
||||
const meta=(pkg:string,name:string,launch=true)=>({package:pkg,name,version:'1.2.3',versionCode:123,minSdk:24,activities:launch?[`${pkg}.Main`]:[],declaredActivities:[`${pkg}.Main`],abis:['arm64-v8a'],vr:false});
|
||||
const apps:any[]=[{id:'framely-wolvic/com.igalia.wolvic',context:'framely-wolvic',metadata:meta('com.igalia.wolvic','Wolvic'),installed:true,running:false,managed:true,size:128*1024*1024},{id:'dev/com.example.service',context:'dev',metadata:meta('com.example.service','Background service',false),installed:true,running:true,managed:false,size:20*1024*1024},{id:'old/com.example.notes',context:'old',metadata:meta('com.example.notes','Notes'),installed:false,running:false,managed:true,size:4*1024*1024}];
|
||||
const containers=[{acceptsAdditionalApps:false,id:'framely-wolvic',name:'framely-wolvic',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/framely-wolvic/baked'},{acceptsAdditionalApps:true,id:'dev',name:'dev',managed:false,running:true,baked:'/home/steamos/.local/share/lepton/contexts/dev/baked'},{id:'old',name:'old',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/old/baked'}];
|
||||
const calls:{method:string;params:any}[]=[];const jobs=new Map<string,any>();let counter=0,fail=false,cleanupItems:any[]=[{id:'data:old/com.example.notes',kind:'data',name:'Notes',bytes:4*1024*1024,sensitive:true,app:'old/com.example.notes'},{id:'backups:backup1',kind:'backups',name:'20261005-backup',bytes:128*1024*1024,sensitive:true,context:'framely-wolvic',restorable:true},{id:'unknown:dev/com.example.orphan',kind:'unknown',name:'com.example.orphan',bytes:20,sensitive:true,unknown:true}];
|
||||
window.fetch=async(url,init)=>{if(String(url).startsWith('/api/upload/')){const offset=+String(url).split('/').at(-1)!;return new Response(JSON.stringify({result:{received:offset+(init?.body as Blob).size}}));}const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
|
||||
if(method==='apk.upload.start')result={upload:'fixture-upload',chunkSize:512*1024};
|
||||
else if(method==='apk.cleanup.list')result=cleanupItems;
|
||||
else if(method==='apk.logs')result={name:'apk.log',text:'Installation complete\nAndroid package manager: Success'};
|
||||
else if(method==='apk.list.start'||method==='apk.inspect.start'||method==='apk.operation.start'){
|
||||
const id='job-'+counter++;let value:any=true;
|
||||
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true};
|
||||
else if(method==='apk.inspect.start')value={ticket:'review-ticket',metadata:meta('com.example.new','New APK'),bytes:4};
|
||||
else{const app=apps.find(a=>a.id===params.app);if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall')app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
jobs.set(id,{id,kind:'apk',phase:fail&&method==='apk.operation.start'?'failed':'done',result:value,error:'Fixture signature mismatch',received:0});result={job:id};
|
||||
}else if(method==='job.status')result=jobs.get(params.job);
|
||||
return new Response(JSON.stringify({result}));};
|
||||
const root=createRoot(document.getElementById('root')!);function render(){root.render(<div className="app manager"><header className="shell-header"><Brand/><span>Framely</span></header><div className="workspace"><nav>{[['installed','已安装'],['download','插件库'],['download','APK 管理'],['settings','设置']].map(([icon,key])=><button className={key==='APK 管理'?'active':''} key={key}><Icon name={icon as any}/>{t(key)}</button>)}</nav><main className="content"><ApkManager/></main></div></div>);}
|
||||
const button=(text:string,scope:ParentNode=document)=>Array.from(scope.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent?.trim()===text)??(()=>{throw Error('Missing button '+text)})();
|
||||
async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIEW_INSTALL_'+name);await wait(180);}
|
||||
(async()=>{
|
||||
configureLanguage('en-US',[]);render();await until(()=>document.querySelectorAll('.apk-row').length===2);const content=document.querySelector('.content')!.getBoundingClientRect(),manager=document.querySelector('.apk-manager')!.getBoundingClientRect();if(Math.abs(manager.width-(content.width-64))>2&&innerWidth>1000)throw Error('Manager width does not fill content');const search=document.querySelector<HTMLInputElement>('.apk-filters input')!.getBoundingClientRect(),filter=document.querySelector<HTMLButtonElement>('.apk-filters .framely-select>button')!.getBoundingClientRect();if(Math.abs(search.y-filter.y)>1||Math.abs(search.height-filter.height)>1||search.right>=filter.left)throw Error('APK search/filter controls are not aligned');await snapshot('APK_EN');
|
||||
// Container startup and shutdown are not data deletion operations.
|
||||
button('Containers').click();await until(()=>document.querySelectorAll('.apk-container').length===3);
|
||||
for(const title of ['Start container','Stop container']){button(title).click();await until(()=>!!document.querySelector('.apk-dialog'));if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Container operation displayed data deletion warning');if(!button('Confirm',document.querySelector('.apk-dialog')!).classList.contains('primary'))throw Error('Non-destructive container confirmation uses destructive styling');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));}
|
||||
const external=document.querySelectorAll('.apk-container')[1];button('Delete container',external).click();await until(()=>document.querySelector('.apk-dialog'));if(!document.querySelector('.apk-dialog')!.textContent!.includes(t('容器内全部数据将永久删除。')))throw Error('External deletion lacks data warning');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));if(calls.some(c=>c.params.operation==='container.delete'))throw Error('Cancelled deletion modified external container');
|
||||
button('Apps').click();await until(()=>document.querySelectorAll('.apk-row').length===2);
|
||||
button('Manage',document.querySelector('.apk-row')!).click();await until(()=>document.querySelector('.apk-dialog'));const advanced=document.querySelector<HTMLDetailsElement>('.apk-advanced')!;advanced.open=true;document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Handle window display automatically"]')!.click();await until(()=>calls.some(c=>c.method==='apk.operation.start'&&c.params.operation==='settings'));await until(()=>!document.querySelector('progress'));await until(()=>document.querySelector('.apk-dialog')&&!button('Uninstall app',document.querySelector('.apk-dialog')!).disabled);
|
||||
button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>document.querySelector('[role=switch][aria-label="Delete app data"]'));if(document.querySelector('[aria-label="Delete app data"]')!.getAttribute('aria-checked')!=='false')throw Error('Uninstall defaults to data deletion');if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Data-preserving uninstall warns of permanent data deletion');document.querySelector<HTMLButtonElement>('[aria-label="Delete app data"]')!.click();await until(()=>document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')));document.querySelector<HTMLButtonElement>('[aria-label="Delete app data"]')!.click();await until(()=>!document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')));button('Confirm',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='uninstall'));await until(()=>!document.querySelector('progress'));if(calls.find(c=>c.params.operation==='uninstall')!.params.purge)throw Error('Uninstall purged by default');await until(()=>Array.from(document.querySelectorAll('.apk-dialog button')).some(b=>b.textContent==='Done'));button('Done',document.querySelector('.apk-dialog')!).click();
|
||||
button('Data cleanup').click();await until(()=>document.querySelectorAll('.apk-cleanup-row').length===3);if(document.querySelector('[role=switch][aria-checked=true]'))throw Error('Sensitive data selected by default');if(!document.querySelector<HTMLButtonElement>('[role=switch][aria-label*="com.example.orphan"]')!.disabled)throw Error('Unknown data can be deleted');await snapshot('APK_CLEANUP_EN');
|
||||
document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Select Notes"]')!.click();await until(()=>!button('Clean selected').disabled);button('Clean selected').click();await until(()=>document.querySelector('.apk-dialog'));button('Cancel',document.querySelector('.apk-dialog')!).click();if(calls.some(c=>c.params.operation==='cleanup'))throw Error('Cancel performed cleanup');
|
||||
apps[0].installed=true;await wait();configureLanguage('zh-CN',[]);console.log('APK_LOCALE '+t('APK 管理'));root.render(<div/>);await wait();configureLanguage('zh-CN',[]);render();await until(()=>document.querySelector('.apk-manager h1')?.textContent==='APK 管理');await until(()=>!document.querySelector('.empty'));await snapshot('APK_ZH');button('数据清理').click();await until(()=>document.querySelectorAll('.apk-cleanup-row').length===3);await snapshot('APK_CLEANUP_ZH');button('应用').click();
|
||||
const file=new File(['test'],'sample.apk',{type:'application/vnd.android.package-archive'}),transfer=new DataTransfer();transfer.items.add(file);const input=document.querySelector<HTMLInputElement>('input[type=file]')!;input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));await until(()=>document.querySelector('[aria-label="安装 APK"] .apk-meta'));if(calls.find(c=>c.method==='apk.upload.start')?.params.size!==4)throw Error('APK upload transport mismatch');await snapshot('APK_REVIEW_ZH');document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="目标容器"]')!.click();await until(()=>document.querySelector('[role=listbox]'));const targets=[...document.querySelectorAll('[role=option]')].map(b=>b.textContent);if(!targets.includes('dev')||targets.includes('framely-wolvic'))throw Error('Install targets ignore backend shared-mount policy');document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="目标容器"]')!.click();button('取消',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.method==='apk.review.drop'));if(calls.some(c=>c.params.operation==='install'))throw Error('Cancelled review installed APK');
|
||||
configureLanguage('en-US',[]);apps.push({id:'unknown/com.example.unknown',context:'unknown',metadata:meta('com.example.unknown','Unknown app'),installed:false,stateKnown:false,managed:false,size:10,pending:'Container unavailable; data has not been deleted'},{id:'steam/com.example.steam',context:'steam',metadata:meta('com.example.steam','Steam app'),installed:true,steam:true,size:10});root.render(<div/>);await wait();render();await until(()=>[...document.querySelectorAll('.apk-row')].some(r=>r.textContent?.includes('Unknown app')));if([...document.querySelectorAll('.apk-row')].some(r=>r.textContent?.includes('Steam app')))throw Error('Steam app exposed in side-loading app list');const unknown=[...document.querySelectorAll('.apk-row')].find(r=>r.textContent?.includes('Unknown app'))!;if(!unknown.textContent?.includes(t('安装状态未知'))||[...unknown.querySelectorAll('button')].some(b=>b.textContent==='Open'))throw Error('Unknown state presented as launchable or uninstalled');button('Manage',unknown).click();await until(()=>document.querySelector('.apk-dialog'));if([...document.querySelectorAll('.apk-dialog button')].some(b=>b.textContent==='Reinstall APK'))throw Error('Unknown app offers reinstallation');if(!button(t('清除保留数据'),document.querySelector('.apk-dialog')!).disabled)throw Error('Unknown app can delete retained data');button('Done',document.querySelector('.apk-dialog')!).click();
|
||||
console.log('FRAMELY_APK_MANAGER_PASS');console.log('FRAMELY_BRIDGE_PASS');
|
||||
})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e));
|
||||
@@ -1,3 +1,5 @@
|
||||
#include "../native/page_scroll_injection.h"
|
||||
#include "include/capi/cef_load_handler_capi.h"
|
||||
#include "../native/paint_buffer.h"
|
||||
#include "../native/launcher_pointer.h"
|
||||
// Integration test only: render the real manager and exercise its iframe bridge.
|
||||
@@ -22,6 +24,7 @@ static Handler<cef_client_t> client;
|
||||
static Handler<cef_render_handler_t> render;
|
||||
static Handler<cef_display_handler_t> display;
|
||||
static Handler<cef_life_span_handler_t> life;
|
||||
static Handler<cef_load_handler_t> load;
|
||||
static std::vector<unsigned char> pixels;static int pixel_width=0,pixel_height=0;
|
||||
static void snapshot(const std::string& name){const char* dir=std::getenv("FRAMELY_PREVIEW_DIR");if(!dir||pixels.empty())return;std::ofstream out(std::string(dir)+"/"+name+".ppm",std::ios::binary);out<<"P6\n"<<pixel_width<<" "<<pixel_height<<"\n255\n";for(size_t i=0;i<pixels.size();i+=4){unsigned char rgb[]={pixels[i+2],pixels[i+1],pixels[i]};out.write(reinterpret_cast<char*>(rgb),3);}}
|
||||
static bool install_review_mode=std::getenv("FRAMELY_INSTALL_REVIEW_TEST")!=nullptr;
|
||||
@@ -32,15 +35,18 @@ static bool ready=false,passed=false,failed=false,closed=false;static int paints
|
||||
static void str(cef_string_t& s,const std::string& v){cef_string_utf8_to_utf16(v.data(),v.size(),&s);}
|
||||
static std::string text(const cef_string_t* s){cef_string_utf8_t o{};cef_string_utf16_to_utf8(s->str,s->length,&o);std::string r(o.str,o.length);cef_string_utf8_clear(&o);return r;}
|
||||
int main(int argc,char** argv){
|
||||
load.api.on_load_end=[](cef_load_handler_t*,cef_browser_t*,cef_frame_t* frame,int){inject_page_scroll(frame);};
|
||||
client.api.get_load_handler=[](cef_client_t*){return load.acquire();};
|
||||
|
||||
std::cout.setf(std::ios::unitbuf);std::string url=argc>1?argv[1]:"",runtime=argc>2?argv[2]:"",cef=std::getenv("FRAMELY_CEF_ROOT")?std::getenv("FRAMELY_CEF_ROOT"):"";
|
||||
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");}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {FrameFilePicker} from '../ui/src/file-picker';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<100;i++){if(f())return;await wait();}throw Error('Timed out '+document.body.innerText);};
|
||||
const calls:{path:string;params:any}[]=[];
|
||||
const source=[{name:'Nested',path:'/home/steamos/Downloads/Nested',directory:true,size:0},{name:'Wolvic.apk',path:'/home/steamos/Downloads/Wolvic.apk',directory:false,size:157426910,modified:1791200000},{name:'language.json',path:'/home/steamos/Downloads/language.json',directory:false,size:100}];
|
||||
window.fetch=async(url,init)=>{const path=String(url),params=JSON.parse(String(init?.body));calls.push({path,params});if(path.endsWith('/complete'))return new Response(JSON.stringify({result:true}));if(!path.endsWith('/list'))throw Error('Unexpected request');await wait(params.query==='slow'?250:20);if(params.path==='/denied')return new Response(JSON.stringify({error:'无法读取目录'}));const directory=params.path||'/home/steamos/Downloads';let entries=directory==='/home/steamos'?[{name:'Downloads',path:'/home/steamos/Downloads',directory:true,size:0},{name:'Documents',path:'/home/steamos/Documents',directory:true,size:0}]:directory.endsWith('/Nested')||directory.endsWith('/Documents')?[]:source.filter(e=>e.directory||(!(params.extensions??[]).length||(params.extensions??[]).some((s:string)=>e.name.endsWith(s))));if(params.directoryOnly)entries=entries.filter(e=>e.directory);entries=entries.filter(e=>e.name.toLowerCase().includes((params.query??'').toLowerCase()));return new Response(JSON.stringify({result:{path:directory,parent:'/home/steamos',roots:[{name:'下载',path:'/home/steamos/Downloads'},{name:'主目录',path:'/home/steamos'},{name:'临时目录',path:'/tmp'}],entries,total:entries.length,offset:0,skipped:0}}));};
|
||||
const root=createRoot(document.getElementById('root')!);root.render(<><div className="app manager"><button>Underlying page</button></div><FrameFilePicker/></>);
|
||||
const open=(id:string,multiple=false,accept=['.apk'])=>window.dispatchEvent(new CustomEvent('framely.fileDialog',{detail:{id,multiple,directory:false,accept}}));
|
||||
const button=(name:string)=>[...document.querySelectorAll<HTMLButtonElement>('.frame-files button')].find(b=>b.textContent?.trim()===name)!;
|
||||
const row=(name:string)=>[...document.querySelectorAll<HTMLButtonElement>('.frame-file-row')].find(b=>b.textContent?.includes(name))!;
|
||||
function change(selector:string,value:string){const input=document.querySelector<HTMLInputElement>(selector)!;Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(input,value);input.dispatchEvent(new Event('input',{bubbles:true}));}
|
||||
(async()=>{try{
|
||||
configureLanguage('en-US',[]);await wait();if(document.querySelector('.frame-files'))throw Error('External browser automatically opened custom picker');open('apk');await until(()=>row('Wolvic.apk'));await until(()=>document.querySelector('[role=treeitem][aria-label=Downloads]'));const treeDownloads=document.querySelector<HTMLElement>('[role=treeitem][aria-label=Downloads]')!;if(treeDownloads.getAttribute('aria-selected')!=='true'||!document.querySelector('[role=treeitem][aria-label=Home] [role=group]'))throw Error('Current folder ancestors are not expanded');document.querySelector<HTMLButtonElement>('[aria-label="Collapse Home"]')!.click();await until(()=>!document.querySelector('[role=treeitem][aria-label=Downloads]'));document.querySelector<HTMLButtonElement>('[aria-label="Expand Home"]')!.click();await until(()=>document.querySelector('[role=treeitem][aria-label=Downloads]'));if(!document.getElementById('root')!.inert||row('language.json'))throw Error('Background or type filter incorrect');if(button('Select').disabled!==true)throw Error('Can select without a file');row('Wolvic.apk').click();await until(()=>!button('Select').disabled);button('Select').click();await until(()=>!document.querySelector('.frame-files'));if(calls.at(-1)?.params.paths[0]!=='/home/steamos/Downloads/Wolvic.apk'||document.getElementById('root')!.inert)throw Error('File selection not completed');
|
||||
open('multi',true,['.apk','.json']);await until(()=>row('language.json'));row('language.json').click();await wait();row('Wolvic.apk').click();await until(()=>document.querySelectorAll('.frame-file-row.selected').length===2);button('Select').click();await until(()=>!document.querySelector('.frame-files'));if(calls.at(-1)?.params.paths.length!==2)throw Error('Multiple selection lost');
|
||||
open('cancel');await until(()=>row('Wolvic.apk'));row('Nested').click();await until(()=>document.querySelector('.frame-files-list .empty'));document.querySelector<HTMLButtonElement>('[aria-label="Previous folder"]')!.click();await until(()=>row('Wolvic.apk'));let backgroundBack=false;const behind=()=>backgroundBack=true;window.addEventListener('framely.back',behind);window.dispatchEvent(new Event('framely.back'));await until(()=>!document.querySelector('.frame-files'));window.removeEventListener('framely.back',behind);if(backgroundBack||calls.at(-1)?.params.paths.length)throw Error('Back escaped picker or failed to cancel');
|
||||
open('errors');await until(()=>row('Wolvic.apk'));document.querySelector<HTMLButtonElement>('[aria-label="View options"]')!.click();await until(()=>document.querySelector('[aria-label="Supported files only"]'));document.querySelector<HTMLButtonElement>('[aria-label="Supported files only"]')!.click();await until(()=>row('language.json'));if(!row('language.json').disabled)throw Error('Unsupported file can be selected');document.querySelector<HTMLButtonElement>('[aria-label="Supported files only"]')!.click();await until(()=>!row('language.json'));document.querySelector<HTMLButtonElement>('[aria-label="View options"]')!.click();change('[aria-label="Folder path"]','/denied');button('Go').click();await until(()=>document.querySelector('[role=alert]'));if(!button('Select').disabled)throw Error('Stale directory remains selectable');button('Downloads').click();await until(()=>row('Wolvic.apk'));change('[aria-label="Search this folder"]','slow');await wait(220);change('[aria-label="Search this folder"]','wolvic');await until(()=>row('Wolvic.apk')&&!row('Nested'));await wait(300);if(!row('Wolvic.apk'))throw Error('Stale search response won');change('[aria-label="Search this folder"]','');await until(()=>row('Nested'));row('Wolvic.apk').click();await until(()=>document.querySelector('.frame-file-row.selected'));await wait(400);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PICKER_EN');await wait(650);button('Cancel').click();await until(()=>!document.querySelector('.frame-files'));
|
||||
configureLanguage('zh-CN',[]);open('zh');await until(()=>row('Wolvic.apk'));row('Wolvic.apk').click();await until(()=>document.querySelector('.frame-file-row.selected'));await wait(400);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PICKER_ZH');await wait(650);button('取消').click();await until(()=>!document.querySelector('.frame-files'));console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.log('FRAMELY_BRIDGE_FAIL '+String(e));}})();
|
||||
@@ -0,0 +1,10 @@
|
||||
#include "launcher_fade.h"
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
int main(){
|
||||
using namespace std::chrono;auto now=steady_clock::now();LauncherFade fade;
|
||||
fade.open(now);assert(fade.value(now)==0);assert(std::abs(fade.value(now+milliseconds(290))-.5f)<.001f);assert(fade.value(now+milliseconds(580))==1);
|
||||
// Closing during entry is continuous; reopening starts a new entry.
|
||||
auto interrupted=now+milliseconds(290);fade.close(interrupted);assert(std::abs(fade.value(interrupted)-.5f)<.001f);assert(std::abs(fade.value(interrupted+milliseconds(220))-.25f)<.001f);assert(fade.value(interrupted+milliseconds(440))==0);
|
||||
fade.open(interrupted);assert(fade.value(interrupted)==0);fade.reduced=true;assert(fade.value(interrupted)==1);fade.close(interrupted);assert(fade.value(interrupted)==0);
|
||||
}
|
||||
+37
-19
@@ -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,35 +17,35 @@ 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);await wait(640);
|
||||
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');}};
|
||||
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){if(![...groups.keys()][0].startsWith('3 /'))throw Error('Single row is not in the middle');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[1][1])-center(rows[1][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');}};
|
||||
assertRows([4,5,1]);
|
||||
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');
|
||||
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]];
|
||||
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],[5],[1,5],[2,5],[3,5],[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([2,5]);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');
|
||||
@@ -71,21 +72,38 @@ function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-i
|
||||
await until(()=>!!document.querySelector('[role=menu]'));await until(()=>document.querySelector('.launcher-editing'));if(document.querySelector('[role=menu]'))throw Error('800 ms left the menu open');
|
||||
console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(last.x+last.width/2)}_${Math.round(last.y+last.height/2)}`);await until(()=>calls.some(c=>c.method==='launcher.order.save'));
|
||||
await until(()=>!document.querySelector('.launcher-drag-ghost'));const reordered=[...document.querySelectorAll<HTMLElement>('.launch-item')].map(n=>n.dataset.launchKey!);if(reordered[3]!==orderedBefore[0]||order[3]!==orderedBefore[0])throw Error('Drag did not insert and persist order');if(calls.filter(c=>c.method==='plugin.launch'||c.method==='steam.launch').length!==launchesBefore)throw Error('Arrange gesture launched an app');const doneStyle=getComputedStyle(button('Done')),railStyle=getComputedStyle(document.querySelector('.launcher-tools')!);if(doneStyle.backgroundColor!==railStyle.backgroundColor||doneStyle.borderTopColor!==railStyle.borderTopColor||doneStyle.boxShadow!==railStyle.boxShadow)throw Error('Done button does not match category rail material');console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_EDIT_EN');await until(()=>!button('Done').disabled);button('Done').click();await until(()=>!document.querySelector('.launcher-editing'));await wait(100);
|
||||
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');
|
||||
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 wait(100);if(!document.querySelector('.launcher-editing')||calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('Inside blank click exited arrangement');window.dispatchEvent(new Event('framely.back'));await until(()=>!document.querySelector('.launcher-editing'));
|
||||
// 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');
|
||||
const arrangeIcon=document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Framely]')!;arrangeIcon.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await wait(80);button('Arrange icons').click();await until(()=>!!document.querySelector('.launcher-editing'));
|
||||
console.log('FRAMELY_LAUNCHER_PRESS_700_90_-180_0_550');await until(()=>!!document.querySelector('.launcher-adjacent-page'));await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');if(!document.querySelector('.launcher-editing'))throw Error('Swipe exited arrangement');await until(()=>!document.querySelector('.launcher-panning'));document.querySelectorAll<HTMLButtonElement>('.launcher-pages button')[0].click();await until(()=>document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')==='page');if(!document.querySelector('.launcher-editing'))throw Error('Page button exited arrangement');button('Done').click();await until(()=>!document.querySelector('.launcher-editing'));await wait(350);
|
||||
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'));
|
||||
console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(edgeFirst.x+edgeFirst.width/2)}_${Math.round(edgeFirst.y+edgeFirst.height/2)}_0_0_4800`);await until(()=>document.querySelector('.launcher-editing'));const dragFilter=document.querySelector('.launcher-filters button.active')!.getAttribute('aria-label');window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:2}));await wait(80);if(document.querySelector('.launcher-filters button.active')!.getAttribute('aria-label')!==dragFilter)throw Error('Vertical stick changed drag category');window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:1}));await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');if(!document.querySelector('.launcher-drag-ghost')||!document.querySelector('.launcher-editing'))throw Error('Stick paging cancelled drag');window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-1}));await until(()=>document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')==='page');const edgeBox=document.querySelector('.launcher-grid')!.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(edgeBox.right+100)}_400`);await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');console.log('FRAMELY_EDGE_PAGE_OK');const shade=document.querySelector('.launcher-drag-edge.right')!;if(shade.querySelector('svg')||getComputedStyle(shade).backgroundColor!=='rgba(0, 0, 0, 0)'||!getComputedStyle(shade,'::before').backgroundImage.includes('radial-gradient'))throw Error('Paging edge did not use feathered shading');console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_EDGE_SHADE_EN');const rightIndicator=document.querySelector('.launcher-drag-edge.right')!.getBoundingClientRect(),rightRail=document.querySelector('.launcher-shortcuts')!.getBoundingClientRect(),rightGhost=document.querySelector('.launcher-drag-ghost')!.getBoundingClientRect();if(rightIndicator.right>=rightRail.left||rightGhost.right>rightIndicator.left+4.5)throw Error('Right boundary overlaps shortcut rail or dragged icon crossed boundary');if(!document.querySelector('.launcher-editing'))throw Error('Edge paging exited arrangement');await wait(340);const leftEdge=categoryRight-40;console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(leftEdge)}_400`);await until(()=>!!document.querySelector('.launcher-drag-edge.left.active'));const indicator=document.querySelector('.launcher-drag-edge.left')!.getBoundingClientRect();if(indicator.left<=categoryRight||document.querySelector('.launcher-drag-ghost')!.getBoundingClientRect().left<indicator.right-4.5)throw Error('Left boundary overlaps rail or dragged icon crossed boundary');await until(()=>document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')==='page');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'));
|
||||
for(let n=0;n<9;n++)delete plugins[`edge.${n}`];redraw();window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-1}));await wait(100);
|
||||
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'));
|
||||
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');
|
||||
// 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+1);window.dispatchEvent(new Event('framely.launcher.closing'));await wait(80);const closingAnimation=document.querySelector('.launcher-shell')!.getAnimations()[0];if(!closingAnimation||Number((closingAnimation.effect as KeyframeEffect).getKeyframes().at(-1)?.opacity)!==0)throw Error('Closing animation missing');window.dispatchEvent(new Event('framely.launcher.open'));await wait(500);
|
||||
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,21 @@ 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)));
|
||||
|
||||
// A Dock depth gesture consumes stick input; a still-deflected stick must
|
||||
// not navigate after the guard expires until it has returned to neutral.
|
||||
LauncherStick depthStick;assert(!depthStick.update(0,0,true));
|
||||
assert(!depthStick.update(0,.9f,false));assert(!depthStick.update(0,.9f,true));
|
||||
assert(!depthStick.update(0,0,true));assert(depthStick.update(0,.9f,true)==2);
|
||||
|
||||
}
|
||||
@@ -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 [['Install APK','apk'],['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,10 @@
|
||||
const wait=(ms=50)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unknown)=>{for(let i=0;i<150;i++){if(f())return;await wait();}throw Error('Timed out '+document.body.innerText.slice(-500));};
|
||||
history.replaceState(null,'','/manager');let language='en-US',event:any=null;
|
||||
const app={id:'test/com.example.app',context:'test',metadata:{name:'Example',package:'com.example.app',version:'1.0',versionCode:1,activities:['com.example.app.Main'],declaredActivities:['com.example.app.Main'],abis:[],vr:false,minSdk:24},installed:true,size:42};
|
||||
window.fetch=async(_url,init)=>{const{method}=JSON.parse(String(init?.body));let result:any=true;
|
||||
if(method==='status')result={version:'0.4.3',database:{language,plugins:{},sources:[],safeMode:false},agreement:{version:'1',accepted:true},notifications:[],running:[]};
|
||||
else if(method==='language.list')result={packs:[]};else if(method==='ui.events'){result={cursor:1,events:event?[event]:[]};event=null;}
|
||||
else if(method==='apk.list.start')result={job:'list'};else if(method==='job.status')result={phase:'done',result:{apps:[app],containers:[{id:'test',name:'test',baked:'/test',managed:false}],roots:[],warnings:[],available:true}};else if(method==='apk.cleanup.list')result=[];
|
||||
return new Response(JSON.stringify({result}));};
|
||||
const button=(name:string)=>Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent?.trim()===name)!;
|
||||
(async()=>{await import('../ui/src/main');await until(()=>button('APK Manager'));if(document.querySelector('.apk-manager')||button('Plugin Manager')?.getAttribute('aria-pressed')!=='true')throw Error('Default is not Plugin Manager');button('APK Manager').click();await until(()=>document.querySelector('.apk-row'));if(document.querySelector('nav button')?.textContent?.trim()!=='Apps')throw Error('APK navigation not isolated');button('Containers').click();await until(()=>document.querySelector('.apk-container'));button('Apps').click();await until(()=>document.querySelector('.apk-row'));await wait(250);console.log('FRAMELY_PREVIEW_INSTALL_APK_PANEL_EN');button('Plugin Manager').click();await until(()=>!document.querySelector('.apk-manager'));if(location.hash)throw Error('Plugin switch retained APK hash');location.hash='apk';await until(()=>document.querySelector('.apk-manager'));event={kind:'apk.selection',app:app.id};await until(()=>document.querySelector('.apk-dialog'));button('Done').click();language='zh-CN';await until(()=>button('插件管理'));await wait(250);console.log('FRAMELY_PREVIEW_INSTALL_APK_PANEL_ZH');button('插件管理').click();await until(()=>!document.querySelector('.apk-manager'));console.log('FRAMELY_BRIDGE_PASS');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e));
|
||||
@@ -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,36 @@
|
||||
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();
|
||||
// A localWeb-style document has no SDK or host bridge, and may forbid scripts.
|
||||
// Native frame-load injection must still install the same gestures exactly once.
|
||||
const local=document.createElement('iframe');local.style.cssText='position:absolute;left:850px;top:200px;width:300px;height:240px';local.srcdoc='<meta http-equiv="Content-Security-Policy" content="script-src \'none\'"><style>body{margin:0}#area{height:180px;overflow:auto}#content{height:800px}</style><div id="area"><div id="content"><button id="local-button" style="margin:40px">Local plugin</button><input id="local-input"></div></div>';root.append(local);
|
||||
await until(()=>(local.contentDocument as any)?.__framelyScrollGestures);const ld=local.contentDocument!,area=ld.getElementById('area')!,lb=ld.getElementById('local-button')!;if(ld.getElementById('framely-scrollbars')||(local.contentWindow as any).__framelyBridge)throw Error('Local fixture accidentally loaded bootstrap');let localClicks=0;lb.addEventListener('click',()=>localClicks++);
|
||||
event(lb,'pointerdown',60,70);event(lb,'pointermove',60,20);event(lb,'pointerup',60,20,0);click(lb);if(area.scrollTop!==50||localClicks)throw Error('Native local plugin drag missing or duplicated');area.scrollTop=0;await wait(250);
|
||||
const input=ld.getElementById('local-input')!;event(input,'pointerdown',60,70);event(input,'pointermove',60,10);event(input,'pointerup',60,10,0);if(area.scrollTop)throw Error('Local input hijacked');await wait(500);const lr=local.getBoundingClientRect(),br=lb.getBoundingClientRect();console.log(`FRAMELY_PAGE_WHEEL_${Math.round(lr.x+br.x+40)}_${Math.round(lr.y+br.y+20)}_0_-120`);await until(()=>area.scrollTop>0);
|
||||
local.srcdoc='<style>#area{height:180px;overflow:auto}#content{height:800px}</style><div id="area"><div id="content">Reloaded</div></div>';await until(()=>local.contentDocument?.body.textContent==='Reloaded'&&(local.contentDocument as any).__framelyScrollGestures);local.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()
|
||||
@@ -4,5 +4,6 @@ base=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cef=$(realpath "$1")
|
||||
out=$(realpath -m "${2:-$base/target/native}")
|
||||
mkdir -p "$out"
|
||||
(cd "$base" && node tools/build-scroll-gestures.mjs "$out")
|
||||
openvr="$base/native/vendor/openvr"
|
||||
g++ -std=c++17 -O2 -I"$cef" -I"$openvr" -I"$base/native" "$base/native/host.cpp" -L"$cef/Release" -L"$openvr/lib/linuxarm64" -lcef -lopenvr_api -lGL -lX11 -pthread -Wl,-rpath,'$ORIGIN:$ORIGIN/../openvr' -o "$out/framely-vr"
|
||||
g++ -std=c++17 -O2 -I"$cef" -I"$openvr" -I"$base/native" -I"$out" "$base/native/host.cpp" -L"$cef/Release" -L"$openvr/lib/linuxarm64" -lcef -lopenvr_api -lGL -lX11 -pthread -Wl,-rpath,'$ORIGIN:$ORIGIN/../openvr' -o "$out/framely-vr"
|
||||
@@ -0,0 +1,8 @@
|
||||
import {build} from 'esbuild';
|
||||
import {mkdir,writeFile} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
const directory=resolve(process.argv[2]);
|
||||
await mkdir(directory,{recursive:true});
|
||||
const result=await build({entryPoints:['sdk/src/native-scroll.ts'],bundle:true,minify:true,write:false,format:'iife',target:'chrome120'});
|
||||
const script=result.outputFiles[0].text;
|
||||
await writeFile(resolve(directory,'native_scroll_script.h'),`#pragma once\nstatic constexpr char native_scroll_script[] = ${JSON.stringify(script)};\n`);
|
||||
@@ -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"
|
||||
|
||||
@@ -10,5 +10,6 @@ npm run build
|
||||
node --input-type=module -e "import {build} from 'esbuild';await build({entryPoints:['tests/keyboard_fixture.tsx'],bundle:true,outfile:'target/keyboard-fixture.js',define:{'process.env.NODE_ENV':'\"production\"'}});"
|
||||
# ICU must be adjacent to the executable before Chromium process startup.
|
||||
cp -a "$cef/Resources/." "$cef/Release/"
|
||||
g++ -std=c++17 -O2 -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread -Wl,-rpath,'$ORIGIN' -o "$cef/Release/framely-browser-probe"
|
||||
node tools/build-scroll-gestures.mjs "target/native-scroll-probe"
|
||||
g++ -std=c++17 -O2 -I"target/native-scroll-probe" -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread -Wl,-rpath,'$ORIGIN' -o "$cef/Release/framely-browser-probe"
|
||||
FRAMELY_CEF_ROOT="$cef" FRAMELY_BROWSER_PROBE="$cef/Release/framely-browser-probe" cargo test --locked cef_manager_plugin_bridge_integration -- --ignored --nocapture
|
||||
@@ -29,7 +29,8 @@ server_pid=$!
|
||||
for _ in {1..100}; do [[ ! -f $out/port ]] || break; sleep 0.05; done
|
||||
port=$(cat "$out/port")
|
||||
cp -a "$cef/Resources/." "$cef/Release/"
|
||||
g++ -std=c++17 -O2 -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread '-Wl,-rpath,$ORIGIN' -o "$cef/Release/framely-browser-probe"
|
||||
node tools/build-scroll-gestures.mjs "$out"
|
||||
g++ -std=c++17 -O2 -I"$out" -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread '-Wl,-rpath,$ORIGIN' -o "$cef/Release/framely-browser-probe"
|
||||
for mode in desktop mobile; do
|
||||
mkdir "$out/$mode"
|
||||
export FRAMELY_CEF_ROOT="$cef" FRAMELY_INSTALL_REVIEW_TEST=1 FRAMELY_PREVIEW_DIR="$out/$mode" GSETTINGS_BACKEND=memory
|
||||
|
||||
@@ -42,7 +42,8 @@ server_pid=$!
|
||||
for _ in {1..100}; do [[ ! -f $out/port ]] || break; sleep 0.05; done
|
||||
port=$(cat "$out/port")
|
||||
cp -a "$cef/Resources/." "$cef/Release/"
|
||||
g++ -std=c++17 -O2 -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread '-Wl,-rpath,$ORIGIN' -o "$cef/Release/framely-browser-probe"
|
||||
node tools/build-scroll-gestures.mjs "$out"
|
||||
g++ -std=c++17 -O2 -I"$out" -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread '-Wl,-rpath,$ORIGIN' -o "$cef/Release/framely-browser-probe"
|
||||
for mode in desktop; do
|
||||
mkdir "$out/$mode"
|
||||
export FRAMELY_CEF_ROOT="$cef" FRAMELY_LAUNCHER_TEST=1 FRAMELY_INSTALL_REVIEW_TEST=1 FRAMELY_PREVIEW_DIR="$out/$mode" GSETTINGS_BACKEND=memory
|
||||
|
||||
@@ -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_fade 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
|
||||
+162
-3
@@ -121,7 +121,7 @@
|
||||
"停用:": "Disable: ",
|
||||
"确认执行": "Confirm",
|
||||
"诊断与日志": "Diagnostics and logs",
|
||||
"暂无日志": "No logs available",
|
||||
"暂无日志": "No logs yet",
|
||||
"导入插件包": "Import plugin package",
|
||||
"插件包超过 64 MiB": "The plugin package exceeds 64 MiB",
|
||||
"插件包下载 URL": "Plugin package download URL",
|
||||
@@ -431,7 +431,7 @@
|
||||
"搜索通知应用": "Search notification apps",
|
||||
"搜索应用": "Search apps",
|
||||
"全局开关优先;关闭全局开关不会改变应用偏好。": "Global switches take priority. App preferences are kept when a global switch is off.",
|
||||
"应用": "App",
|
||||
"应用": "Apps",
|
||||
"未找到应用": "No matching apps",
|
||||
"本体通知,包括检查更新提醒。": "Framely notifications, including update reminders.",
|
||||
"启动台": "Launcher",
|
||||
@@ -469,6 +469,165 @@
|
||||
"日志已保存到:{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",
|
||||
"APK 管理": "APK Manager",
|
||||
"安装和管理 Lepton 侧载应用。": "Install and manage sideloaded Lepton apps.",
|
||||
"安装应用": "Install app",
|
||||
"安装 APK": "Install APK",
|
||||
"容器": "Containers",
|
||||
"数据清理": "Data cleanup",
|
||||
"第一版仅支持单个 APK 文件。": "This version supports individual APK files only.",
|
||||
"APK 已安装。应用数据和图标排序会保留。": "APK installed. Existing app data and icon order are preserved.",
|
||||
"已释放 {0},失败 {1} 项。": "Freed {0}; {1} items failed.",
|
||||
"备份已恢复,请启动应用验证存档;恢复前状态已另行备份。": "Backup restored. Launch the app to verify saved data; the previous state was backed up separately.",
|
||||
"操作完成": "Operation completed",
|
||||
"清除保留数据": "Remove retained data",
|
||||
"卸载应用": "Uninstall app",
|
||||
"默认保留存档和设置。此操作只影响该应用。": "Saved data and settings are retained by default. Only this app is affected.",
|
||||
"将永久删除该应用保留的存档和设置,无法撤销。": "Retained saved data and settings will be permanently deleted. This cannot be undone.",
|
||||
"此操作影响以下应用:": "This operation affects these apps:",
|
||||
"容器内全部数据将永久删除。": "All data in this container will be permanently deleted.",
|
||||
"未检测到 Lepton,请先通过 Steam 安装。": "Lepton was not found. Install it through Steam first.",
|
||||
"搜索 APK": "Search APKs",
|
||||
"搜索名称、包名或容器": "Search name, package or container",
|
||||
"应用筛选": "App filter",
|
||||
"已卸载但保留数据": "Uninstalled, data retained",
|
||||
"容器运行中": "Container running",
|
||||
"未发现启动入口": "No launch entry found",
|
||||
"没有匹配的 APK 应用。": "No matching APK apps.",
|
||||
"Steam 管理": "Managed by Steam",
|
||||
"Framely 管理": "Managed by Framely",
|
||||
"已有侧载容器": "Existing sideload container",
|
||||
"停止容器": "Stop container",
|
||||
"启动容器": "Start container",
|
||||
"删除容器": "Delete container",
|
||||
"其他数据位置": "Additional data locations",
|
||||
"添加其他工具使用的 baked 数据目录,不移动原有应用。": "Add a baked data directory used by another tool. Existing apps stay in place.",
|
||||
"Lepton 数据目录": "Lepton data directory",
|
||||
"原容器名称(可选)": "Original context name (optional)",
|
||||
"移除扫描位置": "Remove scan location",
|
||||
"存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。": "Saved data, settings and recovery backups are not selected by default. Unidentified data is never deleted automatically.",
|
||||
"确认数据清理": "Confirm data cleanup",
|
||||
"选中的文件将永久删除,无法撤销。": "Selected files will be permanently deleted. This cannot be undone.",
|
||||
"清理所选": "Clean selected",
|
||||
"保留的应用数据": "Retained app data",
|
||||
"恢复备份": "Recovery backup",
|
||||
"过期上传文件": "Expired upload",
|
||||
"旧 APK": "Old APK",
|
||||
"未知归属的数据": "Unidentified data",
|
||||
"无法确认归属,不提供自动删除。": "Ownership could not be established. Automatic removal is unavailable.",
|
||||
"删除后不可恢复": "Deletion cannot be undone",
|
||||
"恢复容器备份": "Restore container backup",
|
||||
"恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。": "Restoring replaces the state of apps in this container. A backup of the current state is created first; launch verification is required afterward.",
|
||||
"没有可清理的数据。": "No data to clean.",
|
||||
"部分容器无法读取": "Some containers could not be read",
|
||||
"空间占用": "Storage used",
|
||||
"文件大小": "File size",
|
||||
"最低 Android SDK": "Minimum Android SDK",
|
||||
"未声明": "Not specified",
|
||||
"安装新版 APK": "Install newer APK",
|
||||
"关闭应用": "Close app",
|
||||
"清除应用数据": "Clear app data",
|
||||
"将永久删除该应用的存档和设置,无法撤销。": "Saved data and settings will be permanently deleted. This cannot be undone.",
|
||||
"重新安装 APK": "Reinstall APK",
|
||||
"高级设置": "Advanced settings",
|
||||
"默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。": "Window display is handled automatically by default. This switch does not change whether an app is 2D or VR.",
|
||||
"自动处理窗口显示": "Handle window display automatically",
|
||||
"显示安卓窗口": "Show Android window",
|
||||
"窗口设置需要重启容器后生效。": "Restart the container to apply window settings.",
|
||||
"启动入口": "Launch entry",
|
||||
"自动选择": "Automatic",
|
||||
"确认更新 APK": "Confirm APK update",
|
||||
"确认安装 APK": "Confirm APK installation",
|
||||
"目标容器": "Target container",
|
||||
"独立容器(默认)": "Independent container (default)",
|
||||
"操作可能需要停止目标容器,影响其中正在运行的应用。": "The operation may stop the target container and affect apps running inside it.",
|
||||
"同包名更新保留数据;签名不匹配或降级不会自动卸载重装。": "Updates retain data for the same package. Signature mismatches and downgrades never trigger automatic uninstall/reinstall.",
|
||||
"共享或外部容器的备份不提供一键恢复,避免覆盖其他应用数据。": "One-click restoration is unavailable for shared or external container backups to avoid overwriting other apps.",
|
||||
"删除应用数据": "Delete app data",
|
||||
"关闭时保留存档和设置。": "Keep saved data and settings when switched off.",
|
||||
"永久删除的数据无法撤销。": "Permanently deleted data cannot be recovered.",
|
||||
"正在处理 APK,请等待实际结果。": "Processing APK. Please wait for the actual result.",
|
||||
"安装已提交,等待 Android 完成;此阶段不能安全取消。": "The operation has been submitted. Waiting for Android; cancellation is unavailable at this stage.",
|
||||
"APK 操作进度": "APK operation progress",
|
||||
"APK 日志": "APK logs",
|
||||
"核对实际安装状态": "Reconcile installed state",
|
||||
"管理应用": "Manage app",
|
||||
"正在读取": "Reading",
|
||||
"准备中": "Preparing",
|
||||
"上传 APK": "Uploading APK",
|
||||
"校验 APK": "Validating APK",
|
||||
"核对安装结果": "Verifying installation",
|
||||
"备份应用数据": "Backing up app data",
|
||||
"选择": "Select",
|
||||
"添加": "Add",
|
||||
"恢复": "Restore",
|
||||
"日志": "Logs",
|
||||
"切换管理面板": "Switch manager",
|
||||
"插件管理": "Plugin Manager",
|
||||
"Lepton 侧载管理": "Lepton sideload management",
|
||||
"操作等待超时;请确认应用是否已打开后再重试。": "The operation timed out. Check whether the application opened before retrying.",
|
||||
"未发现已安装的应用,启动容器不会安装 APK。": "No installed apps were found. Starting the container does not install an APK.",
|
||||
"安装状态未知": "Installation state unknown",
|
||||
"请通过 Steam 管理此应用。": "Manage this application through Steam.",
|
||||
"共享容器的备份需手动恢复;独立侧载容器支持一键恢复。": "Shared container backups require manual restoration. Independent sideloaded containers support one-click restoration.",
|
||||
"安装APK": "Install APK",
|
||||
"下载": "Downloads",
|
||||
"主目录": "Home",
|
||||
"桌面": "Desktop",
|
||||
"文档": "Documents",
|
||||
"临时目录": "Temporary files",
|
||||
"选择目录": "Choose folder",
|
||||
"选择文件": "Choose file",
|
||||
"浏览 Frame 上的目录": "Browse folders on Frame",
|
||||
"浏览 Frame 上的文件": "Browse files on Frame",
|
||||
"支持的文件类型": "Supported file types",
|
||||
"文件位置": "Locations",
|
||||
"返回上一个目录": "Previous folder",
|
||||
"上级目录": "Parent folder",
|
||||
"目录路径": "Folder path",
|
||||
"前往": "Go",
|
||||
"搜索当前目录": "Search this folder",
|
||||
"显示隐藏文件": "Show hidden files",
|
||||
"仅显示支持的文件": "Supported files only",
|
||||
"正在读取目录…": "Loading folder…",
|
||||
"文件夹": "Folder",
|
||||
"当前目录没有匹配的文件": "No matching files in this folder",
|
||||
"上一页": "Previous page",
|
||||
"下一页": "Next page",
|
||||
"部分项目无法访问,已跳过。": "Some inaccessible items were skipped.",
|
||||
"已选择 {0} 个文件": "{0} files selected",
|
||||
"请选择要导入的文件": "Select a file to import",
|
||||
"文件或目录不存在,或无权访问": "The file or folder does not exist, or access is denied.",
|
||||
"只能选择用户目录、临时目录或已挂载存储中的文件": "Choose a file in your home folder, temporary folder, or mounted storage.",
|
||||
"没有可访问的目录": "No accessible folders.",
|
||||
"请选择目录": "Select a folder.",
|
||||
"请选择文件": "Select a file.",
|
||||
"搜索内容过长": "Search text is too long.",
|
||||
"无法读取目录": "Unable to read this folder.",
|
||||
"目录文件过多,请选择更具体的目录": "Too many items. Choose a more specific folder.",
|
||||
"选择的文件过多": "Too many files selected.",
|
||||
"无法读取所选文件": "Unable to read the selected file.",
|
||||
"显示选项": "View options",
|
||||
"{0} 个项目": "{0} items",
|
||||
"修改时间": "Modified",
|
||||
"1 个项目": "1 item",
|
||||
"文件夹树": "Folders",
|
||||
"收起 {0}": "Collapse {0}",
|
||||
"展开 {0}": "Expand {0}",
|
||||
"加载更多目录": "Load more folders"
|
||||
}
|
||||
}
|
||||
+160
-1
@@ -469,6 +469,165 @@
|
||||
"日志已保存到:{0}": "日志已保存到:{0}",
|
||||
"日志包已生成,请查看浏览器下载。": "日志包已生成,请查看浏览器下载。",
|
||||
"日志包缺少数据": "日志包缺少数据",
|
||||
"导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。"
|
||||
"导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。",
|
||||
"发现可用发行版本": "发现可用发行版本",
|
||||
"稍后": "稍后",
|
||||
"全部包含的分类": "全部包含的分类",
|
||||
"只影响全部分类,其他分类和收藏仍可访问。": "只影响全部分类,其他分类和收藏仍可访问。",
|
||||
"包含 Framely": "包含 Framely",
|
||||
"启动方式、菜单与分类。": "启动方式、菜单与分类。",
|
||||
"没有匹配的应用": "没有匹配的应用",
|
||||
"还没有收藏的应用": "还没有收藏的应用",
|
||||
"收藏分类": "收藏",
|
||||
"读音搜索暂不可用,仍可使用原文搜索。": "读音搜索暂不可用,仍可使用原文搜索。",
|
||||
"启动方式与菜单": "启动方式与菜单",
|
||||
"当前已是此版本,无需重复更新。": "当前已是此版本,无需重复更新。",
|
||||
"快捷操作": "快捷操作",
|
||||
"APK 管理": "APK 管理",
|
||||
"安装和管理 Lepton 侧载应用。": "安装和管理 Lepton 侧载应用。",
|
||||
"安装应用": "安装应用",
|
||||
"安装 APK": "安装 APK",
|
||||
"容器": "容器",
|
||||
"数据清理": "数据清理",
|
||||
"第一版仅支持单个 APK 文件。": "第一版仅支持单个 APK 文件。",
|
||||
"APK 已安装。应用数据和图标排序会保留。": "APK 已安装。应用数据和图标排序会保留。",
|
||||
"已释放 {0},失败 {1} 项。": "已释放 {0},失败 {1} 项。",
|
||||
"备份已恢复,请启动应用验证存档;恢复前状态已另行备份。": "备份已恢复,请启动应用验证存档;恢复前状态已另行备份。",
|
||||
"操作完成": "操作完成",
|
||||
"清除保留数据": "清除保留数据",
|
||||
"卸载应用": "卸载应用",
|
||||
"默认保留存档和设置。此操作只影响该应用。": "默认保留存档和设置。此操作只影响该应用。",
|
||||
"将永久删除该应用保留的存档和设置,无法撤销。": "将永久删除该应用保留的存档和设置,无法撤销。",
|
||||
"此操作影响以下应用:": "此操作影响以下应用:",
|
||||
"容器内全部数据将永久删除。": "容器内全部数据将永久删除。",
|
||||
"未检测到 Lepton,请先通过 Steam 安装。": "未检测到 Lepton,请先通过 Steam 安装。",
|
||||
"搜索 APK": "搜索 APK",
|
||||
"搜索名称、包名或容器": "搜索名称、包名或容器",
|
||||
"应用筛选": "应用筛选",
|
||||
"已卸载但保留数据": "已卸载但保留数据",
|
||||
"容器运行中": "容器运行中",
|
||||
"未发现启动入口": "未发现启动入口",
|
||||
"没有匹配的 APK 应用。": "没有匹配的 APK 应用。",
|
||||
"Steam 管理": "Steam 管理",
|
||||
"Framely 管理": "Framely 管理",
|
||||
"已有侧载容器": "已有侧载容器",
|
||||
"停止容器": "停止容器",
|
||||
"启动容器": "启动容器",
|
||||
"删除容器": "删除容器",
|
||||
"其他数据位置": "其他数据位置",
|
||||
"添加其他工具使用的 baked 数据目录,不移动原有应用。": "添加其他工具使用的 baked 数据目录,不移动原有应用。",
|
||||
"Lepton 数据目录": "Lepton 数据目录",
|
||||
"原容器名称(可选)": "原容器名称(可选)",
|
||||
"移除扫描位置": "移除扫描位置",
|
||||
"存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。": "存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。",
|
||||
"确认数据清理": "确认数据清理",
|
||||
"选中的文件将永久删除,无法撤销。": "选中的文件将永久删除,无法撤销。",
|
||||
"清理所选": "清理所选",
|
||||
"保留的应用数据": "保留的应用数据",
|
||||
"恢复备份": "恢复备份",
|
||||
"过期上传文件": "过期上传文件",
|
||||
"旧 APK": "旧 APK",
|
||||
"未知归属的数据": "未知归属的数据",
|
||||
"无法确认归属,不提供自动删除。": "无法确认归属,不提供自动删除。",
|
||||
"删除后不可恢复": "删除后不可恢复",
|
||||
"恢复容器备份": "恢复容器备份",
|
||||
"恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。": "恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。",
|
||||
"没有可清理的数据。": "没有可清理的数据。",
|
||||
"部分容器无法读取": "部分容器无法读取",
|
||||
"空间占用": "空间占用",
|
||||
"文件大小": "文件大小",
|
||||
"最低 Android SDK": "最低 Android SDK",
|
||||
"未声明": "未声明",
|
||||
"安装新版 APK": "安装新版 APK",
|
||||
"关闭应用": "关闭应用",
|
||||
"清除应用数据": "清除应用数据",
|
||||
"将永久删除该应用的存档和设置,无法撤销。": "将永久删除该应用的存档和设置,无法撤销。",
|
||||
"重新安装 APK": "重新安装 APK",
|
||||
"高级设置": "高级设置",
|
||||
"默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。": "默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。",
|
||||
"自动处理窗口显示": "自动处理窗口显示",
|
||||
"显示安卓窗口": "显示安卓窗口",
|
||||
"窗口设置需要重启容器后生效。": "窗口设置需要重启容器后生效。",
|
||||
"启动入口": "启动入口",
|
||||
"自动选择": "自动选择",
|
||||
"确认更新 APK": "确认更新 APK",
|
||||
"确认安装 APK": "确认安装 APK",
|
||||
"目标容器": "目标容器",
|
||||
"独立容器(默认)": "独立容器(默认)",
|
||||
"操作可能需要停止目标容器,影响其中正在运行的应用。": "操作可能需要停止目标容器,影响其中正在运行的应用。",
|
||||
"同包名更新保留数据;签名不匹配或降级不会自动卸载重装。": "同包名更新保留数据;签名不匹配或降级不会自动卸载重装。",
|
||||
"共享或外部容器的备份不提供一键恢复,避免覆盖其他应用数据。": "共享或外部容器的备份不提供一键恢复,避免覆盖其他应用数据。",
|
||||
"删除应用数据": "删除应用数据",
|
||||
"关闭时保留存档和设置。": "关闭时保留存档和设置。",
|
||||
"永久删除的数据无法撤销。": "永久删除的数据无法撤销。",
|
||||
"正在处理 APK,请等待实际结果。": "正在处理 APK,请等待实际结果。",
|
||||
"安装已提交,等待 Android 完成;此阶段不能安全取消。": "安装已提交,等待 Android 完成;此阶段不能安全取消。",
|
||||
"APK 操作进度": "APK 操作进度",
|
||||
"APK 日志": "APK 日志",
|
||||
"核对实际安装状态": "核对实际安装状态",
|
||||
"管理应用": "管理应用",
|
||||
"正在读取": "正在读取",
|
||||
"准备中": "准备中",
|
||||
"上传 APK": "上传 APK",
|
||||
"校验 APK": "校验 APK",
|
||||
"核对安装结果": "核对安装结果",
|
||||
"备份应用数据": "备份应用数据",
|
||||
"选择": "选择",
|
||||
"添加": "添加",
|
||||
"恢复": "恢复",
|
||||
"日志": "日志",
|
||||
"切换管理面板": "切换管理面板",
|
||||
"插件管理": "插件管理",
|
||||
"Lepton 侧载管理": "Lepton 侧载管理",
|
||||
"操作等待超时;请确认应用是否已打开后再重试。": "操作等待超时;请确认应用是否已打开后再重试。",
|
||||
"未发现已安装的应用,启动容器不会安装 APK。": "未发现已安装的应用,启动容器不会安装 APK。",
|
||||
"安装状态未知": "安装状态未知",
|
||||
"请通过 Steam 管理此应用。": "请通过 Steam 管理此应用。",
|
||||
"共享容器的备份需手动恢复;独立侧载容器支持一键恢复。": "共享容器的备份需手动恢复;独立侧载容器支持一键恢复。",
|
||||
"安装APK": "安装APK",
|
||||
"下载": "下载",
|
||||
"主目录": "主目录",
|
||||
"桌面": "桌面",
|
||||
"文档": "文档",
|
||||
"临时目录": "临时目录",
|
||||
"选择目录": "选择目录",
|
||||
"选择文件": "选择文件",
|
||||
"浏览 Frame 上的目录": "浏览 Frame 上的目录",
|
||||
"浏览 Frame 上的文件": "浏览 Frame 上的文件",
|
||||
"支持的文件类型": "支持的文件类型",
|
||||
"文件位置": "文件位置",
|
||||
"返回上一个目录": "返回上一个目录",
|
||||
"上级目录": "上级目录",
|
||||
"目录路径": "目录路径",
|
||||
"前往": "前往",
|
||||
"搜索当前目录": "搜索当前目录",
|
||||
"显示隐藏文件": "显示隐藏文件",
|
||||
"仅显示支持的文件": "仅显示支持的文件",
|
||||
"正在读取目录…": "正在读取目录…",
|
||||
"文件夹": "文件夹",
|
||||
"当前目录没有匹配的文件": "当前目录没有匹配的文件",
|
||||
"上一页": "上一页",
|
||||
"下一页": "下一页",
|
||||
"部分项目无法访问,已跳过。": "部分项目无法访问,已跳过。",
|
||||
"已选择 {0} 个文件": "已选择 {0} 个文件",
|
||||
"请选择要导入的文件": "请选择要导入的文件",
|
||||
"文件或目录不存在,或无权访问": "文件或目录不存在,或无权访问",
|
||||
"只能选择用户目录、临时目录或已挂载存储中的文件": "只能选择用户目录、临时目录或已挂载存储中的文件",
|
||||
"没有可访问的目录": "没有可访问的目录",
|
||||
"请选择目录": "请选择目录",
|
||||
"请选择文件": "请选择文件",
|
||||
"搜索内容过长": "搜索内容过长",
|
||||
"无法读取目录": "无法读取目录",
|
||||
"目录文件过多,请选择更具体的目录": "目录文件过多,请选择更具体的目录",
|
||||
"选择的文件过多": "选择的文件过多",
|
||||
"无法读取所选文件": "无法读取所选文件",
|
||||
"显示选项": "显示选项",
|
||||
"{0} 个项目": "{0} 个项目",
|
||||
"修改时间": "修改时间",
|
||||
"1 个项目": "1 个项目",
|
||||
"文件夹树": "文件夹树",
|
||||
"收起 {0}": "收起 {0}",
|
||||
"展开 {0}": "展开 {0}",
|
||||
"加载更多目录": "加载更多目录"
|
||||
}
|
||||
}
|
||||
+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,5 @@
|
||||
.apk-manager{width:100%;min-width:0}.apk-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px}.apk-tabs{display:flex;gap:8px;border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:22px}.apk-tabs button{background:transparent;border-color:transparent;color:var(--muted)}.apk-tabs button.active{background:#30343a;color:var(--text)}.apk-filters{display:flex;gap:12px;width:100%;margin-bottom:20px}.apk-filters input{flex:1;min-width:0;margin:0}.apk-filters select{width:240px;margin:0}.apk-row,.apk-container,.apk-cleanup-row{display:flex;align-items:center;gap:20px;padding:22px 0;border-bottom:1px solid var(--line);width:100%;min-width:0}.apk-copy{flex:1;min-width:0}.apk-copy h2{font-size:23px;display:flex;align-items:center;flex-wrap:wrap;gap:14px;margin:0 0 6px}.apk-copy h2 small{font-size:16px;font-weight:400;color:var(--muted)}.apk-copy code,.apk-location code,.apk-container code{font-size:16px;overflow-wrap:anywhere}.apk-copy p{color:var(--muted);font-size:16px;margin:7px 0 0}.apk-copy .error{color:#f7a7a7}.apk-notice{padding:14px 18px;background:#19282f;border-left:3px solid var(--blue)}.apk-error{display:flex;justify-content:space-between;gap:12px;white-space:pre-wrap;overflow-wrap:anywhere}.apk-error button{flex:none}.apk-locations{margin-top:30px;width:100%}.apk-locations>p,.apk-cleanup-heading p{color:var(--muted);font-size:17px}.apk-location{display:flex;gap:14px;align-items:center;justify-content:space-between;margin:12px 0}.apk-cleanup-heading{display:flex;align-items:center;justify-content:space-between;gap:20px}.apk-cleanup-row>small,.apk-copy>small{color:#d5b889}.apk-warnings{margin-top:24px;overflow-wrap:anywhere}.apk-dialog{position:fixed;z-index:40}.apk-dialog .apk-dialog-heading{display:flex;align-items:center;gap:18px;margin:20px 0}.apk-dialog-heading h2,.apk-dialog-heading h3{margin:0 0 8px}.apk-dialog-heading code{background:transparent;padding:0}.apk-meta{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:12px 24px;margin:24px 0}.apk-meta dt{color:var(--muted)}.apk-meta dd{margin:0;overflow-wrap:anywhere}.apk-dialog label{display:block;width:100%}.apk-dialog label select{width:100%;margin:12px 0}.apk-dialog footer{display:flex;justify-content:flex-end;gap:12px;border-top:1px solid var(--line);padding-top:20px;margin-top:24px}.apk-dialog footer button{margin:0}.apk-dialog .apk-actions{margin:20px 0}.apk-dialog .apk-actions button{margin:0}.apk-advanced{margin-top:24px}.apk-advanced p{font-size:17px;color:var(--muted)}.apk-confirm-copy{white-space:pre-wrap}.apk-dialog pre{max-height:50vh;overflow:auto}.apk-dialog progress{margin:18px 0}.apk-dialog .switch-row{width:100%}@media(max-width:700px){.apk-row,.apk-container{flex-wrap:wrap;gap:14px}.apk-row>.apk-actions,.apk-container>.apk-actions{width:100%}.apk-filters{flex-wrap:wrap}.apk-filters select{width:100%}.apk-cleanup-heading{flex-wrap:wrap}.apk-meta{grid-template-columns:minmax(0,1fr)}.apk-meta dd{margin-bottom:8px}.apk-location{flex-wrap:wrap}}
|
||||
|
||||
.apk-modal-backdrop{position:fixed;inset:0;z-index:40;background:#0009}.apk-modal-backdrop .modal{box-shadow:0 20px 80px #0008}
|
||||
|
||||
.apk-filters{align-items:center}.apk-filters input:not([type=checkbox]):not([type=file]){margin:0;flex:1;width:auto;min-width:0}.apk-filters>.framely-select{flex:0 0 260px;min-width:0}.apk-filters>.framely-select>button{min-height:56px}.apk-filters>button{flex:none}.apk-dialog label .framely-select{margin:12px 0}@media(max-width:700px){.apk-filters>.framely-select{flex:1 1 100%}}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {IconBrandAndroid,IconDownload,IconRefresh,IconTrash,IconPlayerPlay,IconPlayerStop,IconFolder,IconFileText,IconX} from '@tabler/icons-react';
|
||||
import {api,uploadChunk} from './api';
|
||||
import {uploadFile} from './upload';
|
||||
import {Job,PluginImage,runJob} from './store';
|
||||
import {Select} from './localized-select';
|
||||
import {Switch,SwitchRow} from './switch';
|
||||
import {t} from './i18n';
|
||||
import './apk-manager.css';
|
||||
type Metadata={package:string;name:string;version:string;versionCode:number;minSdk:number;activities:string[];declaredActivities:string[];icon?:string;abis:string[];vr:boolean};
|
||||
type App={stateKnown?:boolean;id:string;context:string;metadata:Metadata;installed:boolean;running:boolean;managed:boolean;steam:boolean;size:number;pending?:string;activity?:string;showWindow?:boolean|null};
|
||||
type Container={acceptsAdditionalApps?:boolean;id:string;name:string;baked:string;running:boolean;managed:boolean;steam:boolean};
|
||||
type Data={apps:App[];containers:Container[];warnings:string[];roots:string[];available:boolean};
|
||||
type Cleanup={id:string;kind:string;name:string;bytes:number;sensitive:boolean;app?:string;context?:string;restorable?:boolean;unknown?:boolean};
|
||||
type Review={ticket:string;metadata:Metadata;bytes:number};
|
||||
type Confirm={operation:string;app?:App;container?:Container;backup?:string;items?:string[];title:string;description:string;purge?:boolean};
|
||||
function bytes(n:number){return n<1024?`${n} B`:n<1024*1024?`${(n/1024).toFixed(1)} KiB`:n<1024**3?`${(n/1024**2).toFixed(1)} MiB`:`${(n/1024**3).toFixed(2)} GiB`;}
|
||||
const phases:Record<string,string>={loading:'正在读取',preparing:'准备中',uploading:'上传 APK',inspecting:'校验 APK',starting:'启动容器',installing:'安装 APK',verifying:'核对安装结果','backing-up':'备份应用数据',uninstalling:'卸载应用',done:'完成',failed:'失败',cancelled:'操作已取消'};
|
||||
export function ApkManager({view:externalView,onViewChange}:{view?:string;onViewChange?:(view:string)=>void}={}){
|
||||
const[data,setData]=useState<Data>({apps:[],containers:[],warnings:[],roots:[],available:true}),[loading,setLoading]=useState(true),[localView,setLocalView]=useState('apps'),[filter,setFilter]=useState('installed'),[query,setQuery]=useState(''),[error,setError]=useState(''),[review,setReview]=useState<Review|null>(null),[target,setTarget]=useState(''),[updating,setUpdating]=useState<App|null>(null),[details,setDetails]=useState<App|null>(null),[confirm,setConfirm]=useState<Confirm|null>(null),[job,setJob]=useState<(Job&{cancellable?:boolean})|null>(null),[busy,setBusy]=useState(false),[cleanup,setCleanup]=useState<Cleanup[]>([]),[selected,setSelected]=useState<string[]>([]),[log,setLog]=useState<{name:string;text:string}|null>(null),[path,setPath]=useState(''),[contextName,setContextName]=useState(''),[notice,setNotice]=useState('');
|
||||
const view=externalView??localView;const setView=(value:string)=>{setLocalView(value);onViewChange?.(value);};
|
||||
useEffect(()=>{if(view==='cleanup')void loadCleanup();},[view]);
|
||||
const input=useRef<HTMLInputElement>(null),controller=useRef<AbortController|null>(null),selection=useRef<{app:string;uninstall:boolean}|null>(null),alive=useRef(true),busyRef=useRef(false);
|
||||
const updateJob=(v:Job)=>{if(alive.current)setJob(v);};
|
||||
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');if(!active.job||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean}>('job.status',{job:active.job});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(value.phase==='failed')setError(value.error??t('操作失败'));else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(String(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}}
|
||||
async function refresh(){setLoading(true);try{const r=await runJob('apk.list.start');if(alive.current){setData(r.result);setDetails(old=>old?r.result.apps.find((a:App)=>a.id===old.id)??null:null);}}catch(e){if(alive.current)setError(String(e));}finally{if(alive.current)setLoading(false);}}
|
||||
useEffect(()=>{alive.current=true;void refresh();void resumeOperation();const select=(e:Event)=>{const v=(e as CustomEvent).detail;if(v?.kind==='apk.selection'){selection.current={app:v.app,uninstall:!!v.uninstall};void refresh();}};window.addEventListener('framely.event',select);return()=>{alive.current=false;window.removeEventListener('framely.event',select);controller.current?.abort();};},[]);
|
||||
useEffect(()=>{if(selection.current){const a=data.apps.find(a=>a.id===selection.current!.app);if(a){if(a.steam){setError(t('请通过 Steam 管理此应用。'));selection.current=null;return;}if(selection.current.uninstall)askUninstall(a);else setDetails(a);selection.current=null;}}},[data]);
|
||||
useEffect(()=>{const close=(e:Event)=>{if(e instanceof KeyboardEvent&&e.key!=='Escape')return;if(busyRef.current)return;if(confirm)setConfirm(null);else if(log)setLog(null);else if(review)void discard();else setDetails(null);};window.addEventListener('keydown',close);window.addEventListener('framely.back',close);return()=>{window.removeEventListener('keydown',close);window.removeEventListener('framely.back',close);};},[confirm,log,review]);
|
||||
useEffect(()=>{const dialog=document.querySelector<HTMLElement>('.apk-dialog');if(!dialog)return;const prev=document.activeElement as HTMLElement;const controls=()=>Array.from(dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input,select,a[href]')).filter(e=>e.getClientRects().length);controls()[0]?.focus();const key=(e:KeyboardEvent)=>{if(e.key!=='Tab')return;const a=controls();if(!a.length)return;if(e.shiftKey&&(document.activeElement===a[0]||!dialog.contains(document.activeElement))){e.preventDefault();a.at(-1)?.focus();}else if(!e.shiftKey&&(document.activeElement===a.at(-1)||!dialog.contains(document.activeElement))){e.preventDefault();a[0].focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);prev?.focus();};},[review,details,confirm,log,job?.id]);
|
||||
async function discard(){if(review)await api('apk.review.drop',{ticket:review.ticket}).catch(()=>{});setReview(null);setUpdating(null);}
|
||||
async function inspect(file:File){if(!file.name.toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob({id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});const result=await runJob('apk.inspect.start',{upload},updateJob);if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setReview(result.result);setTarget(updating?.context??'');setJob(null);}catch(e){setJob(null);setError(String(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}}
|
||||
function choose(a?:App){setUpdating(a??null);if(input.current){input.current.value='';input.current.click();}}
|
||||
async function perform(operation:string,params:Record<string,unknown>={}){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(String(e));await refresh();}finally{setBusy(false);busyRef.current=false;}}
|
||||
function askUninstall(a:App){setConfirm({operation:a.installed?'uninstall':'purge',app:a,title:a.installed?t('卸载应用'):t('清除保留数据'),description:a.installed?t('默认保留存档和设置。此操作只影响该应用。'):t('将永久删除该应用保留的存档和设置,无法撤销。'),purge:!a.installed});}
|
||||
async function loadCleanup(){try{const items=await api<Cleanup[]>('apk.cleanup.list');setCleanup(items);setSelected([]);}catch(e){setError(String(e));}}
|
||||
async function showLog(a:App|string){try{setLog(await api('apk.logs',{app:typeof a==='string'?a:a.id}));}catch(e){setError(String(e));}}
|
||||
function downloadLog(){if(!log)return;const url=URL.createObjectURL(new Blob([log.text],{type:'text/plain;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download=log.name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
||||
const listed=data.apps.filter(a=>(filter==='all'||(filter==='installed'?(a.installed||a.stateKnown===false):(!a.installed&&a.stateKnown!==false)))&&!a.steam&&`${a.metadata.name} ${a.metadata.package} ${a.context}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const contextFor=(id:string)=>data.containers.find(c=>c.id===id);
|
||||
function containerAsk(c:Container,operation:string){setConfirm({operation,container:c,title:t(operation==='container.delete'?'删除容器':operation==='container.stop'?'停止容器':'启动容器'),description:`${t('此操作影响以下应用:')} ${data.apps.filter(a=>a.context===c.id).map(a=>a.metadata.name).join(', ')||c.name}${operation==='container.delete'?`\n${t('容器内全部数据将永久删除。')}`:''}`});}
|
||||
const deletesData=!!confirm&&(['purge','clear','cleanup','container.delete'].includes(confirm.operation)||(confirm.operation==='uninstall'&&!!confirm.purge));
|
||||
const dialog=(children:React.ReactNode,label:string)=>createPortal(<div className="apk-modal-backdrop"><div className="modal apk-dialog" role="dialog" aria-modal="true" aria-label={label}>{error&&!review&&!confirm&&<p className="error" role="alert">{error}</p>}{children}</div></div>,document.body);
|
||||
return <section className="apk-manager">
|
||||
<div className="list-heading"><div><h1>{t('APK 管理')}</h1><p>{t('安装和管理 Lepton 侧载应用。')}</p></div><div className="apk-actions"><button disabled={busy||loading} onClick={()=>void refresh()}><IconRefresh size={22}/>{t('刷新')}</button><button className="primary" disabled={busy||!data.available} onClick={()=>choose()}><IconDownload size={22}/>{t('安装 APK')}</button></div></div>
|
||||
<input ref={input} type="file" accept=".apk,application/vnd.android.package-archive" hidden onChange={e=>{if(e.target.files?.[0])void inspect(e.target.files[0]);}}/>
|
||||
{!data.available&&<p className="banner">{t('未检测到 Lepton,请先通过 Steam 安装。')}</p>}
|
||||
{notice&&<p className="apk-notice" role="status">{notice}</p>}{error&&<div className="error apk-error" role="alert">{error}<button aria-label={t('关闭')} onClick={()=>setError('')}><IconX size={20}/></button></div>}
|
||||
{!externalView&&<div className="apk-tabs" role="tablist">{[['apps','应用'],['containers','容器'],['cleanup','数据清理']].map(([key,label])=><button key={key} role="tab" aria-selected={view===key} className={view===key?'active':''} disabled={busy} onClick={()=>{setView(key);if(key==='cleanup')void loadCleanup();}}>{t(label)}</button>)}</div>}
|
||||
{view==='apps'&&<><div className="apk-filters"><input type="search" aria-label={t('搜索 APK')} placeholder={t('搜索名称、包名或容器')} value={query} onChange={e=>setQuery(e.target.value)}/><Select label={t('应用筛选')} value={filter} onChange={setFilter} options={[{value:'installed',label:t('已安装')},{value:'retained',label:t('已卸载但保留数据')},{value:'all',label:t('全部')}]}/></div><div className="apk-list">{listed.map(a=><article className="apk-row" key={a.id}><PluginImage src={a.metadata.icon} name={a.metadata.name} size={64}/><div className="apk-copy"><h2>{a.metadata.name}<small>{a.metadata.version}</small></h2><code>{a.metadata.package}</code><p>{contextFor(a.context)?.name??a.context} · {bytes(a.size)} · {t(a.stateKnown===false?'安装状态未知':a.installed?(a.running?'容器运行中':'已安装'):'已卸载但保留数据')}</p>{a.pending&&<p className="error">{t(a.pending)}</p>}{a.installed&&!a.metadata.activities.length&&!a.activity&&<p>{t('未发现启动入口')}</p>}</div><div className="apk-actions">{a.installed&&a.stateKnown!==false&&<button disabled={busy||!a.metadata.activities.length&&!a.activity} onClick={()=>void perform('launch',{app:a.id})}><IconPlayerPlay size={20}/>{t('打开')}</button>}<button disabled={busy} onClick={()=>setDetails(a)}>{t('管理')}</button></div></article>)}</div>{!listed.length&&<div className="empty"><IconBrandAndroid size={40}/><p>{loading?t('正在读取'):t('没有匹配的 APK 应用。')}</p></div>}</>}
|
||||
{view==='containers'&&<><div className="apk-list">{data.containers.map(c=><article className="apk-container" key={c.id}><div className="apk-copy"><h2><IconFolder size={24}/>{c.name}</h2><code>{c.baked}</code><p>{t(c.steam?'Steam 管理':c.managed?'Framely 管理':'已有侧载容器')} · {t(c.running?'运行中':'已停止')}</p><p>{data.apps.filter(a=>a.context===c.id&&a.installed).map(a=>a.metadata.name).join(', ')||t('未发现已安装的应用,启动容器不会安装 APK。')}</p></div><div className="apk-actions"><button disabled={busy} onClick={()=>void showLog(c.id)}><IconFileText size={20}/>{t('日志')}</button>{!c.steam&&<><button disabled={busy} onClick={()=>containerAsk(c,c.running?'container.stop':'container.start')}>{t(c.running?'停止容器':'启动容器')}</button><button className="danger" disabled={busy} onClick={()=>containerAsk(c,'container.delete')}>{t('删除容器')}</button></>}</div></article>)}</div><section className="apk-locations"><h2>{t('其他数据位置')}</h2><p>{t('添加其他工具使用的 baked 数据目录,不移动原有应用。')}</p><div className="apk-filters"><input value={path} placeholder="/path/to/compatdata/baked" aria-label={t('Lepton 数据目录')} onChange={e=>setPath(e.target.value)}/><input value={contextName} placeholder={t('原容器名称(可选)')} aria-label={t('原容器名称(可选)')} onChange={e=>setContextName(e.target.value)}/><button disabled={busy||!path.trim()} onClick={()=>void perform('root.add',{path,context:contextName}).then(()=>setPath(''))}>{t('添加')}</button></div>{data.roots.map(p=><div className="apk-location" key={p}><code>{p}</code><button disabled={busy} onClick={()=>void perform('root.remove',{path:p})}>{t('移除扫描位置')}</button></div>)}</section></>}
|
||||
{view==='cleanup'&&<><div className="apk-cleanup-heading"><div><h2>{t('数据清理')}</h2><p>{t('存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。')}</p></div><button className="danger" disabled={busy||!selected.length} onClick={()=>setConfirm({operation:'cleanup',items:selected,title:t('确认数据清理'),description:cleanup.filter(i=>selected.includes(i.id)).map(i=>`${i.name} · ${bytes(i.bytes)}`).join('\n')+`\n${t('选中的文件将永久删除,无法撤销。')}`})}><IconTrash size={22}/>{t('清理所选')}</button></div>{cleanup.map(i=><article className="apk-cleanup-row" key={i.id}><Switch checked={selected.includes(i.id)} label={`${t('选择')} ${i.name}`} disabled={busy||i.unknown} onChange={checked=>setSelected(s=>checked?[...s,i.id]:s.filter(v=>v!==i.id))}/><div className="apk-copy"><b>{i.name}</b><p>{t(i.kind==='unknown'?'未知归属的数据':i.kind==='data'?'保留的应用数据':i.kind==='backups'?'恢复备份':(i.kind==='reviews'||i.kind==='uploads')?'过期上传文件':'旧 APK')} · {bytes(i.bytes)}</p>{i.unknown?<small>{t('无法确认归属,不提供自动删除。')}</small>:i.sensitive&&<small>{t('删除后不可恢复')}</small>}</div>{i.restorable&&<button disabled={busy} onClick={()=>setConfirm({operation:'restore',container:contextFor(i.context!),backup:i.name,title:t('恢复容器备份'),description:t('恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。')})}>{t('恢复')}</button>}</article>)}{!cleanup.length&&<p className="empty">{t('没有可清理的数据。')}</p>}</>}
|
||||
{!!data.warnings.length&&<details className="apk-warnings"><summary>{t('部分容器无法读取')}</summary>{data.warnings.map((w,i)=><p key={i}>{w}</p>)}</details>}
|
||||
{details&&!confirm&&!review&&!log&&!job&&dialog(<><header className="apk-dialog-heading"><PluginImage name={details.metadata.name} src={details.metadata.icon} size={64}/><div><h2>{details.metadata.name}</h2><code>{details.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{details.metadata.version} ({details.metadata.versionCode})</dd><dt>{t('容器')}</dt><dd>{contextFor(details.context)?.name??details.context}</dd><dt>{t('空间占用')}</dt><dd>{bytes(details.size)}</dd></dl>{details.pending&&<><p className="error">{t(details.pending)}</p><button disabled={busy} onClick={()=>void perform('reconcile',{app:details.id})}>{t('核对实际安装状态')}</button></>}<div className="apk-actions">{details.installed&&details.stateKnown!==false&&!details.steam&&<><button disabled={busy||!details.metadata.activities.length&&!details.activity} onClick={()=>void perform('launch',{app:details.id})}>{t('打开')}</button><button disabled={busy} onClick={()=>void perform('close',{app:details.id})}><IconPlayerStop size={20}/>{t('关闭应用')}</button><button disabled={busy} onClick={()=>choose(details)}>{t('安装新版 APK')}</button><button className="danger" disabled={busy} onClick={()=>setConfirm({operation:'clear',app:details,title:t('清除应用数据'),description:t('将永久删除该应用的存档和设置,无法撤销。')})}>{t('清除应用数据')}</button></>}{!details.installed&&details.stateKnown!==false&&!details.steam&&<button disabled={busy} onClick={()=>choose(details)}>{t('重新安装 APK')}</button>}<button disabled={busy} onClick={()=>void showLog(details)}><IconFileText size={20}/>{t('日志')}</button><button className="danger" disabled={busy||!!details.pending||details.stateKnown===false||details.steam} onClick={()=>askUninstall(details)}>{t(details.installed?'卸载应用':'清除保留数据')}</button></div>{details.installed&&details.stateKnown!==false&&!details.steam&&<details className="apk-advanced"><summary>{t('高级设置')}</summary><p>{t('默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。')}</p><SwitchRow label={t('自动处理窗口显示')} checked={details.showWindow==null} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked?null:true})}/>{details.showWindow!=null&&<SwitchRow label={t('显示安卓窗口')} checked={details.showWindow} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked})}/>}<p>{t('窗口设置需要重启容器后生效。')}</p><label>{t('启动入口')}<Select label={t('启动入口')} value={details.activity??''} disabled={busy} onChange={activity=>void perform('settings',{app:details.id,activity})} options={[{value:'',label:t('自动选择')},...details.metadata.declaredActivities.map(a=>({value:a,label:a}))]}/></label></details>}<footer><button onClick={()=>setDetails(null)}>{t('完成')}</button></footer></>,t('管理应用'))}
|
||||
{review&&!job&&dialog(<><h2>{t(updating?'确认更新 APK':'确认安装 APK')}</h2><header className="apk-dialog-heading"><PluginImage name={review.metadata.name} src={review.metadata.icon} size={64}/><div><h3>{review.metadata.name}</h3><code>{review.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{updating&&`${updating.metadata.version} → `}{review.metadata.version} ({review.metadata.versionCode})</dd><dt>{t('文件大小')}</dt><dd>{bytes(review.bytes)}</dd><dt>{t('最低 Android SDK')}</dt><dd>{review.metadata.minSdk||t('未声明')}</dd><dt>{t('启动入口')}</dt><dd>{review.metadata.activities.length||t('未发现启动入口')}</dd></dl><label>{t('目标容器')}<Select label={t('目标容器')} value={target} disabled={busy||!!updating} onChange={setTarget} options={[{value:'',label:t('独立容器(默认)')},...data.containers.filter(c=>!c.steam&&(c.id===updating?.context||c.acceptsAdditionalApps!==false||(data.apps.some(a=>a.context===c.id&&a.installed&&a.stateKnown!==false&&a.metadata.package===review.metadata.package)&&!data.apps.some(a=>a.context===c.id&&a.installed&&a.metadata.package!==review.metadata.package)))).map(c=>({value:c.id,label:c.name}))]}/></label>{target&&<p className="banner">{t('操作可能需要停止目标容器,影响其中正在运行的应用。')}</p>}{updating&&<p>{t('同包名更新保留数据;签名不匹配或降级不会自动卸载重装。')}</p>}{updating&&<p className="banner">{t('共享容器的备份需手动恢复;独立侧载容器支持一键恢复。')}</p>}{error&&<p className="error" role="alert">{error}</p>}<footer><button disabled={busy} onClick={()=>void discard()}>{t('取消')}</button><button className="primary" disabled={busy||!!updating&&updating.metadata.package!==review.metadata.package} onClick={()=>void perform('install',{ticket:review.ticket,context:target,app:updating?.id,approve:true})}>{t('确认安装')}</button></footer></>,t('安装 APK'))}
|
||||
{confirm&&!job&&dialog(<><h2>{confirm.title}</h2><p className="apk-confirm-copy">{confirm.description}</p>{confirm.app&&<p><b>{confirm.app.metadata.name}</b><br/><code>{confirm.app.metadata.package}</code></p>}{confirm.operation==='uninstall'&&<SwitchRow label={t('删除应用数据')} description={t('关闭时保留存档和设置。')} checked={!!confirm.purge} disabled={busy} onChange={purge=>setConfirm(c=>c?{...c,purge}:c)}/>}{deletesData&&<p>{t('永久删除的数据无法撤销。')}</p>}{error&&<p className="error">{error}</p>}<footer><button disabled={busy} onClick={()=>setConfirm(null)}>{t('取消')}</button><button className={deletesData||confirm.operation==='uninstall'?'danger':'primary'} disabled={busy} onClick={()=>void perform(confirm.operation,{app:confirm.app?.id,context:confirm.container?.id,backup:confirm.backup,items:confirm.items,purge:confirm.purge,approve:true})}>{t('确认')}</button></footer></>,confirm.title)}
|
||||
{job&&dialog(<><h2>{t(phases[job.phase]??job.phase)}</h2><p>{t('正在处理 APK,请等待实际结果。')}</p><progress value={job.phase==='uploading'?job.received:undefined} max={job.phase==='uploading'?job.total:undefined}/>{job.phase==='uploading'&&<p>{bytes(job.received)} / {bytes(job.total??0)}</p>}{job.cancellable===false?<p>{t('安装已提交,等待 Android 完成;此阶段不能安全取消。')}</p>:<button onClick={()=>{if(job.id==='upload')controller.current?.abort();else void api('job.cancel',{job:job.id}).catch(e=>setError(String(e)));}}>{t('取消')}</button>}</>,t('APK 操作进度'))}
|
||||
{log&&!job&&dialog(<><h2>{t('APK 日志')}</h2><pre>{log.text||t('暂无日志')}</pre><footer><button onClick={()=>setLog(null)}>{t('关闭')}</button><button onClick={downloadLog}>{t('导出日志')}</button></footer></>,t('APK 日志'))}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Framely's existing dark interface, with a quieter file-browser hierarchy. */
|
||||
.frame-files-backdrop{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;padding:28px;background:#090d14b8}
|
||||
.frame-files{--files-muted:#a3adb9;--files-line:#ffffff12;--files-accent:#a8d5f4;width:min(1160px,100%);height:min(690px,calc(100dvh - 56px));display:flex;flex-direction:column;background:#1a2028;color:#edf2f7;border:1px solid #ffffff24;border-radius:18px;box-shadow:0 32px 100px #0008;overflow:hidden;font:18px/1.4 system-ui,-apple-system,sans-serif}
|
||||
.frame-files button{font:inherit;min-height:48px;margin:0;border-radius:10px;white-space:nowrap;transition:background 120ms,color 120ms;border-color:transparent}
|
||||
.frame-files button:focus-visible,.frame-files input:focus-visible{outline:2px solid var(--files-accent);outline-offset:2px}
|
||||
.frame-files button:disabled{opacity:.38}
|
||||
.frame-files header{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:14px 22px;background:#1d242d;border-bottom:1px solid var(--files-line);flex:none}
|
||||
.frame-files-heading{display:flex;align-items:center;gap:14px}.frame-files-heading-icon{display:grid;place-items:center;width:48px;height:48px;border-radius:12px;background:#2a3a49;color:var(--files-accent)}
|
||||
.frame-files header h2{font-size:23px;letter-spacing:-.35px;font-weight:600;line-height:1.2;margin:0 0 5px}.frame-files header small{font-size:15px;color:var(--files-muted)}
|
||||
.frame-files .frame-files-icon-button{width:52px;padding:0;background:transparent;color:#b7c3ce;flex:none}.frame-files .frame-files-icon-button:hover:not(:disabled){background:#ffffff0c;color:#f1f6fb}.frame-files .frame-files-icon-button.active{background:#304456;color:var(--files-accent)}
|
||||
.frame-files-body{display:grid;grid-template-columns:242px minmax(0,1fr);flex:1;min-height:0}
|
||||
.frame-files aside{padding:20px 12px;background:#161c23;border-right:1px solid var(--files-line);overflow:auto}
|
||||
.frame-files-sidebar-label{font-size:14px;font-weight:500;color:var(--files-muted);padding:0 16px;margin:0 0 14px}
|
||||
.frame-files aside button{width:100%;padding:14px 16px;display:flex;align-items:center;justify-content:flex-start;text-align:left;gap:13px;min-height:58px;margin-bottom:5px;background:transparent;color:#b7c1cb;font-size:18px}
|
||||
.frame-files aside button span{min-width:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.frame-files aside button.active{background:#293b4c;color:#c1e4fc}.frame-files aside button:hover:not(:disabled){background:#ffffff09}.frame-files aside button.active:hover:not(:disabled){background:#304456}
|
||||
.frame-files section{min-width:0;min-height:0;padding:16px 22px 10px;display:flex;flex-direction:column;gap:10px}
|
||||
.frame-files-location{display:flex;align-items:center;gap:8px}.frame-files-navigation{display:flex;flex:none;gap:2px}.frame-files-path{display:flex;align-items:center;gap:10px;flex:1;min-width:0;padding:0 8px 0 12px;background:#141a21;border:1px solid #ffffff0f;border-radius:10px;color:var(--files-muted)}
|
||||
.frame-files .frame-files-path input:not([type=checkbox]):not([type=file]){margin:0;min-height:48px;padding:9px 0;flex:1;min-width:0;width:auto;border:0;border-radius:0;background:transparent;color:#ccd6df;font-size:16px;outline-offset:0}
|
||||
.frame-files .frame-files-go{padding:0 9px;min-height:44px;min-width:48px;background:#26303b;color:#c1ccd7;font-size:15px;border-radius:7px}.frame-files .frame-files-go:hover:not(:disabled){background:#364350}
|
||||
.frame-files-toolbar{display:flex;gap:10px;align-items:center}.frame-files-search{display:flex;align-items:center;gap:12px;flex:1;min-width:0;padding:0 15px;background:#141a21;border:1px solid #ffffff12;border-radius:10px;color:#9aabba}
|
||||
.frame-files .frame-files-search input:not([type=checkbox]):not([type=file]){margin:0;padding:10px 0;min-height:48px;min-width:0;border:0;background:transparent;font-size:18px}.frame-files .frame-files-search input::placeholder{color:#9aa9b7}.frame-files-search:focus-within{border-color:#a8d5f477}
|
||||
.frame-files-options-anchor{position:relative}.frame-files-options{position:absolute;right:0;top:calc(100% + 8px);z-index:5;width:330px;padding:12px 18px;background:#252e39;border:1px solid #ffffff24;border-radius:12px;box-shadow:0 12px 35px #0006}.frame-files-options label{display:flex;justify-content:space-between;align-items:center;gap:20px;font-size:17px;margin:0;padding:12px 0}.frame-files-options .ui-switch{min-height:44px}
|
||||
.frame-files-directory{display:flex;justify-content:space-between;align-items:center;padding:8px 4px 2px;min-height:38px}.frame-files-directory h3{margin:0;font-size:21px;font-weight:550;letter-spacing:-.3px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-files-directory small{flex:none;font-size:15px;color:var(--files-muted);margin-left:14px}
|
||||
.frame-files-columns{display:grid;grid-template-columns:minmax(0,1fr) 100px 118px 24px;gap:16px;padding:0 16px 9px;color:var(--files-muted);font-size:14px}.frame-files-columns span:first-child{padding-left:60px}
|
||||
.frame-files-list{flex:1;min-height:0;overflow:auto;padding:2px 2px 4px;scrollbar-gutter:stable}
|
||||
.frame-files .frame-file-row{width:100%;min-height:68px;display:grid;grid-template-columns:44px minmax(0,1fr) 100px 118px 24px;align-items:center;text-align:left;gap:16px;padding:8px 14px;background:transparent;border:1px solid transparent;margin-bottom:3px;border-radius:10px;color:#e2e9ef;white-space:normal}
|
||||
.frame-files .frame-file-row:hover:not(:disabled){background:#ffffff07}.frame-files .frame-file-row.selected{background:#2b4053;border-color:#9bcff42e}.frame-files .frame-file-row.selected:hover:not(:disabled){background:#314a60}
|
||||
.frame-file-symbol{display:grid;place-items:center;width:44px;height:44px;border-radius:10px;background:#252f3a;color:#afbecb}.frame-file-symbol.folder{color:#aecbe0;background:#243442}.frame-file-symbol.apk{color:#b8dcf7;background:#293e52}
|
||||
.frame-file-name{display:flex;flex-direction:column;gap:4px;min-width:0}.frame-file-name b{font-size:18px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-file-name small{font-size:13px;letter-spacing:.2px;color:var(--files-muted)}.frame-file-size,.frame-file-modified{font-size:15px;color:#aab7c3;font-variant-numeric:tabular-nums}.frame-file-check{display:grid;place-items:center;width:24px;height:24px;color:#8ba1b3}.selected .frame-file-check{border-radius:50%;background:var(--files-accent);color:#172635}
|
||||
.frame-files-loading,.frame-files-empty{min-height:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;color:var(--files-muted)}.frame-files-loading p,.frame-files-empty .empty{padding:0;margin:0;font-size:18px}
|
||||
.frame-files footer{border-top:1px solid var(--files-line);background:#1d242d;padding:12px 22px;display:flex;align-items:center;gap:12px;flex:none}
|
||||
.frame-files-selection{flex:1;display:flex;align-items:center;gap:12px;min-width:0;color:#aab7c3;font-size:16px}.frame-files-selection>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-files-selection small{display:block;font-size:13px;color:var(--files-muted);margin-top:3px}.frame-files-selection-icon{width:38px;height:38px;display:grid;place-items:center;background:#27313c;border-radius:10px;flex:none}.frame-files-selection.has-selection{color:#e3edf5}.has-selection .frame-files-selection-icon{background:#2b4053;color:var(--files-accent)}
|
||||
.frame-files .frame-files-cancel{padding:10px 23px;background:#2a333e;color:#d6e1e9}.frame-files .frame-files-cancel:hover:not(:disabled){background:#36424f}.frame-files .frame-files-confirm{min-width:112px;padding:10px 25px;background:var(--files-accent);color:#132637;font-weight:600}.frame-files .frame-files-confirm:hover:not(:disabled){background:#c7e8ff}
|
||||
.frame-files-pages{display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--files-muted)}.frame-files-pages button{font-size:16px;background:#27313c;padding:8px 16px}.frame-files .error{margin:0;display:flex;align-items:center;gap:12px;justify-content:space-between;padding:10px 12px;background:#3b262d;border-radius:8px;font-size:16px}
|
||||
@media(prefers-reduced-motion:no-preference){.frame-files{animation:frame-files-enter 160ms ease-out}@keyframes frame-files-enter{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}}
|
||||
@media(prefers-reduced-motion:reduce){.frame-files button{transition:none}}
|
||||
@media(max-width:1024px){.frame-files-body{grid-template-columns:190px minmax(0,1fr)}.frame-files section{padding:16px}.frame-files-columns{grid-template-columns:minmax(0,1fr) 86px 24px}.frame-files-columns span:nth-child(3){display:none}.frame-files .frame-file-row{grid-template-columns:44px minmax(0,1fr) 86px 24px}.frame-file-modified{display:none}.frame-files aside button{padding:14px 12px;font-size:17px}}
|
||||
@media(max-width:768px){.frame-files-backdrop{padding:12px}.frame-files{height:calc(100dvh - 24px);border-radius:12px}.frame-files-body{grid-template-columns:150px minmax(0,1fr)}.frame-files-navigation{display:none}.frame-files aside{padding:18px 8px}.frame-files aside button{gap:8px;font-size:16px}.frame-files aside button>svg:last-child:not(:first-child){display:none}.frame-files header{padding:16px}.frame-files footer{padding:14px 16px}.frame-files-selection-icon{display:none}.frame-files-options{width:290px}.frame-files .frame-file-row{grid-template-columns:36px minmax(0,1fr) 24px;gap:10px}.frame-file-symbol{width:36px;height:44px}.frame-file-size{display:none}.frame-files-columns{grid-template-columns:minmax(0,1fr) 24px}.frame-files-columns span:nth-child(2){display:none}.frame-files-columns span:first-child{padding-left:46px}}
|
||||
.frame-folder-tree{width:100%;min-width:0}.frame-folder-row{display:flex;align-items:center;min-height:48px;border-radius:8px;margin-bottom:3px;padding-right:8px;color:#b8c6d1}.frame-folder-row.active{background:#293f52;color:#c9e9ff}.frame-files .frame-folder-row>button{margin:0;padding:0;background:transparent;color:inherit;min-height:48px;border:0;border-radius:6px}.frame-files .frame-folder-toggle{width:30px;flex:none;justify-content:center}.frame-files .frame-folder-name{flex:1;min-width:0;display:flex;align-items:center;justify-content:flex-start;gap:10px;text-align:left;font-size:17px}.frame-folder-name span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-files .frame-folder-row>button:hover:not(:disabled){background:#ffffff08}.frame-folder-toggle svg{transition:transform 120ms}.frame-folder-status{display:flex;align-items:center;gap:8px;min-height:36px;font-size:14px;color:var(--files-muted)}.frame-files aside .frame-folder-retry,.frame-files aside .frame-folder-more{width:auto;max-width:100%;font-size:14px;min-height:44px;margin:2px 0 6px 28px;padding:6px 10px;white-space:normal}.frame-files aside .frame-folder-name.active{background:transparent}@media(prefers-reduced-motion:reduce){.frame-folder-toggle svg{transition:none}}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {IconArrowLeft,IconArrowUp,IconFolder,IconFile,IconRefresh,IconSearch,IconX,IconCheck,IconFolderOpen,IconAdjustmentsHorizontal,IconPackage,IconFileCode,IconChevronRight} from '@tabler/icons-react';
|
||||
import {t} from './i18n';
|
||||
import {Switch} from './switch';
|
||||
import {FolderTree} from './folder-tree';
|
||||
import './file-picker.css';
|
||||
type Request={id:string;accept:string[];multiple:boolean;directory:boolean;defaultPath?:string};
|
||||
type Entry={name:string;path:string;directory:boolean;size:number;modified?:number};
|
||||
type Listing={path:string;parent:string|null;roots:{name:string;path:string}[];entries:Entry[];total:number;offset:number;skipped:number};
|
||||
async function files<T>(operation:string,params:unknown,signal?:AbortSignal):Promise<T>{
|
||||
const response=await fetch(`/host/files/${operation}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(params),signal});
|
||||
if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));
|
||||
const data=await response.json();if(data.error)throw new Error(t(data.error));return data.result;
|
||||
}
|
||||
function extensions(accept:string[]){const mapped:Record<string,string[]>={'application/json':['.json'],'application/vnd.android.package-archive':['.apk']};return [...new Set(accept.flatMap(a=>a.split(/[;,]/)).flatMap(a=>a.startsWith('.')?[a.toLowerCase()]:mapped[a]??[]))];}
|
||||
function size(bytes:number){return bytes<1024?`${bytes} B`:bytes<1048576?`${(bytes/1024).toFixed(1)} KiB`:`${(bytes/1048576).toFixed(1)} MiB`;}
|
||||
export function FrameFilePicker(){
|
||||
const [request,setRequest]=useState<Request|null>(null);
|
||||
useEffect(()=>{const open=(e:Event)=>{const detail=(e as CustomEvent<Request>).detail;if(detail?.id&&Array.isArray(detail.accept))setRequest(detail);};window.addEventListener('framely.fileDialog',open);return()=>window.removeEventListener('framely.fileDialog',open);},[]);
|
||||
return request?createPortal(<FileDialog key={request.id} request={request} close={()=>setRequest(null)}/>,document.body):null;
|
||||
}
|
||||
function FileDialog({request,close}:{request:Request;close:()=>void}){
|
||||
const [path,setPath]=useState(()=>{try{return sessionStorage.getItem('framely.filePicker.path')??'';}catch{return '';}}),[address,setAddress]=useState(''),[query,setQuery]=useState(''),[search,setSearch]=useState(''),[hidden,setHidden]=useState(false),[onlySupported,setOnlySupported]=useState(true),[optionsOpen,setOptionsOpen]=useState(false),[offset,setOffset]=useState(0),[listing,setListing]=useState<Listing|null>(null),[selected,setSelected]=useState<Entry[]>([]),[loading,setLoading]=useState(true),[busy,setBusy]=useState(false),[error,setError]=useState(''),[revision,setRevision]=useState(0),[treeRevision,setTreeRevision]=useState(0);
|
||||
const initial=useRef(true),history=useRef<string[]>([]),dialog=useRef<HTMLDivElement>(null),previousFocus=useRef<HTMLElement|null>(null),done=useRef(false),completing=useRef(false),requestSequence=useRef(0),cancelRef=useRef(()=>{});
|
||||
const allowed=extensions(request.accept),supported=(entry:Entry)=>!request.directory&&(!allowed.length||allowed.some(ext=>entry.name.toLowerCase().endsWith(ext)));
|
||||
const entries=listing?.entries.filter(e=>e.directory||!onlySupported||supported(e))??[];
|
||||
async function complete(paths:string[]){if(completing.current)return;completing.current=true;setBusy(true);setError('');try{await files('complete',{id:request.id,paths,directory:request.directory});done.current=true;if(paths.length&&listing){try{sessionStorage.setItem('framely.filePicker.path',listing.path);}catch{}}close();}catch(e){completing.current=false;setError(String(e));setBusy(false);}}
|
||||
cancelRef.current=()=>{void complete([]);};
|
||||
useEffect(()=>{previousFocus.current=document.activeElement as HTMLElement;const root=document.getElementById('root');const wasInert=root?.inert??false;if(root)root.inert=true;dialog.current?.querySelector<HTMLButtonElement>('button')?.focus();const key=(e:Event)=>{if(e instanceof KeyboardEvent){if(e.key==='Escape'){e.preventDefault();e.stopImmediatePropagation();cancelRef.current();}else if(e.key==='Tab'){const nodes=Array.from(dialog.current?.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),[tabindex="0"]')??[]).filter(n=>n.getClientRects().length);if(!nodes.length)return;if(e.shiftKey&&document.activeElement===nodes[0]){e.preventDefault();nodes.at(-1)?.focus();}else if(!e.shiftKey&&document.activeElement===nodes.at(-1)){e.preventDefault();nodes[0].focus();}}}else{e.stopImmediatePropagation();cancelRef.current();}};window.addEventListener('keydown',key,true);window.addEventListener('framely.back',key,true);return()=>{window.removeEventListener('keydown',key,true);window.removeEventListener('framely.back',key,true);if(root)root.inert=wasInert;previousFocus.current?.focus();if(!done.current)void files('complete',{id:request.id,paths:[]}).catch(()=>{});};},[]);
|
||||
useEffect(()=>{const timer=setTimeout(()=>{setQuery(search);setOffset(0);},180);return()=>clearTimeout(timer);},[search]);
|
||||
useEffect(()=>{const abort=new AbortController(),sequence=++requestSequence.current;setLoading(true);setError('');void files<Listing>('list',{path,query,hidden,offset,extensions:onlySupported&&!request.directory?allowed:[],directoryOnly:request.directory},abort.signal).then(data=>{if(sequence!==requestSequence.current)return;initial.current=false;setListing(data);setAddress(data.path);setSelected([]);}).catch(e=>{if(!abort.signal.aborted){if(initial.current&&path){initial.current=false;setPath('');}else setError(String(e));}}).finally(()=>{if(sequence===requestSequence.current&&!abort.signal.aborted)setLoading(false);});return()=>abort.abort();},[path,query,hidden,offset,revision,onlySupported]);
|
||||
function navigate(next:string,back=false){if(listing&&!back&&listing.path!==next)history.current.push(listing.path);setPath(next);setOffset(0);setSearch('');setQuery('');setSelected([]);setRevision(r=>r+1);}
|
||||
function choose(entry:Entry){if(entry.directory){navigate(entry.path);return;}if(!supported(entry))return;setSelected(s=>request.multiple?(s.some(e=>e.path===entry.path)?s.filter(e=>e.path!==entry.path):s.length<64?[...s,entry]:s):[entry]);}
|
||||
const canSelect=request.directory?!!listing&&!loading&&!error:!!selected.length&&!loading&&!error;
|
||||
const title=listing?.path.split('/').filter(Boolean).at(-1)??t('选择文件');
|
||||
return <div className="frame-files-backdrop" onPointerDown={e=>{if(e.target===e.currentTarget)cancelRef.current();}}><div ref={dialog} className="frame-files" role="dialog" aria-modal="true" aria-label={t(request.directory?'选择目录':'选择文件')}>
|
||||
<header><div className="frame-files-heading"><span className="frame-files-heading-icon"><IconFolderOpen size={26} stroke={1.6}/></span><div><h2>{t(request.directory?'选择目录':'选择文件')}</h2><small>{request.directory?t('浏览 Frame 上的目录'):allowed.length?`${t('支持的文件类型')} ${allowed.join(' · ')}`:t('浏览 Frame 上的文件')}</small></div></div><button className="frame-files-icon-button" aria-label={t('关闭')} disabled={busy} onClick={()=>cancelRef.current()}><IconX size={23} stroke={1.6}/></button></header>
|
||||
<div className="frame-files-body"><aside aria-label={t('文件夹树')}><p className="frame-files-sidebar-label">{t('文件夹树')}</p><FolderTree roots={listing?.roots??[]} path={listing?.path??''} hidden={hidden} refresh={treeRevision} onNavigate={navigate} read={(path,offset,hidden,signal)=>files<Listing>('list',{path,offset,hidden,directoryOnly:true},signal)}/></aside><section>
|
||||
<div className="frame-files-location"><div className="frame-files-navigation"><button className="frame-files-icon-button" aria-label={t('返回上一个目录')} disabled={busy||!history.current.length} onClick={()=>navigate(history.current.pop()!,true)}><IconArrowLeft size={22} stroke={1.7}/></button><button className="frame-files-icon-button" aria-label={t('上级目录')} disabled={busy||!listing?.parent} onClick={()=>navigate(listing!.parent!)}><IconArrowUp size={22} stroke={1.7}/></button></div><div className="frame-files-path"><IconFolder size={20} stroke={1.6}/><input aria-label={t('目录路径')} value={address} disabled={busy} onChange={e=>setAddress(e.target.value)} onKeyDown={e=>{if(e.key==='Enter')navigate(address);}}/><button className="frame-files-go" disabled={busy||!address.trim()} onClick={()=>navigate(address)}>{t('前往')}</button></div><button className="frame-files-icon-button" aria-label={t('刷新')} disabled={busy} onClick={()=>{setRevision(r=>r+1);setTreeRevision(r=>r+1);}}><IconRefresh size={22} stroke={1.7}/></button></div>
|
||||
<div className="frame-files-toolbar"><div className="frame-files-search"><IconSearch size={22} stroke={1.6}/><input type="search" aria-label={t('搜索当前目录')} placeholder={t('搜索当前目录')} value={search} disabled={busy} onChange={e=>setSearch(e.target.value)}/></div><div className="frame-files-options-anchor"><button className={`frame-files-icon-button ${optionsOpen?'active':''}`} aria-label={t('显示选项')} aria-expanded={optionsOpen} disabled={busy} onClick={()=>setOptionsOpen(v=>!v)}><IconAdjustmentsHorizontal size={24} stroke={1.6}/></button>{optionsOpen&&<div className="frame-files-options"><label><span>{t('显示隐藏文件')}</span><Switch label={t('显示隐藏文件')} checked={hidden} disabled={busy} onChange={v=>{setOffset(0);setHidden(v);}}/></label>{!!allowed.length&&!request.directory&&<label><span>{t('仅显示支持的文件')}</span><Switch label={t('仅显示支持的文件')} checked={onlySupported} disabled={busy} onChange={v=>{setOffset(0);setOnlySupported(v);}}/></label>}</div>}</div></div>
|
||||
<div className="frame-files-directory"><h3>{title}</h3><small>{loading?t('正在读取目录…'):listing?.total===1?t('1 个项目'):t('{0} 个项目',{0:listing?.total??0})}</small></div>
|
||||
{error&&<p role="alert" className="error">{error}<button onClick={()=>setRevision(r=>r+1)} disabled={busy}>{t('重试')}</button></p>}
|
||||
<div className="frame-files-columns" aria-hidden="true"><span>{t('名称')}</span><span>{t('文件大小')}</span><span>{t('修改时间')}</span><span/></div>
|
||||
<div className="frame-files-list" aria-busy={loading}>{loading?<div className="frame-files-loading" role="status"><IconRefresh size={28}/><p>{t('正在读取目录…')}</p></div>:entries.map(entry=>{const active=selected.some(s=>s.path===entry.path),extension=entry.name.toLowerCase().split('.').at(-1),FileIcon=entry.directory?IconFolder:extension==='apk'?IconPackage:extension==='json'?IconFileCode:IconFile;return <button key={entry.path} title={entry.name} className={`frame-file-row ${active?'selected':''}`} aria-pressed={!entry.directory?active:undefined} disabled={busy||(!entry.directory&&!supported(entry))} onClick={()=>{setOptionsOpen(false);choose(entry);}} onDoubleClick={()=>{if(!entry.directory&&supported(entry))void complete([entry.path]);}}><span className={`frame-file-symbol ${entry.directory?'folder':extension==='apk'?'apk':''}`}><FileIcon size={28} stroke={1.55}/></span><span className="frame-file-name"><b>{entry.name}</b><small>{entry.directory?t('文件夹'):extension==='apk'?'APK':extension?.toUpperCase()}</small></span><span className="frame-file-size">{entry.directory?'':size(entry.size)}</span><span className="frame-file-modified">{entry.modified?new Date(entry.modified*1000).toLocaleDateString(document.documentElement.lang||undefined):''}</span><span className="frame-file-check">{entry.directory?<IconChevronRight size={19} stroke={1.6}/>:active?<IconCheck size={21} stroke={2}/>:null}</span></button>})}{!loading&&!entries.length&&!error&&<div className="frame-files-empty"><IconSearch size={36} stroke={1.4}/><p className="empty">{t('当前目录没有匹配的文件')}</p></div>}</div>
|
||||
{!!listing&&listing.total>200&&<div className="frame-files-pages"><button disabled={busy||loading||offset===0} onClick={()=>setOffset(v=>Math.max(0,v-200))}>{t('上一页')}</button><span>{offset+1}-{Math.min(offset+200,listing.total)} / {listing.total}</span><button disabled={busy||loading||offset+200>=listing.total} onClick={()=>setOffset(v=>v+200)}>{t('下一页')}</button></div>}
|
||||
{!!listing?.skipped&&<small>{t('部分项目无法访问,已跳过。')}</small>}
|
||||
</section></div><footer><div className={`frame-files-selection ${canSelect?'has-selection':''}`}><span className="frame-files-selection-icon">{canSelect?<IconCheck size={22}/>:<IconFile size={22} stroke={1.5}/>}</span><span>{request.directory?listing?.path:selected.length===1?selected[0].name:selected.length?t('已选择 {0} 个文件',{0:selected.length}):t('请选择要导入的文件')}{selected.length===1&&<small>{size(selected[0].size)}</small>}</span></div><button className="frame-files-cancel" disabled={busy} onClick={()=>cancelRef.current()}>{t('取消')}</button><button className="frame-files-confirm" disabled={busy||!canSelect} onClick={()=>void complete(request.directory?[listing!.path]:selected.map(e=>e.path))}>{busy?t('正在处理…'):t('选择')}</button></footer>
|
||||
</div></div>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React,{useEffect,useMemo,useRef,useState} from 'react';
|
||||
import {IconChevronRight,IconFolder,IconFolderOpen,IconHome,IconDeviceSdCard,IconRefresh} from '@tabler/icons-react';
|
||||
import {t} from './i18n';
|
||||
type Folder={name:string;path:string;directory:boolean};
|
||||
type Page={entries:Folder[];total:number};
|
||||
type NodeState={children:Folder[];total:number;loading:boolean;error?:string;loaded:boolean};
|
||||
type Root={name:string;path:string};
|
||||
export function FolderTree({roots,path,hidden,refresh,onNavigate,read}:{roots:Root[];path:string;hidden:boolean;refresh:number;onNavigate:(path:string)=>void;read:(path:string,offset:number,hidden:boolean,signal:AbortSignal)=>Promise<Page>}){
|
||||
const rootKey=JSON.stringify(roots),topRoots=useMemo(()=>roots.filter((root,index)=>!roots.some((other,j)=>other.path===root.path?j<index:root.path.startsWith(other.path+'/'))),[rootKey]);
|
||||
const [nodes,setNodes]=useState<Record<string,NodeState>>({}),[expanded,setExpanded]=useState<Set<string>>(new Set());
|
||||
const cache=useRef<Record<string,NodeState>>({}),requests=useRef(new Map<string,AbortController>()),reader=useRef(read),generation=useRef(0);reader.current=read;
|
||||
const update=(key:string,value:NodeState)=>{cache.current={...cache.current,[key]:value};setNodes(cache.current);};
|
||||
async function load(key:string,more=false){const previous=cache.current[key];if(previous?.loading||previous?.loaded&&!more&&!previous.error)return;const controller=new AbortController();requests.current.set(key,controller);const currentGeneration=generation.current,children=more?previous?.children??[]:[];update(key,{children,total:previous?.total??0,loading:true,loaded:previous?.loaded??false});try{const result=await reader.current(key,children.length,hidden,controller.signal);if(controller.signal.aborted||generation.current!==currentGeneration)return;const existing=new Set(children.map(c=>c.path));update(key,{children:[...children,...result.entries.filter(e=>e.directory&&!existing.has(e.path))],total:result.total,loading:false,loaded:true});}catch(e){if(!controller.signal.aborted&&generation.current===currentGeneration)update(key,{children,total:previous?.total??0,loading:false,loaded:false,error:String(e)});}finally{if(requests.current.get(key)===controller)requests.current.delete(key);}}
|
||||
useEffect(()=>{generation.current++;for(const controller of requests.current.values())controller.abort();requests.current.clear();cache.current={};setNodes({});},[hidden,rootKey,refresh]);
|
||||
useEffect(()=>{const root=topRoots.find(root=>path===root.path||path.startsWith(root.path+'/'));if(!root)return;const ancestors=[root.path];let next=root.path;for(const segment of path.slice(root.path.length).split('/').filter(Boolean)){next+='/'+segment;ancestors.push(next);}setExpanded(current=>new Set([...current,...ancestors]));for(const ancestor of new Set([...expanded,...ancestors])){if(topRoots.some(root=>ancestor===root.path||ancestor.startsWith(root.path+'/')))void load(ancestor);}},[path,hidden,rootKey,refresh]);
|
||||
useEffect(()=>()=>{generation.current++;for(const controller of requests.current.values())controller.abort();},[]);
|
||||
function toggle(key:string){const open=!expanded.has(key);setExpanded(current=>{const next=new Set(current);if(open)next.add(key);else next.delete(key);return next;});if(open)void load(key);}
|
||||
function branch(folder:Root,depth:number):React.ReactNode{const label=depth===0?t(folder.name):folder.name,state=nodes[folder.path],open=expanded.has(folder.path),selected=folder.path===path,hasChildren=!state?.loaded||!!state.children.length||path.startsWith(folder.path+'/'),FolderIcon=depth===0?(folder.name==='主目录'?IconHome:folder.path.startsWith('/run/media/')?IconDeviceSdCard:IconFolder):open?IconFolderOpen:IconFolder;
|
||||
const children=[...(state?.children??[])];if(path.startsWith(folder.path+'/')){const name=path.slice(folder.path.length+1).split('/')[0],childPath=folder.path+'/'+name;if(!children.some(child=>child.path===childPath))children.push({name,path:childPath,directory:true});}
|
||||
return <div key={folder.path} role="treeitem" aria-label={label} aria-selected={selected} aria-expanded={hasChildren?open:undefined} aria-level={depth+1}><div className={`frame-folder-row ${selected?'active':''}`} style={{paddingLeft:8+depth*18}}><button className="frame-folder-toggle" aria-label={t(open?'收起 {0}':'展开 {0}',{0:label})} disabled={!hasChildren} onClick={()=>toggle(folder.path)}><IconChevronRight size={17} stroke={1.8} style={{transform:open?'rotate(90deg)':'none',visibility:hasChildren?'visible':'hidden'}}/></button><button className="frame-folder-name" title={label} onClick={()=>onNavigate(folder.path)}><FolderIcon size={21} stroke={1.6}/><span>{label}</span></button></div>{open&&<div role="group">{state?.loading&&!state.children.length&&<div className="frame-folder-status" style={{paddingLeft:30+depth*18}}><IconRefresh size={16}/><span>{t('正在读取')}</span></div>}{children.map(child=>branch(child,depth+1))}{state?.error&&<button className="frame-folder-retry" title={state.error} onClick={()=>void load(folder.path)}>{t('无法读取目录')} · {t('重试')}</button>}{state?.loaded&&state.children.length<state.total&&<button className="frame-folder-more" disabled={state.loading} onClick={()=>void load(folder.path,true)}>{state.loading?t('正在读取'):t('加载更多目录')}</button>}</div>}</div>;
|
||||
}
|
||||
return <div className="frame-folder-tree" role="tree" aria-label={t('文件夹树')}>{topRoots.map(root=>branch(root,0))}</div>;
|
||||
}
|
||||
@@ -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));}
|
||||
+42
-13
@@ -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%}
|
||||
@@ -42,21 +42,50 @@
|
||||
.launcher-shell button:disabled{opacity:.4;cursor:default}.launcher-shell button:not(.launch-icon):focus-visible{outline:2px solid var(--launch-blue);outline-offset:4px}
|
||||
.launcher-shell .modal-backdrop{position:absolute;inset:0;background:#101d2b65;z-index:5;display:grid;place-items:center}.launcher-shell .modal{position:relative;transform:none;inset:auto;width:560px;max-width:90%;margin:0;border-radius:22px}
|
||||
@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(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%}}
|
||||
@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}
|
||||
.launcher-edit-toolbar span,.launcher-edit-toolbar button{display:flex;align-items:center;gap:10px}.launcher-edit-toolbar button{border-radius:32px;padding:10px 20px;background:#a7bac72b;border:1px solid #e8f4ff30;color:#d9e3ec;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b}
|
||||
.launcher-editing .launch-disc{animation:launcher-wiggle .32s ease-in-out infinite alternate}.launcher-editing .launch-item:nth-child(2n) .launch-disc{animation-direction:alternate-reverse}
|
||||
.launch-item-dragged .launch-disc{opacity:.22;animation:none}.launcher-drag-ghost{position:fixed;transform:translate(-50%,-50%) scale(1.1);pointer-events:none;z-index:7;filter:drop-shadow(0 12px 18px #06132066)}
|
||||
.launcher-drag-edge{position:absolute;top:150px;bottom:150px;width:8px;border-radius:8px;background:#b4dbf477;pointer-events:none;z-index:6}.launcher-drag-edge.left{left:max(45px,96px)}.launcher-drag-edge.right{right:45px}
|
||||
.launcher-drag-edge{pointer-events:none;z-index:6}
|
||||
@keyframes launcher-wiggle{from{rotate:-1.4deg}to{rotate:1.4deg}}
|
||||
@media(prefers-reduced-motion:reduce){.launcher-editing .launch-disc{animation:none}}
|
||||
|
||||
.launcher-edit-toolbar button:hover:not(:disabled){background:#e3f1ff26;color:#f3f8fc}
|
||||
|
||||
@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)}
|
||||
|
||||
/* These viewport boundaries also drive icon clamping and dwell paging. */
|
||||
/* Apple-inspired spatial edge shading, not Apple's native material.
|
||||
The invisible 8px boundary remains exact; feathered shading faces the grid. */
|
||||
.launcher-shell .launcher-drag-edge{position:fixed;right:auto;bottom:auto;width:8px;background:transparent;border:0;box-shadow:none}
|
||||
.launcher-drag-edge::before,.launcher-drag-edge::after{content:"";position:absolute;top:8%;bottom:8%;left:-4px;width:76px;pointer-events:none;opacity:.35;transform-origin:left center;transition:opacity 220ms,transform 220ms}
|
||||
.launcher-drag-edge::before{background:radial-gradient(ellipse at 0% 50%,#d9eaf744 0%,#c6e0f021 30%,#b4dbf408 58%,transparent 78%)}
|
||||
.launcher-drag-edge::after{left:-12px;width:62px;background:radial-gradient(ellipse at 20% 50%,#10213252 0%,#10213219 45%,transparent 75%)}
|
||||
.launcher-drag-edge.right::before{left:auto;right:-4px;transform-origin:right center;background:radial-gradient(ellipse at 100% 50%,#d9eaf744 0%,#c6e0f021 30%,#b4dbf408 58%,transparent 78%)}
|
||||
.launcher-drag-edge.right::after{left:auto;right:-12px;transform-origin:right center;background:radial-gradient(ellipse at 80% 50%,#10213252 0%,#10213219 45%,transparent 75%)}
|
||||
.launcher-drag-edge.active::before{opacity:1;transform:scaleX(1.18)}.launcher-drag-edge.active::after{opacity:.8;transform:scaleX(1.12)}
|
||||
@media(prefers-reduced-motion:reduce){.launcher-drag-edge::before,.launcher-drag-edge::after{transition:none}}
|
||||
@media(prefers-reduced-transparency:reduce){.launcher-drag-edge::before{opacity:.8}.launcher-drag-edge.active::before{opacity:1}}
|
||||
+79
-41
@@ -1,14 +1,20 @@
|
||||
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,IconPackageImport} 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 launcherCells(total:number){
|
||||
const counts=[Math.min(Math.max(total-5,0),4),Math.min(total,5),Math.max(total-9,0)];
|
||||
return counts.flatMap((count,row)=>Array.from({length:count},(_,col)=>({row,column:(6-(total<=5?count:row===1?5:4))*2+1+col*4})));
|
||||
}
|
||||
function LauncherArtwork({src,name,kind,fit}:{src?:string|null;name:string;kind:string;fit?:string}){
|
||||
const [failed,setFailed]=useState(false);
|
||||
useEffect(()=>setFailed(false),[src]);
|
||||
@@ -21,33 +27,50 @@ 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;left:number;right:number;top:number;bottom: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||state.current.panning)return;const direction=(e as CustomEvent<number>).detail;const s=state.current;if(drag.current){if(Math.abs(direction)===1){stopEdge();moveDraggedPage(direction);}return;}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);};},[]);
|
||||
useEffect(()=>{const preference=window.matchMedia('(prefers-reduced-motion: reduce)'),report=()=>console.log(`FRAMELY_LAUNCHER_REDUCED_MOTION=${preference.matches?1:0}`);report();preference.addEventListener('change',report);return()=>preference.removeEventListener('change',report);},[]);
|
||||
const surfaceAnimation=useRef<Animation|null>(null);
|
||||
function animateSurface(closing=false){const node=shell.current;if(!node)return;surfaceAnimation.current?.cancel();const reduced=window.matchMedia('(prefers-reduced-motion: reduce)').matches;surfaceAnimation.current=node.animate(closing?[{opacity:1,transform:'translateY(0) scale(1)'},{opacity:0,transform:'translateY(32px) scale(.88)'}]:[{opacity:0,transform:'translateY(44px) scale(.86)'},{opacity:1,transform:'translateY(0) scale(1)'}],{duration:reduced?0:closing?440:580,easing:'cubic-bezier(.4,0,.2,1)',fill:'both'});node.style.pointerEvents=closing?'none':'';}
|
||||
useEffect(()=>{if(!loading)animateSurface();},[loading]);
|
||||
useEffect(()=>{const closing=()=>animateSurface(true);window.addEventListener('framely.launcher.closing',closing);return()=>{window.removeEventListener('framely.launcher.closing',closing);surfaceAnimation.current?.cancel();};},[]);
|
||||
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})));
|
||||
const cells=launcherCells(visible.length);
|
||||
state.current={editing,current,pages,filter,panning:!!panView,items,all};
|
||||
const pageTransition=useRef({current,filter}),pageAnimation=useRef<Animation|null>(null);
|
||||
useLayoutEffect(()=>{const previous=pageTransition.current;pageTransition.current={current,filter};if(previous.current===current&&previous.filter===filter)return;pageAnimation.current?.cancel();if(skipPageAnimation.current){skipPageAnimation.current=false;return;}if(window.matchMedia('(prefers-reduced-motion: reduce)').matches||!pageContent.current)return;const category=previous.filter!==filter,direction=category?Math.sign(categories.findIndex(c=>c.key===filter)-categories.findIndex(c=>c.key===previous.filter)):Math.sign(current-previous.current);pageAnimation.current=pageContent.current.animate([{opacity:0,translate:category?`0 ${direction*110}px`:`${direction*110}px 0`,scale:1},{opacity:1,translate:'0 0',scale:1}],{duration:category?360:300,easing:'cubic-bezier(.22,.65,.24,1)'});},[current,filter]);
|
||||
@@ -55,30 +78,45 @@ 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);}}
|
||||
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);}
|
||||
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;});}}
|
||||
}
|
||||
// Use the same boundaries for feedback, ghost clamping and edge dwell.
|
||||
function dragBounds(){const box=grid.current!.getBoundingClientRect(),leftRail=shell.current?.querySelector('.launcher-filters')?.getBoundingClientRect(),rightRail=shell.current?.querySelector('.launcher-shortcuts')?.getBoundingClientRect();return {left:Math.max(box.left,(leftRail?.right??box.left)+24),right:Math.min(box.right,(rightRail?.left??box.right)-24),top:box.top,bottom:box.bottom};}
|
||||
function dragPosition(x:number,y:number){const bounds=dragBounds(),radius=(shell.current?.querySelector('.launch-disc')?.getBoundingClientRect().width??96)*.55+4;return {...bounds,x:Math.max(bounds.left+radius,Math.min(bounds.right-radius,x)),y};}
|
||||
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,...dragPosition(x,y)});setMenu(null);}
|
||||
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;}});}};
|
||||
function moveDraggedPage(direction:number){const d=drag.current,s=state.current,next=s.current+direction;if(!d||next<0||next>=s.pages)return false;const target=s.items[next*pageSize+(direction>0?0:Math.min(pageSize-1,s.items.length-next*pageSize-1))];if(target)reorder(target.key);d.over=null;setPage(next);return true;}
|
||||
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=()=>{if(!moveDraggedPage(direction)){stopEdge();return;}edgeTimer.current=setTimeout(tick,900);};edgeTimer.current=setTimeout(tick,650);}
|
||||
useEffect(()=>{const move=(e:PointerEvent)=>{const d=drag.current;if(d){const bounds=dragPosition(e.clientX,e.clientY);setGhost(g=>g?{...g,...bounds}: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 inHeight=e.clientY>=bounds.top&&e.clientY<=bounds.bottom;const direction=inHeight?(e.clientX<=bounds.left?-1:e.clientX>=bounds.right?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??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)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&&!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'))setMenu(null);}}>
|
||||
<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:'apk',label:'安装APK',icon:IconPackageImport},{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>}
|
||||
{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>
|
||||
{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>;
|
||||
<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':''} ${cells[index].row===2?'above':''}`} onPointerEnter={()=>setOutside(false)} onPointerLeave={()=>setOutside(true)}>
|
||||
{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>{a.kind==='lepton'&&!a.key.startsWith('steam:')&&<><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.manager.open',{page:'apk'}).then(()=>launcherRequest('apk.selection',{app:a.id})))}><IconAdjustments size={22}/>{t('管理应用')}</button><button role="menuitem" className="danger" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.manager.open',{page:'apk'}).then(()=>launcherRequest('apk.selection',{app:a.id,uninstall:true})))}><IconTrash 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,column:slot}=launcherCells(count)[index];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>}{ghost&&[-1,1].map(direction=><div key={direction} className={`launcher-drag-edge ${direction<0?'left':'right'} ${edge===direction?'active':''}`} style={{left:(direction<0?ghost.left:ghost.right)-4,top:ghost.top,height:ghost.bottom-ghost.top}} aria-hidden="true"/>)}
|
||||
{!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={()=>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>;
|
||||
}
|
||||
+21
-13
@@ -1,8 +1,12 @@
|
||||
import {FrameFilePicker} from './file-picker';
|
||||
import {ManagerSwitcher} from './manager-switcher';
|
||||
import {ApkManager} from './apk-manager';
|
||||
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,15 +24,17 @@ 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.startsWith('#apk')?location.hash.slice(1):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);
|
||||
const apkPanel=managerMode&&tab.startsWith('apk');
|
||||
function switchManager(apk:boolean){setPage(null);setManage(null);setTab(apk?'apk':'installed');history.replaceState(null,'',location.pathname+location.search+(apk?'#apk':''));}
|
||||
async function exportLogs(){
|
||||
setExportingLogs(true);setLogExport('');setError('');
|
||||
try{const v=await api<{name:string;path?:string;data?:string}>('diagnostics.export');
|
||||
@@ -60,8 +66,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','#apk','#apk-containers','#apk-cleanup'].includes(location.hash)){setPage(null);setCatalogTarget(null);setTab(location.hash.startsWith('#apk')?location.hash.slice(1):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 +83,31 @@ 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">
|
||||
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong>{managerMode?<><ManagerSwitcher apk={apkPanel} onChange={switchManager}/>{!apkPanel&&<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?(apkPanel?[['apk',t('应用'),'download'],['apk-containers',t('容器'),'plugins'],['apk-cleanup',t('数据清理'),'settings']]:[['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),history.replaceState(null,'',location.pathname+location.search+(key.startsWith('apk')?'#'+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>{apkPanel?t("Lepton 侧载管理"):<>{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>}</>}
|
||||
{apkPanel&&<ApkManager view={tab==='apk-containers'?'containers':tab==='apk-cleanup'?'cleanup':'apps'} onViewChange={view=>setTab(view==='apps'?'apk':'apk-'+view)}/>}
|
||||
{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 +115,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');
|
||||
createRoot(document.getElementById('root')!).render(<Boundary><App/><FrameFilePicker/></Boundary>);console.log('FRAMELY_VIEW_READY');
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import {t} from './i18n';
|
||||
export function ManagerSwitcher({apk,onChange}:{apk:boolean;onChange:(apk:boolean)=>void}) {
|
||||
return <div className="manager-switcher" role="group" aria-label={t('切换管理面板')}>
|
||||
<button className={!apk?'active':''} aria-pressed={!apk} onClick={()=>onChange(false)}>{t('插件管理')}</button>
|
||||
<button className={apk?'active':''} aria-pressed={apk} onClick={()=>onChange(true)}>{t('APK 管理')}</button>
|
||||
</div>;
|
||||
}
|
||||
+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>}</>;
|
||||
}
|
||||
+20
-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,22 @@ 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}
|
||||
.manager-switcher{display:flex;gap:4px;padding:4px;border:1px solid var(--line);border-radius:8px;background:#121416}.manager-switcher button{background:transparent;border:0;min-height:44px;padding:8px 16px;color:var(--muted);font-size:18px;white-space:nowrap}.manager-switcher button.active{background:#30343a;color:var(--text);border-radius:5px}@media(max-width:700px){.manager .shell-header{flex-wrap:wrap}.manager .shell-header .manager-switcher{order:3;width:100%}.manager-switcher button{flex:1}.manager .brand-wordmark{width:156px}}
|
||||
@@ -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