Compare commits

...
7 Commits
Author SHA1 Message Date
toorux f4886c4de2 fix(installer): handle nonblocking accepted sockets in download tests
Wait for complete bounded HTTP headers in the shared test server, cover delayed fragmented requests, and release installer 0.4.3.
2026-10-18 02:34:04 +08:00
toorux 05e3215204 release: core 0.8.3-preview.3 and installer 0.4.2 2026-10-17 18:12:42 +08:00
toorux dbf37ecd33 fix(installer): allow confirming changed SSH host fingerprints 2026-10-17 18:08:55 +08:00
toorux f301327eb8 feat(plugins): discover GitHub releases alongside catalogs 2026-10-17 18:08:23 +08:00
toorux ed51e913bb fix(network): avoid self-redirect loops on passwordless manager entry 2026-10-17 13:03:55 +08:00
toorux 176c4d0f8b release: 0.8.3-preview.2 2026-10-17 00:57:58 +08:00
toorux 2b43094404 test: update CEF download mocks and Steam artwork cleanup expectations 2026-10-17 00:57:47 +08:00
34 changed files with 2033 additions and 142 deletions

No files matched your search

Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.8.3-preview.1"
version = "0.8.3-preview.3"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.8.3-preview.1"
version = "0.8.3-preview.3"
edition = "2021"
license = "AGPL-3.0-only"
+2
View File
@@ -6,6 +6,8 @@
## Default GitHub Release URL
GitHub topic discovery is also supported without a catalog. See [GitHub plugin discovery and release.json](../github-plugin-discovery.md) for the generated Release asset, legacy compatibility and publishing commands.
A public GitHub plugin repository's **source `manifest.json` may omit `downloadUrl`**. The community database derives it from the registered repository, version and plugin ID:
```text
+22
View File
@@ -0,0 +1,22 @@
# GitHub plugin discovery
[简体中文及完整 JSON 示例](zh-CN/github-plugin-discovery.md)
Framely searches public GitHub repositories with topic `framely-plugin`, excluding forks and archived repositories. Releases appear in the existing plugin library alongside catalogs. One row per plugin ID recommends the highest compatible SemVer; version selection retains different package hashes and origins. Existing install confirmation, runtime-user checks and dependency resolution apply.
Upload a `release.json` asset with `schemaVersion: 1` and a nonempty `plugins` array. Required plugin fields are `id`, `name`, `version`, `apiVersion`, `author`, `description`, `asset`, `size`, `sha256`, and `runAs`. `asset` identifies a `.framely` asset in that Release; size is whole-package bytes and SHA256 is 64 hexadecimal characters. `runAs` is the effective backend/lifecycle user, or explicit `null` for UI-only plugins. Optional presentation, engines and relation fields follow existing catalog metadata. Images may name Release assets or fixed HTTPS URLs. Release tag, prerelease flag and download URLs come from GitHub.
Generate metadata from final, verified packages rather than maintaining hashes manually:
```bash
framely release-json --packages ./release --output ./release/release.json
gh release upload "$TAG" ./release/*
```
The generator also exports packaged images into that directory unless external publishing images are configured. Upload every generated file. Add these commands to your own publishing CI and add the repository topic to opt into discovery. Existing external plugin repositories are not changed by this feature.
Only Releases without `release.json` use legacy compatibility: resolve the tag to a commit SHA, read its root `manifest.json`, require tag/version agreement, match the explicit download URL or `<id>-<version>.framely`, and require GitHub's SHA256 asset digest or `SHA256SUMS`. All available hashes must agree. Malformed new metadata never falls back to legacy metadata. Old clients continue using their existing catalogs; package format is unchanged.
Metadata uses pagination, SemVer sorting, a ten-minute cache, ETag revalidation and a global six-request concurrency limit. Manual refresh revalidates. Retryable network failures may use stale cache with a warning; invalid JSON, certificate errors and ordinary 404s do not. Proxy settings and cancellation are reused. Browsing does not download package bytes.
GitHub sources use canonical repository URLs and stable IDs. A newly discovered source is persisted only when installation is confirmed. Updates, history and dependencies subsequently read its Releases. Disabled sources remain disabled. Installation verifies actual package hash, identity, version, API, engine requirements, runtime user and relations against Release metadata.
+2
View File
@@ -64,6 +64,8 @@ framely catalog --name 'My plugins' --base-url https://example.org/plugins --pac
Archives contain Manifest and hashed payload only, without keys/signatures. Host the main catalog and relative history JSON. Authors can host packages on GitHub Releases. Current first initialization includes community stable/testing sources. Origins remain explicit; same-ID plugins in other sources do not silently replace existing origins. HTTP needs explicit development permission; up to five validated redirects, without HTTPS downgrade.
Alternatively, opt into [GitHub plugin discovery](github-plugin-discovery.md) with topic `framely-plugin` and a generated `release.json` asset. The library merges identical IDs and recommends the highest compatible version, preserving origin/hash choices and installation confirmation. Existing catalogs and older Releases remain supported.
## Hover feedback
Interactive controls automatically receive one hover pulse per entry, excluding disabled/inert controls and movement between their children. Supported elements include buttons, links, inputs, interactive ARIA roles/focusable controls or pointer cursors. Custom controls may mark `data-framely-interactive`. Native feedback uses the current hit controller, 12 ms/120 Hz/amplitude 0.15 with at least 80 ms spacing. Pages cannot target other windows/controllers.
+6 -6
View File
@@ -38,8 +38,8 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven
Device and installer share the repository with independent versions/tags/workflows:
- Device: root Cargo version, `v<version>` (currently `v0.7.1`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.10`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
- Device: root Cargo version, `v<version>` (currently `v0.8.3-preview.3`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.3`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
Versions need not match or ship together. Update the relevant Cargo.lock with version changes. Installer package/macOS versions use the installer version.
@@ -47,11 +47,11 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
```bash
# After committing and pushing the intended source:
git tag v0.7.1
git push origin v0.7.1
git tag v0.8.3-preview.3
git push origin v0.8.3-preview.3
# Independent installer release:
git tag installer-v0.4.1-preview.10
git push origin installer-v0.4.1-preview.10
git tag installer-v0.4.3
git push origin installer-v0.4.3
```
Prerelease suffixes create GitHub Prereleases without Latest. Installer releases are never Latest. Only stable device releases become Latest, keeping default installer/update URLs on the device product. The desktop installer filters device archive names and does not mistake ARM64 installer packages for device runtimes. Download desktop installers from their explicit installer tags.
+2
View File
@@ -6,6 +6,8 @@
## GitHub Release 默认地址
现在也支持通过 GitHub topic 自动发现,无需目录文件。`release.json` 格式、生成命令和旧 Release 兼容规则见 [GitHub 插件自动发现](../github-plugin-discovery.md)。
公开 GitHub 插件仓库的**源码 `manifest.json` 可以省略 `downloadUrl`**。社区数据库从登记的仓库地址、版本和插件 ID 自动生成:
```text
+79
View File
@@ -0,0 +1,79 @@
# GitHub 插件自动发现与 Release 元数据
[English](../github-plugin-discovery.md)
Framely 在现有插件库中搜索带 `framely-plugin` topic 的公开 GitHub 仓库,排除 fork 和 archived 仓库,再读取 Releases。无需 npm 或额外目录文件。现有社区源、自建源继续使用;同 ID 合并为一个列表项,优先推荐语义版本最高的兼容版本。来源、正式版/预览版和历史版本可在原界面选择。同 ID、版本和包哈希相同的条目合并;同版本不同哈希仍保留,版本选项显示短哈希。更换来源继续经过安装确认。
## 新 Release:release.json
每个 Release 上传一个 `release.json` 附件,可描述多个插件。不要放在仓库默认分支来代替 Release 附件。
```json
{
"schemaVersion": 1,
"plugins": [{
"id": "example.plugin",
"name": "Example Plugin",
"version": "1.2.0",
"apiVersion": 1,
"author": "Developer",
"description": "插件简介",
"engines": {"framely": ">=0.8.3"},
"asset": "example.plugin-1.2.0.framely",
"size": 123456,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"runAs": "steamos",
"icon": "example.plugin-1.2.0-icon.png",
"screenshots": [],
"tags": ["工具"],
"details": "详细介绍",
"changelog": "更新记录",
"homepage": "https://github.com/example/plugin",
"dependencies": {},
"optionalDependencies": {},
"conflicts": {},
"exclusiveResources": []
}]
}
```
必填:`id`、`name`、`version`(SemVer)、`apiVersion`、`author`、`description`、`asset`、`size`(整包字节数)、`sha256`(整包 64 位十六进制 SHA256)、`runAs`。有后端或生命周期脚本时,`runAs` 是实际运行用户;纯界面插件为 `null`,不能省略。其他字段遵循现有插件目录格式;图片可以是同一 Release 的附件名或固定 HTTPS 地址。`authorUrl`、`documentationUrl` 也支持现有格式。
`asset` 必须对应同一 Release 的 `.framely` 附件;大小必须与 GitHub 附件资料一致。GitHub 提供 tag、预览版标记及附件下载地址,JSON 无需重复 `downloadUrl`。有 GitHub asset digest/`SHA256SUMS` 时必须与 JSON 一致。声明不合法、缺附件或哈希冲突的 Release 不会作为可安装版本,也不会退回旧格式掩盖错误。
## 自动生成及上传
先完成正常构建、打包和验证,把最终 `.framely` 包放入一个专用发行目录,再运行新版 Framely:
```bash
framely release-json --packages ./release --output ./release/release.json
```
在 Framely 源码目录也可用 `cargo run --locked -- release-json ...`。生成器验证包内文件哈希,读取最终包内清单,自动计算整包 SHA256、字节数、运行用户及依赖。没有 `publish.icon`/非空 `publish.screenshots` 外链时,自动导出包内图标和截图到同一发行目录,JSON 使用这些附件名。拒绝覆盖内容不同的同名发行图片。每个插件 ID 在一次发行中只出现一次。
将生成步骤加入自己的发布 CI,并上传发行目录中的全部包、JSON 和图片,例如已有 GitHub CLI 发布流程中:
```bash
framely release-json --packages ./release --output ./release/release.json
gh release upload "$TAG" ./release/*
```
不要在生成 JSON 后重新打包,也不要覆盖已发布的同版本附件。想加入自动发现,由仓库维护者添加 `framely-plugin` topic。本次 Framely 实现不会替维护者修改外部插件仓库。
## 没有 release.json 的旧 Release
只有完全没有该附件时才采用兼容路径:
1. 将 Release 的 tag 解析为提交 SHA,读取该 SHA 的根目录 `manifest.json`;不读取默认分支。
2. tag 为 `v<version>` 或 `<version>`,且必须与清单版本完全一致。
3. 清单有 `downloadUrl` 时必须精确对应本 Release 的 `.framely` 附件;否则匹配 `<id>-<version>.framely`。
4. 使用 GitHub asset digest 或 Release 的 `SHA256SUMS`;二者都存在必须一致。均缺失则不允许安装,不猜测哈希。可选 `downloadSha256` 也必须一致。
5. 有 `publish` 图片外链时沿用;否则图标使用固定提交 SHA 的 Raw 地址。
旧插件和现有 `catalog.json`/`versions.json` 来源无需迁移,旧客户端仍沿用原有来源,忽略新 Release 附件。`release.json` 只是增加一种发现方式,不替换现有安装包格式和兼容性检查。
## 缓存、安装及失败处理
搜索和 Releases 分页读取,最多各 10 页,每页 100 条;Release 先全部读取再按 SemVer 排序,不依赖 API 返回顺序。达到搜索上限会提示;超过 Release 历史上限会报错,避免错误推荐未完整读取的版本。元数据缓存 10 分钟,携带 ETag,手动刷新重新验证;网络失败时可显示旧缓存并提示,证书、解析和普通 404 等错误不隐藏。所有 GitHub 请求最多同时 6 个,支持取消,复用现有 HTTP/手动 GitHub 代理和自动代理策略。浏览目录不下载插件包。
首次选择 GitHub 插件时来源是规范仓库地址 `https://github.com/<owner>/<repo>`,来源 ID 根据仓库名稳定生成,安装确认成功才写入配置。后续更新、依赖和历史版本从该仓库 Releases 读取;禁用来源不会被自动发现重新启用。安装时再次核对整包哈希及包内 ID、版本、API、Framely 要求、实际运行用户、依赖与冲突;权限和来源切换使用现有确认流程。部分仓库失败不会隐藏其他来源已成功加载的插件。
+1 -1
View File
@@ -79,7 +79,7 @@ framely catalog --name 'My plugins' --base-url https://example.org/plugins --pac
包中包含 manifest.json 和文件载荷,files 记录每个载荷的 SHA256;不生成公钥或签名。只支持 manifest.json 与清单中声明的载荷文件,拒绝未声明的附加文件。
源需静态托管 catalog.json 及相对路径下的 plugins/<ID>/versions.json;插件包可放在作者自己的 GitHub Release。在插件源管理中添加目录完整 URL;当前代码首次初始化加入社区 stable/testing 来源,实际内容以服务器为准。目录条目有版本、下载 URL、SHA256。每个源独立显示,插件记录所选来源;另一个源同 ID 不会静默替换。HTTP 必须用户明确允许;目录和包支持最多五次重定向,HTTPS 不允许降级为 HTTP。
传统源可静态托管 catalog.json 及相对路径下的 plugins/<ID>/versions.json;插件包可放在作者自己的 GitHub Release。在插件源管理中添加目录完整 URL;当前代码首次初始化加入社区 stable/testing 来源,实际内容以服务器为准。目录条目有版本、下载 URL、SHA256。插件库也支持 [GitHub 自动发现及 release.json](github-plugin-discovery.md),同 ID 合并展示并按兼容语义版本推荐,历史版本保留不同来源及哈希。插件记录所选来源,切换来源仍经过安装确认。HTTP 必须用户明确允许;目录和包支持最多五次重定向,HTTPS 不允许降级为 HTTP。
### 悬停反馈
+6 -6
View File
@@ -48,8 +48,8 @@ CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.7.1`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.10`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.8.3-preview.3`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.3`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
@@ -59,12 +59,12 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
```bash
# 提交并推送代码后,选择需要发布的产品
git tag v0.7.1
git push origin v0.7.1
git tag v0.8.3-preview.3
git push origin v0.8.3-preview.3
# 独立发布安装器,不触发 Framely 构建
git tag installer-v0.4.1-preview.10
git push origin installer-v0.4.1-preview.10
git tag installer-v0.4.3
git push origin installer-v0.4.3
```
带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。
+1 -1
View File
@@ -1985,7 +1985,7 @@ dependencies = [
[[package]]
name = "framely-installer"
version = "0.4.1-preview.10"
version = "0.4.3"
dependencies = [
"anyhow",
"base64 0.22.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely-installer"
version = "0.4.1-preview.10"
version = "0.4.3"
edition = "2024"
license = "AGPL-3.0-only"
+2 -2
View File
@@ -16,7 +16,7 @@ Only one installer instance can run per user; additional instances exit immediat
1. Enable developer mode and set a password on Frame. Connect the computer and device to the same LAN.
2. Discovery prioritizes `192.168.*` subnets, with at most 1024 addresses per scan. Name discovery and IP probing run together. Only devices whose hostname is `frame` are listed, with SSH availability indicated. Other devices and addresses without a confirmed name are omitted. Manual IP and SSH port entry is always available.
3. Check the SSH handshake and displayed host fingerprint, then enter credentials. The default user is `steamos`. Changed saved fingerprints cause the connection to be rejected. After verifying a device reinstall, you can remove its entry from `known-hosts.json` in the configuration directory.
3. Check the SSH handshake and displayed host fingerprint, then enter credentials. The default user is `steamos`. If a reinstall changes the host key, the connection page shows both old and new fingerprints. Verify the device and choose “Trust new fingerprint and connect” to continue without editing `known-hosts.json`. Cancellation or failed login preserves the old record; the new fingerprint is saved only after authentication and device checks succeed. A further key change before login stops the connection and requires a fresh SSH check.
4. Select a release or a local `framely-*-linux-arm64.tar.gz` together with its external `SHA256SUMS` or matching `.sha256` file. Local packages do not require GitHub access. Prereleases are hidden by default.
5. Choose an operation on Install & Maintenance and confirm the device, version and action in the modal dialog. Cancel or press Esc to return without executing. Errors and operation results also appear in dialogs. SSH fingerprint verification stays on the connection page. The installer does not disable SteamOS root filesystem read-only protection; it stops with an error when required directories are not writable.
@@ -64,7 +64,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
Preview requires an available GPU or software Vulkan renderer. Default release builds do not include preview support.
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.10`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.3`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
Actions builds Linux x64/ARM64, Windows x64, and macOS Intel/Apple Silicon. macOS produces an `.app` ZIP, Windows an EXE ZIP, and Linux a tar.gz. Apple notarization and Windows code signing are not configured; verify first-launch behavior on each system before publishing.
+2 -2
View File
@@ -16,7 +16,7 @@ Rust + GPUI Kit 的桌面安装器,连接 Steam Frame,安装、更新、修
1. 在 Frame 开启开发者模式并设置密码;电脑和设备连接同一局域网。
2. 默认优先扫描 `192.168.*` 网段(每次最多 1024 个地址)。名称发现和 IP 探测同时开始;只列出主机名为 `frame` 的设备,并标注 SSH 是否可用。其他设备和无法确认名称的地址不会显示。手动 IP 和 SSH 端口始终可用。
3. 检查 SSH 握手,核实显示的主机指纹,再输入账号密码登录。用户名默认 `steamos`。已保存的指纹发生变化时拒绝连接;核实设备重装后可手动移除配置目录中的对应 `known-hosts.json` 条目。
3. 检查 SSH 握手,核实显示的主机指纹,再输入账号密码登录。用户名默认 `steamos`。设备重装等情况导致指纹变化时,连接页显示旧、新指纹,核实设备身份后点击“信任新指纹并连接”即可继续,无需手动修改 `known-hosts.json`。取消或登录失败保留原记录;登录及设备检查成功后才保存新指纹。登录前若指纹再次变化,会停止并要求重新检查 SSH。
4. 从 Releases 选择版本,或选择本地 `framely-*-linux-arm64.tar.gz` 和压缩包外部的 `SHA256SUMS`(也可选择对应 `.sha256` 文件)。本地包不需要访问 GitHub。默认隐藏测试版。
5. 在“安装与维护”选择操作,在弹窗中核对设备、版本和操作后确认;取消或按 Esc 返回,不会执行。错误和操作结果也使用弹窗。SSH 指纹确认仍保留在连接页面。安装器不会解除 SteamOS 根分区的只读保护,所需目录不可写时会报错停止。
@@ -64,7 +64,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
预览依赖可用的 GPU 或软件 Vulkan 渲染器;默认发行构建不包含此预览功能。
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.10`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.3`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
Actions 构建 Linux x64/ARM64、Windows x64、macOS Intel/Apple Silicon。macOS 输出 `.app` ZIP,Windows 输出 EXE ZIP,Linux 输出 tar.gz。当前未配置 Apple 公证或 Windows 代码签名,发布前应分别验证系统的首次启动体验。
+82 -2
View File
@@ -275,7 +275,12 @@ impl Installer {
self.busy = false;
match result {
Ok(probe) => {
self.status = "SSH 握手成功。确认下方设备指纹后登录。".into();
self.status = if probe.changed() {
"设备指纹已变化。若设备已重装,请核对下方新指纹后重新信任并连接。"
} else {
"SSH 握手成功。确认下方设备指纹后登录。"
}
.into();
self.probe = Some(probe);
}
Err(error) => {
@@ -294,6 +299,10 @@ impl Installer {
self.busy = false;
match result {
Ok(connection) => {
if let Some(probe) = &mut self.probe {
probe.previous_fingerprint = Some(probe.fingerprint.clone());
probe.known = true;
}
self.status =
format!("已连接 {}:{}", connection.host, connection.version);
self.chosen_action = connection.installation.default_action().into();
@@ -527,7 +536,7 @@ impl Installer {
self.connection_stage = Some(self.status.clone());
let sender = self.sender.clone();
std::thread::spawn(move || {
let result = remote::connect(credentials, &probe.fingerprint, &mut |stage| {
let result = remote::connect(credentials, &probe, &mut |stage| {
let _ = sender.send(Event::ConnectionStage(stage));
})
.map_err(|e| format!("{e:#}"));
@@ -837,6 +846,75 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
})?;
context.update_window(handle, |_, window, cx| transparent_window_root(window, cx))?;
context.allow_parking();
if std::env::var("FRAMELY_INSTALLER_PREVIEW_CASE").as_deref() == Ok("fingerprint-change") {
context.update_window(handle, |_, window, cx| {
view.update(cx, |view, cx| {
view.page = 0;
view.busy = true;
view.connection = None;
view.sender
.send(Event::Probe(Ok(Probe {
endpoint: "192.168.1.42:22".into(),
fingerprint: "SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".into(),
known: false,
previous_fingerprint: Some(
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into(),
),
})))
.unwrap();
view.poll(window, cx);
assert!(!view.busy && view.probe.as_ref().unwrap().changed());
assert!(view.status.contains("设备指纹已变化"));
});
window.render_frame(cx);
assert!(window.find("connect").visible());
assert!(window.find("cancel-fingerprint-change").visible());
})?;
context.run_until_parked();
context.update_window(handle, |_, window, cx| window.render_frame(cx))?;
context
.capture_screenshot(handle)?
.save(dir.join("installer-fingerprint-change.png"))?;
let pending =
context.update_window(handle, |_, _, cx| view.read(cx).probe.clone().unwrap())?;
context.update_window(handle, |_, window, cx| {
window.click("cancel-fingerprint-change", cx);
window.render_frame(cx);
assert!(view.read(cx).probe.is_none() && !view.read(cx).busy);
assert!(view.read(cx).status.contains("未更改"));
assert!(window.find("probe").visible());
view.update(cx, |view, cx| {
view.probe = Some(pending.clone());
view.sender
.send(Event::Connected(Err("SSH 登录失败".into())))
.unwrap();
view.poll(window, cx);
assert!(view.probe.as_ref().unwrap().changed() && !view.busy);
});
window.close_all_dialogs(cx);
view.update(cx, |view, cx| {
view.prompts.clear();
view.sender
.send(Event::Connected(Ok(Connection {
session: ssh2::Session::new().unwrap(),
host: "192.168.1.42".into(),
user: "steamos".into(),
version: "frame · 0.8.3".into(),
installation: Installation {
present: true,
current_version: Some("0.8.3".into()),
previous_version: None,
},
})))
.unwrap();
view.poll(window, cx);
let probe = view.probe.as_ref().unwrap();
assert!(probe.known && !probe.changed() && !view.busy);
});
})?;
println!("Fingerprint change preview, cancellation and connection state checks passed");
return Ok(());
}
for (page, name) in [
(0, "devices"),
(1, "versions"),
@@ -870,8 +948,10 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
view.devices.clear();
} else if name == "connecting" {
view.probe = Some(Probe {
endpoint: "192.168.1.42:22".into(),
fingerprint: "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into(),
known: false,
previous_fingerprint: None,
});
view.busy = true;
view.sender
+142 -26
View File
@@ -23,8 +23,32 @@ pub struct Credentials {
}
#[derive(Clone, Debug)]
pub struct Probe {
pub endpoint: String,
pub fingerprint: String,
pub known: bool,
pub previous_fingerprint: Option<String>,
}
impl Probe {
pub fn changed(&self) -> bool {
self.previous_fingerprint
.as_ref()
.is_some_and(|old| old != &self.fingerprint)
}
fn confirm(&self, actual: &str, hosts: &Hosts) -> Result<()> {
ensure!(
actual == self.fingerprint,
"SSH 指纹与刚才检查的不一致,请重新检查 SSH 连接"
);
ensure!(
hosts
.keys
.get(&self.endpoint)
.map(|saved| normalized_saved_fingerprint(saved))
== self.previous_fingerprint,
"已保存的 SSH 指纹记录发生变化,请重新检查 SSH 连接"
);
Ok(())
}
}
pub struct Connection {
pub session: Session,
@@ -44,7 +68,9 @@ fn hosts_file() -> Result<PathBuf> {
.join("known-hosts.json"))
}
fn load_hosts() -> Result<Hosts> {
let path = hosts_file()?;
load_hosts_from(&hosts_file()?)
}
fn load_hosts_from(path: &Path) -> Result<Hosts> {
if path.exists() {
Ok(serde_json::from_slice(&fs::read(path)?)?)
} else {
@@ -88,20 +114,24 @@ pub fn probe(host: &str, port: u16) -> Result<Probe> {
let fingerprint = fingerprint(&session)?;
let hosts = load_hosts()?;
let endpoint = format!("{host}:{port}");
if let Some(saved) = hosts.keys.get(&endpoint) {
ensure!(
normalized_saved_fingerprint(saved) == fingerprint,
"SSH 主机指纹发生变化,已阻止连接;请先核实设备身份"
);
}
Ok(Probe {
Ok(observed_probe(endpoint, fingerprint, &hosts))
}
fn observed_probe(endpoint: String, fingerprint: String, hosts: &Hosts) -> Probe {
let previous_fingerprint = hosts
.keys
.get(&endpoint)
.map(|saved| normalized_saved_fingerprint(saved));
let known = previous_fingerprint.as_ref() == Some(&fingerprint);
Probe {
endpoint,
fingerprint,
known: hosts.keys.contains_key(&endpoint),
})
known,
previous_fingerprint,
}
}
pub fn connect(
credentials: Credentials,
expected: &str,
confirmed: &Probe,
progress: &mut dyn FnMut(String),
) -> Result<Connection> {
ensure!(
@@ -118,12 +148,15 @@ pub fn connect(
"密码不能包含换行符"
);
ensure!(!credentials.password.is_empty(), "请输入设备登录密码");
ensure!(
confirmed.endpoint == format!("{}:{}", credentials.host, credentials.port),
"设备地址已变化,请重新检查 SSH 连接"
);
progress("正在建立 SSH 连接…".into());
let session = handshake(&credentials.host, credentials.port).context("无法建立 SSH 连接")?;
ensure!(
fingerprint(&session)? == expected,
"SSH 指纹与刚才检查的不一致"
);
// The Connect button confirms this exact observation, including a changed
// key. Recheck before sending credentials; never accept a second change.
confirmed.confirm(&fingerprint(&session)?, &load_hosts()?)?;
progress("正在验证用户名和密码…".into());
session
.userauth_password(&credentials.user, &credentials.password)
@@ -161,16 +194,7 @@ pub fn connect(
"未安装"
});
progress("正在保存设备指纹…".into());
let path = hosts_file()?;
fs::create_dir_all(path.parent().unwrap())
.context("无法创建指纹配置目录,请检查本机目录权限")?;
let mut hosts = load_hosts()?;
hosts.keys.insert(
format!("{}:{}", credentials.host, credentials.port),
expected.into(),
);
fs::write(path, serde_json::to_vec_pretty(&hosts)?)
.context("无法保存设备指纹,请检查本机配置目录权限")?;
remember_probe(&hosts_file()?, confirmed)?;
Ok(Connection {
session,
host: credentials.host,
@@ -179,6 +203,21 @@ pub fn connect(
installation,
})
}
fn remember_probe(path: &Path, confirmed: &Probe) -> Result<()> {
let parent = path.parent().context("无效的指纹配置路径")?;
fs::create_dir_all(parent).context("无法创建指纹配置目录,请检查本机目录权限")?;
let mut hosts = load_hosts_from(path)?;
confirmed.confirm(&confirmed.fingerprint, &hosts)?;
hosts
.keys
.insert(confirmed.endpoint.clone(), confirmed.fingerprint.clone());
let mut file = tempfile::NamedTempFile::new_in(parent)?;
file.write_all(&serde_json::to_vec_pretty(&hosts)?)?;
file.as_file().sync_all()?;
file.persist(path)
.context("无法保存设备指纹,请检查本机配置目录权限")?;
Ok(())
}
fn read_installation(
session: &Session,
password: &str,
@@ -463,6 +502,83 @@ mod tests {
normalized_saved_fingerprint(&format!("SHA256:{}", hex::encode([1_u8; 32]))),
standard
);
let hosts = Hosts {
keys: BTreeMap::from([("frame:22".into(), format!("SHA256:{}", hex::encode(digest)))]),
};
let probe = observed_probe("frame:22".into(), standard.clone(), &hosts);
assert!(probe.known && !probe.changed());
probe.confirm(&standard, &hosts).unwrap();
}
#[test]
fn changed_fingerprint_can_be_confirmed_without_losing_other_devices() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known-hosts.json");
let old = "SHA256:old";
let new = "SHA256:new";
let hosts = Hosts {
keys: BTreeMap::from([
("frame:22".into(), old.into()),
("other:2222".into(), "SHA256:other".into()),
]),
};
fs::write(&path, serde_json::to_vec(&hosts).unwrap()).unwrap();
let fresh = observed_probe("fresh:22".into(), new.into(), &hosts);
assert!(!fresh.known && !fresh.changed() && fresh.previous_fingerprint.is_none());
let changed = observed_probe("frame:22".into(), new.into(), &hosts);
assert!(!changed.known && changed.changed());
assert_eq!(changed.previous_fingerprint.as_deref(), Some(old));
// Merely inspecting or cancelling leaves the original record intact.
assert_eq!(load_hosts_from(&path).unwrap().keys["frame:22"], old);
assert!(changed.confirm("SHA256:changed-again", &hosts).is_err());
assert_eq!(load_hosts_from(&path).unwrap().keys["frame:22"], old);
changed.confirm(new, &hosts).unwrap();
remember_probe(&path, &changed).unwrap();
let saved = load_hosts_from(&path).unwrap();
assert_eq!(saved.keys["frame:22"], new);
assert_eq!(saved.keys["other:2222"], "SHA256:other");
let next = observed_probe("frame:22".into(), new.into(), &saved);
assert!(next.known && !next.changed());
next.confirm(new, &saved).unwrap();
}
#[test]
fn stale_fingerprint_confirmation_does_not_replace_a_newer_record() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known-hosts.json");
let mut hosts = Hosts {
keys: BTreeMap::from([("frame:22".into(), "SHA256:old".into())]),
};
let pending = observed_probe("frame:22".into(), "SHA256:new".into(), &hosts);
hosts
.keys
.insert("frame:22".into(), "SHA256:confirmed-elsewhere".into());
fs::write(&path, serde_json::to_vec(&hosts).unwrap()).unwrap();
assert!(pending.confirm(&pending.fingerprint, &hosts).is_err());
assert!(remember_probe(&path, &pending).is_err());
assert_eq!(
load_hosts_from(&path).unwrap().keys["frame:22"],
"SHA256:confirmed-elsewhere"
);
}
#[test]
fn confirmed_fingerprint_is_bound_to_the_checked_address_and_port() {
let pending = observed_probe("frame:22".into(), "SHA256:new".into(), &Hosts::default());
for (host, port) in [("other", 22), ("frame", 2222)] {
let mut stages = vec![];
let error = connect(
Credentials {
host: host.into(),
port,
user: "steamos".into(),
password: Zeroizing::new("test".into()),
},
&pending,
&mut |stage| stages.push(stage),
)
.err()
.unwrap();
assert!(error.to_string().contains("设备地址已变化"));
assert!(stages.is_empty());
}
}
#[test]
#[ignore = "Requires an explicitly supplied SSH device and trusted OpenSSH fingerprints"]
@@ -492,7 +608,7 @@ mod tests {
user: "steamos".into(),
password: Zeroizing::new("framely-invalid-password-test-9c45bc39".into()),
},
&probe.fingerprint,
&probe,
&mut |stage| stages.push(stage),
);
let error = match result {
+85 -15
View File
@@ -336,6 +336,9 @@ impl Installer {
this.child(field("扫描范围", &self.cidr, self.busy || self.scanning))
});
let mut connection = panel()
.when(self.probe.as_ref().is_some_and(Probe::changed), |this| {
this.gap_3().p_5()
})
.flex_1()
.min_w_0()
.child(
@@ -391,32 +394,99 @@ impl Installer {
.gap_2()
.items_center()
.child(icon(IconName::ShieldCheck))
.child(div().text_sm().child(if probe.known {
.child(div().text_sm().child(if probe.changed() {
"设备指纹已变化"
} else if probe.known {
"设备指纹与上次一致"
} else {
"首次连接,请核对设备指纹"
})),
)
.when(probe.changed(), |this| {
this.child(muted("设备重装后,请核对并信任新指纹,继续连接。"))
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.flex_none()
.text_xs()
.text_color(rgb(MUTED))
.child("上次指纹"),
)
.child(
div()
.text_size(px(12.))
.text_color(rgb(MUTED))
.child(
probe
.previous_fingerprint
.clone()
.unwrap_or_default(),
),
),
)
})
.child(
div()
.text_size(px(12.))
.text_color(rgb(BLUE))
.child(probe.fingerprint.clone()),
.flex()
.items_center()
.gap_2()
.when(probe.changed(), |this| {
this.child(
div()
.flex_none()
.text_xs()
.text_color(rgb(MUTED))
.child("当前指纹"),
)
})
.child(
div()
.text_size(px(12.))
.text_color(rgb(BLUE))
.child(probe.fingerprint.clone()),
),
),
)
})
.child(
Button::new("connect")
.h(px(42.))
.primary()
.label(if self.connection_stage.is_some() {
"正在连接…"
} else {
"确认指纹并连接"
})
.loading(self.connection_stage.is_some())
.disabled(locked)
.on_click(cx.listener(|view, _, _, cx| view.connect(cx))),
div()
.flex()
.gap_3()
.child(
Button::new("connect")
.flex_1()
.h(px(42.))
.primary()
.label(if self.connection_stage.is_some() {
"正在连接…"
} else if probe.changed() {
"信任新指纹并连接"
} else {
"确认指纹并连接"
})
.loading(self.connection_stage.is_some())
.disabled(locked)
.on_click(cx.listener(|view, _, _, cx| view.connect(cx))),
)
.when(probe.changed(), |this| {
this.child(
Button::new("cancel-fingerprint-change")
.h(px(42.))
.ghost()
.label("取消")
.disabled(locked)
.on_click(cx.listener(|view, _, _, cx| {
view.probe = None;
view.error = None;
view.status = "未更改已保存的设备指纹。".into();
cx.notify();
})),
)
}),
);
} else {
connection = connection.child(
File diff suppressed because it is too large. Load diff
+1
View File
@@ -25,6 +25,7 @@ pub fn get_headers(
) -> Result<ureq::Response> {
get_with_proxy(initial, allow_http, timeout, headers, &Default::default())
}
#[cfg(test)]
pub fn get_with_proxy(
initial: &str,
allow_http: bool,
+58 -9
View File
@@ -47,17 +47,35 @@ pub(crate) fn local_truncated_server() -> (String, std::thread::JoinHandle<()>)
for body in [b"old".as_slice(), b"new-data".as_slice()] {
let deadline = Instant::now() + Duration::from_secs(5);
let mut stream = loop {
if let Ok((stream, _)) = listener.accept() {
break stream;
match listener.accept() {
Ok((stream, _)) => break stream,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) => panic!("local fallback server accept failed: {error}"),
}
assert!(Instant::now() < deadline, "local fallback server timed out");
std::thread::sleep(Duration::from_millis(5));
};
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut request = [0u8; 8192];
let _ = stream.read(&mut request).unwrap();
// Accepted sockets can inherit O_NONBLOCK on macOS. Wait for
// the complete request even when its headers arrive separately.
stream.set_nonblocking(false).unwrap();
let request_deadline = Instant::now() + Duration::from_secs(2);
let mut request = Vec::new();
let mut chunk = [0u8; 1024];
while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") {
let remaining = request_deadline
.checked_duration_since(Instant::now())
.expect("local fallback server request timed out");
stream.set_read_timeout(Some(remaining)).unwrap();
let received = match stream.read(&mut chunk) {
Ok(received) => received,
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => panic!("local fallback server read failed: {error}"),
};
assert!(received > 0, "local fallback server request ended before headers");
request.extend_from_slice(&chunk[..received]);
assert!(request.len() <= 8192, "local fallback server request too large");
}
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\n")
.unwrap();
@@ -84,8 +102,12 @@ pub fn eligible(initial: &str, headers: &[(&str, &str)]) -> bool {
&& url.port_or_known_default() == Some(443)
&& url.username().is_empty() && url.password().is_none()
&& url.fragment().is_none()
// Allow only known public API pagination parameters, never signed URLs.
&& url.query_pairs().all(|(key, value)| matches!(key.as_ref(), "page" | "per_page") && !value.is_empty() && value.bytes().all(|c|c.is_ascii_digit()))
// Only pagination and Framely's fixed public topic search, never arbitrary queries or signed asset addresses.
&& url.query_pairs().all(|(key, value)| {
if matches!(key.as_ref(), "page" | "per_page") { return !value.is_empty() && value.bytes().all(|c| c.is_ascii_digit()); }
url.host_str() == Some("api.github.com") && url.path() == "/search/repositories"
&& matches!((key.as_ref(), value.as_ref()), ("q", "topic:framely-plugin archived:false fork:false") | ("sort", "stars") | ("order", "desc"))
})
&& headers.iter().all(|(key, _)| matches!(key.to_ascii_lowercase().as_str(), "accept" | "accept-encoding" | "user-agent" | "if-none-match" | "if-modified-since" | "cache-control"))
}
fn certificate_error(error: &(dyn std::error::Error + 'static)) -> bool {
@@ -185,11 +207,38 @@ pub fn run<T>(
mod tests {
use super::*;
#[test]
fn local_fallback_server_waits_for_complete_fragmented_headers() {
use std::{io::{Read, Write}, net::TcpStream, time::Duration};
let (address, server) = local_truncated_server();
for expected in [b"old".as_slice(), b"new-data".as_slice()] {
let mut stream = TcpStream::connect(address.trim_start_matches("http://")).unwrap();
// Leave time for accept before sending a partial request. The
// server must wait for the final header delimiter before replying.
std::thread::sleep(Duration::from_millis(25));
stream.write_all(b"GET /asset HTTP/1.1\r\nHost: localhost\r\n").unwrap();
stream.set_read_timeout(Some(Duration::from_millis(50))).unwrap();
let mut byte = [0u8; 1];
let error = stream.read(&mut byte).expect_err("server replied before headers completed");
assert!(matches!(error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut));
stream.write_all(b"\r\n").unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let mut response = Vec::new();
stream.read_to_end(&mut response).unwrap();
let header_end = response.windows(4).position(|bytes| bytes == b"\r\n\r\n").unwrap() + 4;
assert_eq!(&response[header_end..], expected);
}
server.join().unwrap();
}
#[test]
fn github_fallback_only_public_exact_hosts_without_credentials() {
assert!(eligible(
"https://api.github.com/repos/a/b/releases?page=1&per_page=100",
&[]
));
assert!(eligible("https://api.github.com/search/repositories?q=topic%3Aframely-plugin+archived%3Afalse+fork%3Afalse&sort=stars&order=desc&per_page=100&page=1", &[]));
assert!(!eligible("https://api.github.com/search/repositories?q=secret", &[]));
assert!(!eligible("https://github.com/search/repositories?q=topic%3Aframely-plugin+archived%3Afalse+fork%3Afalse", &[]));
for u in [
"https://github.com.evil/a",
"https://foo.github.com/a",
+28 -2
View File
@@ -258,6 +258,28 @@ impl Jobs {
let key = id.clone();
std::thread::spawn(move || {
let result = (|| -> Result<Value> {
let mut request = request.clone();
let mut github_source = None;
let mut github_entry = None;
if let Some(repo) = request["githubRepository"].as_str() {
let mut source = crate::github_plugins::source(repo)?;
let status = ipc::call(&socket, "status", json!({}))?;
let saved: Vec<crate::model::Source> = serde_json::from_value(status["database"]["sources"].clone())?;
if let Some(existing) = saved.iter().find(|s| request["source"].as_str() == Some(s.id.as_str())) {
ensure!(existing.enabled && crate::github_plugins::repository(&existing).is_some_and(|r| r.eq_ignore_ascii_case(repo)), "GitHub source is disabled or changed");
source = existing.clone();
} else {
ensure!(!saved.iter().any(|s| crate::github_plugins::repository(s).is_some_and(|r|r.eq_ignore_ascii_case(repo))), "GitHub source identity changed; refresh the plugin library");
ensure!(request["source"].as_str() == Some(source.id.as_str()), "GitHub source identity mismatch");
}
let proxy = serde_json::from_value(status["database"]["proxy"].clone())?;
let client = crate::github_plugins::Client::system(&proxy, &cancel, false)?;
let entry = client.entries(repo)?.into_iter().find(|entry|
request["pluginId"] == entry.id && request["version"] == entry.version
&& request["sha256"] == entry.sha256 && request["url"] == entry.url).context("GitHub release selection changed or is invalid")?;
request["allowHttp"] = json!(false);
github_source = Some(source); github_entry = Some(entry);
}
jobs.patch(&key, json!({"phase":"downloading"}));
let bytes = if let Some(staged) = staged {
staged
@@ -297,6 +319,10 @@ impl Jobs {
ensure!(cancel.check().is_ok(), "下载已取消");
jobs.patch(&key, json!({"phase":"verifying"}));
let manifest = bytes.manifest()?;
if let Some(entry) = &github_entry {
ensure!(entry.sha256 == bytes.hash, "GitHub release package SHA256 mismatch");
crate::github_plugins::check_package(entry, &manifest)?;
}
if let Some(expected) = request["pluginId"].as_str() {
ensure!(manifest.id == expected, "插件包 ID 与目录不一致");
}
@@ -324,12 +350,12 @@ impl Jobs {
.cloned()
.unwrap_or_else(|| json!({}));
jobs.patch(&key, json!({"phase":"resolving"}));
let batch = match crate::planner::prepare_staged(
let batch = match crate::planner::prepare_staged_sources(
&database,
bytes.clone(),
request["source"].as_str().map(str::to_owned),
choices,
cancel.clone(),
cancel.clone(), github_source.into_iter().collect(),
) {
Ok(batch) => {
info["plan"] = json!(batch.plan);
+21 -1
View File
@@ -6,6 +6,7 @@ mod desktop;
mod diagnostics;
mod file_browser;
mod gamepad;
mod github_plugins;
mod http;
mod ipc;
mod jobs;
@@ -33,7 +34,7 @@ use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD, Engine};
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use std::{fs, path::PathBuf};
use std::{fs, path::{Path, PathBuf}};
#[derive(Parser)]
#[command(version, about = "Framely plugin manager")]
@@ -170,9 +171,27 @@ enum Cmd {
#[arg(long)]
output: PathBuf,
},
/// Generate release.json from finalized plugin packages.
ReleaseJson {
#[arg(long)]
packages: PathBuf,
#[arg(long)]
output: PathBuf,
},
}
fn run() -> Result<()> {
match Cli::parse().command {
Cmd::ReleaseJson { packages, output } => {
let value = github_plugins::release_json(&packages)?;
let parent = output.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
fs::create_dir_all(parent)?;
use std::io::Write;
let mut file = tempfile::NamedTempFile::new_in(parent)?;
file.write_all(&serde_json::to_vec_pretty(&value)?)?;
file.as_file().sync_all()?;
file.persist(output)?;
Ok(())
}
Cmd::ApkSteam { app, token } => apk::steam_wrapper(&app, &token),
Cmd::ApkNative {
app,
@@ -369,6 +388,7 @@ fn run() -> Result<()> {
let v = package::verify(&bytes)?;
let publish = v.manifest.publish.clone().unwrap_or_default();
entries.push(model::CatalogEntry {
prerelease: !semver::Version::parse(&v.manifest.version).map(|v| v.pre.is_empty()).unwrap_or(true),
run_as: Some(v.manifest.run_as()),
dependencies: v.manifest.dependencies.clone(),
optional_dependencies: v.manifest.optional_dependencies.clone(),
+2
View File
@@ -1043,6 +1043,8 @@ pub struct Catalog {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogEntry {
#[serde(default)]
pub prerelease: bool,
pub id: String,
pub name: String,
pub version: String,
+19 -2
View File
@@ -212,6 +212,7 @@ impl Resolver<'_> {
if let Some(e) = &manifest.engines {
e.check()?;
}
if crate::github_plugins::repository(&source).is_some() { crate::github_plugins::check_package(entry, &manifest)?; }
ensure!(
manifest.id == entry.id && manifest.version == entry.version,
"依赖包 ID 或版本与目录不一致"
@@ -311,19 +312,35 @@ pub fn prepare_staged(
source: Option<String>,
choices: BTreeMap<String, String>,
cancel: crate::jobs::Cancellation,
) -> Result<Prepared> {
prepare_staged_sources(db, bytes, source, choices, cancel, vec![])
}
pub fn prepare_staged_sources(
db: &Database, bytes: std::sync::Arc<crate::package::Staged>, source: Option<String>,
choices: BTreeMap<String, String>, cancel: crate::jobs::Cancellation, additional: Vec<Source>,
) -> Result<Prepared> {
cancel.check()?;
let mut sources = db.sources.clone();
for candidate in additional {
candidate.validate()?;
if let Some(old) = sources.iter().find(|s| s.id == candidate.id) {
ensure!(old.enabled && old.url == candidate.url, "GitHub plugin source is disabled or changed");
} else {
ensure!(sources.len() < 200, "插件源数量超过 200");
sources.push(candidate);
}
}
let manifest = bytes.manifest()?;
if let Some(e) = &manifest.engines {
e.check()?;
}
let root = manifest.id.clone();
if let Some(source) = &source {
ensure!(db.sources.iter().any(|s| &s.id == source), "未知安装来源");
ensure!(sources.iter().any(|s| &s.id == source && s.enabled), "未知安装来源");
}
let mut resolver = Resolver {
db,
sources: db.sources.clone(),
sources,
catalogs: BTreeMap::new(),
histories: Default::default(),
selected: BTreeMap::new(),
+144 -37
View File
@@ -77,6 +77,23 @@ fn push(q: &Mutex<VecDeque<Value>>, v: Value) {
q.push_back(v);
}
impl Agent {
fn send_page(&self, r: Request, remote: bool, guest: bool, network_cookie: Option<String>) -> Result<()> {
let data = fs::read(self.assets.join("index.html"))?;
let data = if remote {
String::from_utf8(data)?.replacen("<head>", "<head><meta name=\"framely-remote\" content=\"true\">", 1).into_bytes()
} else { data };
let data = if guest {
String::from_utf8(data)?.replacen("<head>", "<head><meta name=\"framely-cast-guest\" content=\"true\">", 1).into_bytes()
} else { data };
let mut response = Response::from_data(data)
.with_header(header("Content-Type", "text/html; charset=utf-8"))
.with_header(header("Cache-Control", "no-store"))
.with_header(header("Referrer-Policy", "no-referrer"))
.with_header(header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
if let Some(cookie) = network_cookie { response = response.with_header(header("Set-Cookie", &cookie)); }
r.respond(response)?;
Ok(())
}
fn watch_cookie(r: &Request) -> Option<String> {
r.headers().iter().filter(|h| h.field.equiv("Cookie"))
.flat_map(|h| h.value.as_str().split(';'))
@@ -283,18 +300,18 @@ impl Agent {
&& r.method() == &Method::Get
&& (path == "/" || path == "/manager" || path == "/cast/watch")
{
r.respond(
Response::empty(StatusCode(302))
.with_header(header("Location", if path == "/cast/watch" { "/cast/watch" } else { "/manager" }))
.with_header(header(
"Set-Cookie",
&format!(
"framely-network={}; HttpOnly; SameSite=Strict; Path=/",
self.network_key.lock().unwrap()
),
)),
)?;
return Ok(());
// A cross-site redirect chain may withhold Strict cookies on
// every hop. Commit a page instead of redirecting to itself;
// subsequent same-site asset/API requests carry the cookie.
let cookie = format!("framely-network={}; HttpOnly; SameSite=Strict; Path=/", self.network_key.lock().unwrap());
if path == "/" {
r.respond(Response::empty(StatusCode(302))
.with_header(header("Location", "/manager"))
.with_header(header("Set-Cookie", &cookie))
.with_header(header("Cache-Control", "no-store")))?;
return Ok(());
}
return self.send_page(r, true, false, Some(cookie));
}
if path == "/setup" && r.method() == &Method::Post {
ensure!(
@@ -313,7 +330,7 @@ impl Agent {
match self.core("network.password.setup", json!({"password":fields.get("password"),"confirmPassword":fields.get("confirmPassword")})) {
Ok(_) => {
*self.network_key.lock().unwrap() = random_key();
r.respond(Response::empty(StatusCode(303)).with_header(header("Location", "/manager")))?;
r.respond(Response::empty(StatusCode(303)).with_header(header("Location", "/manager")).with_header(header("Cache-Control", "no-store")))?;
}
Err(error) => {
r.respond(Response::from_string(format!("{error}。请返回重试。"))
@@ -368,6 +385,7 @@ impl Agent {
if verified && key_before == *self.network_key.lock().unwrap() {
r.respond(
Response::empty(StatusCode(302))
.with_header(header("Cache-Control", "no-store"))
.with_header(header("Location", if destination { "/cast/watch" } else { "/manager" }))
.with_header(header(
"Set-Cookie",
@@ -387,7 +405,7 @@ impl Agent {
return Ok(());
}
if r.method() == &Method::Get && (path == "/" || path == "/manager" || path == "/cast/watch") {
r.respond(Response::from_string(if configured { if path == "/cast/watch" { NETWORK_LOGIN.replace("<form method=\"post\" action=\"/login\">", "<form method=\"post\" action=\"/login\"><input type=\"hidden\" name=\"next\" value=\"/cast/watch\">") } else { NETWORK_LOGIN.into() } } else { NETWORK_SETUP.into() }).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'")))?;
r.respond(Response::from_string(if configured { if path == "/cast/watch" { NETWORK_LOGIN.replace("<form method=\"post\" action=\"/login\">", "<form method=\"post\" action=\"/login\"><input type=\"hidden\" name=\"next\" value=\"/cast/watch\">") } else { NETWORK_LOGIN.into() } } else { NETWORK_SETUP.into() }).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Cache-Control","no-store")).with_header(header("Content-Security-Policy","default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'")))?;
} else {
r.respond(
Response::empty(StatusCode(401))
@@ -398,7 +416,7 @@ impl Agent {
}
if remote && r.method() == &Method::Get && path == "/" {
r.respond(
Response::empty(StatusCode(302)).with_header(header("Location", "/manager")),
Response::empty(StatusCode(302)).with_header(header("Location", "/manager")).with_header(header("Cache-Control", "no-store")),
)?;
return Ok(());
}
@@ -577,6 +595,7 @@ impl Agent {
if let Some(key) = path.strip_prefix("/boot/") {
ensure!(key == self.web_key, "Invalid boot token");
let response = Response::empty(StatusCode(302))
.with_header(header("Cache-Control", "no-store"))
.with_header(header(
"Set-Cookie",
&format!(
@@ -956,22 +975,7 @@ impl Agent {
|| path == "/launcher"
|| path.starts_with("/window/")
{
let data = fs::read(self.assets.join("index.html"))?;
let data = if remote {
String::from_utf8(data)?
.replacen(
"<head>",
"<head><meta name=\"framely-remote\" content=\"true\">",
1,
)
.into_bytes()
} else {
data
};
let data = if guest { String::from_utf8(data)?.replacen("<head>", "<head><meta name=\"framely-cast-guest\" content=\"true\">", 1).into_bytes() } else { data };
let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Cache-Control","no-store")).with_header(header("Referrer-Policy","no-referrer")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
r.respond(response)?;
return Ok(());
return self.send_page(r, remote, guest, None);
}
r.respond(Response::from_string("Not found").with_status_code(404))?;
Ok(())
@@ -1438,6 +1442,7 @@ impl Agent {
}
"catalog" => self.catalog(),
"catalog.start" => {
let refresh = p["refresh"].as_bool().unwrap_or(false);
let status = self.core("status", json!({}))?;
let sources: Vec<Source> =
serde_json::from_value(status["database"]["sources"].clone())?;
@@ -1446,11 +1451,11 @@ impl Agent {
let kind = format!(
"catalog:{}",
crate::package::digest(&serde_json::to_vec(
&json!({"sources":sources,"proxy":proxy})
&json!({"sources":sources,"proxy":proxy,"refresh":refresh})
)?)
);
self.jobs.task_progress(&kind, move |cancel, progress| {
fetch_catalogs_progress(sources, proxy, cache, &cancel, progress.as_ref())
fetch_library(sources, proxy, cache, &cancel, progress.as_ref(), refresh)
})
}
"catalog.versions.start" => {
@@ -1463,10 +1468,16 @@ impl Agent {
let status = self.core("status", json!({}))?;
let sources: Vec<Source> =
serde_json::from_value(status["database"]["sources"].clone())?;
ensure!(!sources.iter().any(|s| s.id == source_id && !s.enabled), "插件源不存在或已停用");
let source = sources
.into_iter()
.find(|s| s.id == source_id && s.enabled)
.context("插件源不存在或已停用")?;
.map(Ok).unwrap_or_else(|| {
let repo = p["githubRepository"].as_str().context("插件源不存在或已停用")?;
let source = crate::github_plugins::source(repo)?;
ensure!(source.id == source_id, "GitHub source identity mismatch");
Ok::<_, anyhow::Error>(source)
})?;
let proxy = self.proxy()?;
self.jobs.task(
&format!("versions:{}:{}", source.id, plugin_id),
@@ -1740,11 +1751,11 @@ impl Agent {
fn catalog(&self) -> Result<Value> {
let status = self.core("status", json!({}))?;
let sources: Vec<Source> = serde_json::from_value(status["database"]["sources"].clone())?;
Ok(fetch_catalogs_proxy(
fetch_library(
sources,
serde_json::from_value(status["database"]["proxy"].clone())?,
self.catalog_cache.clone(),
))
self.catalog_cache.clone(), &Default::default(), &|_| {}, false,
)
}
}
pub(crate) fn fetch_catalog_proxy_cancel(
@@ -1754,6 +1765,10 @@ pub(crate) fn fetch_catalog_proxy_cancel(
) -> Result<Catalog> {
cancel.check()?;
source.validate()?;
if let Some(repo) = crate::github_plugins::repository(source) {
let client = crate::github_plugins::Client::system(proxy, cancel, false)?;
return Ok(crate::github_plugins::catalog(&source.name, &client.entries(&repo)?));
}
if proxy.github_auto_fallback {
return crate::http::with_github_fallback(&source.url, &[], proxy, |route| fetch_catalog_proxy_cancel(source, route, cancel));
}
@@ -1794,6 +1809,10 @@ pub(crate) fn fetch_plugin_versions_cancel(
) -> Result<Vec<CatalogEntry>> {
source.validate()?;
valid_id(plugin_id)?;
if let Some(repo) = crate::github_plugins::repository(source) {
let client = crate::github_plugins::Client::system(proxy, cancel, false)?;
return Ok(client.entries(&repo)?.into_iter().filter(|p| p.id == plugin_id).collect());
}
let url = url::Url::parse(&source.url)?.join(&format!("plugins/{plugin_id}/versions.json"))?;
if proxy.github_auto_fallback {
return crate::http::with_github_fallback(url.as_str(), &[], proxy, |route| fetch_plugin_versions_cancel(source, plugin_id, route, cancel));
@@ -1877,6 +1896,7 @@ fn fetch_catalogs(
) -> Value {
fetch_catalogs_proxy(sources, Default::default(), cache)
}
#[cfg(test)]
fn fetch_catalogs_proxy(
sources: Vec<Source>,
proxy: crate::model::ProxySettings,
@@ -1893,6 +1913,49 @@ fn fetch_catalogs_cancel(
) -> Result<Value> {
fetch_catalogs_progress(sources, proxy, cache, cancel, &|_| {})
}
fn fetch_library(
sources: Vec<Source>, proxy: ProxySettings,
cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>, cancel: &crate::jobs::Cancellation,
progress: &(dyn Fn(Value) + Send + Sync), refresh: bool,
) -> Result<Value> {
let client = crate::github_plugins::Client::system(&proxy, cancel, refresh)?;
let (mut result, discovered) = std::thread::scope(|scope| {
let searching = scope.spawn(|| client.discover());
let normal = sources.iter().filter(|s| crate::github_plugins::repository(s).is_none()).cloned().collect();
let result = fetch_catalogs_progress(normal, proxy.clone(), cache.clone(), cancel, progress);
(result, searching.join().unwrap_or_else(|_| Err(anyhow::anyhow!("GitHub discovery failed"))))
});
cancel.check()?;
let mut repositories: Vec<_> = sources.iter().filter(|s| s.enabled && crate::github_plugins::repository(s).is_some()).cloned().collect();
match discovered {
Ok(discovered) => repositories.extend(discovered.into_iter().filter(|s| !sources.iter().any(|old|
old.id == s.id || crate::github_plugins::repository(old).is_some_and(|repo|
crate::github_plugins::repository(s).is_some_and(|new|repo.eq_ignore_ascii_case(&new)))))),
Err(error) => client.warnings.lock().unwrap().push(crate::http::github_fallback::diagnostic(&error)),
}
{
let discovered = repositories;
let values = result.as_mut().map_err(|e| anyhow::anyhow!("{e:#}"))?.as_array_mut().context("Invalid catalog results")?;
for chunk in discovered.chunks(6) {
cancel.check()?;
let batch = std::thread::scope(|scope| chunk.iter().map(|source| {
let client = &client;
scope.spawn(move || match client.entries(&crate::github_plugins::repository(source).unwrap()) {
Ok(entries) => json!({"source":source,"catalog":crate::github_plugins::catalog(&source.name,&entries),"versions":entries,"fetchedAt":crate::service::now_ms()}),
Err(error) => json!({"source":source,"error":crate::http::github_fallback::diagnostic(&error)}),
})
}).collect::<Vec<_>>().into_iter().map(|h| h.join().unwrap_or_else(|_| json!({"error":"GitHub release worker failed"}))).collect::<Vec<_>>());
values.extend(batch);
progress(json!({"completedSources":values.len(),"totalSources":sources.iter().filter(|s|s.enabled && crate::github_plugins::repository(s).is_none()).count()+discovered.len(),"partial":values}));
}
}
let warnings = client.warnings.lock().unwrap();
if !warnings.is_empty() {
result.as_mut().map_err(|e| anyhow::anyhow!("{e:#}"))?.as_array_mut().unwrap().push(json!({"source":{"id":"github-discovery","name":"GitHub","url":"https://github.com/topics/framely-plugin","enabled":true,"allowHttp":false},
"warning":warnings.iter().take(20).cloned().collect::<Vec<_>>().join("\n")}));
}
result
}
fn fetch_catalogs_progress(
sources: Vec<Source>,
proxy: crate::model::ProxySettings,
@@ -3456,6 +3519,50 @@ mod tests {
.set("Origin", "http://other.example")
.send_json(json!({"method":"ui.events"}))
.is_err());
agent.core("agreement.accept", json!({"version":crate::model::AGREEMENT_VERSION,"userAgreement":true,"privacyStatement":true})).unwrap();
agent.core("network.save", json!({"enabled":true,"port":15915,"passwordEnabled":false})).unwrap();
// Reproduce a cross-site navigation whose redirect chain never sends
// Strict cookies. Each direct landing must terminate without a hop.
for path in ["/manager", "/cast/watch"] {
for _ in 0..3 {
let page = client.get(&format!("{origin}{path}"))
.set("Sec-Fetch-Site", "cross-site")
.set("Sec-Fetch-Mode", "navigate")
.call().unwrap();
assert_eq!(page.status(), 200);
assert!(page.header("Location").is_none());
assert_eq!(page.header("Cache-Control"), Some("no-store"));
assert_eq!(page.header("Referrer-Policy"), Some("no-referrer"));
let grant = page.header("Set-Cookie").unwrap();
assert!(grant.contains("HttpOnly") && grant.contains("SameSite=Strict") && grant.contains("Path=/"));
let html = page.into_string().unwrap();
assert!(html.contains("framely-remote"));
assert!(!html.contains("framely-cast-guest"));
}
}
let landing = client.get(&origin).call().unwrap();
assert_eq!(landing.status(), 302);
assert_eq!(landing.header("Location"), Some("/manager"));
assert_eq!(landing.header("Cache-Control"), Some("no-store"));
// Only the landing grants a cookie; it does not authorize other
// cookie-less routes or bypass same-origin checks on later requests.
for path in ["/api", "/manager-api/files"] {
assert!(matches!(client.post(&format!("{origin}{path}"))
.set("Origin", &origin).send_json(json!({"method":"ui.events"})),
Err(ureq::Error::Status(401, _))));
}
assert!(client.post(&format!("{origin}/api"))
.set("Cookie", cookie).set("Origin", "http://other.example")
.send_json(json!({"method":"ui.events"})).is_err());
assert!(client.post(&format!("{origin}/api"))
.set("Cookie", cookie).set("Origin", &origin)
.send_json(json!({"method":"ui.events"})).is_ok());
agent.core("network.save", json!({"enabled":true,"port":15915,"passwordEnabled":true})).unwrap();
let login = client.get(&format!("{origin}/manager")).call().unwrap();
assert_eq!(login.status(), 200);
assert!(login.header("Set-Cookie").is_none());
assert_eq!(login.header("Cache-Control"), Some("no-store"));
assert!(login.into_string().unwrap().contains("action=\"/login\""));
stop.store(true, Ordering::Relaxed);
worker.join().unwrap();
core_worker.join().unwrap();
+2
View File
@@ -34,6 +34,8 @@ The template and bundled SDK use `AGPL-3.0-only`. See Framely's `docs/developer-
For community GitHub registration, source may omit `downloadUrl`; default assets use `<id>-<version>.framely`. Release packages need a temporary Manifest with the derived URL. See Framely's `docs/developer-guide/publishing.md` for commands, Release digests, custom URLs and database window-dimension validation. The direct packaging command above is for core testing and does not establish community registration acceptance.
For automatic GitHub discovery, add topic `framely-plugin` and upload `release.json` alongside each Release's final packages and images. Generate it with `framely release-json --packages ./release --output ./release/release.json`; include all generated files in your publishing workflow. No npm publication or catalog is needed. See Framely's `docs/github-plugin-discovery.md` for metadata and old-release compatibility.
Launcher support requires Framely 0.4.3-preview.2+. `actions.ts` is built separately; edit `engines.framely`, `ui.launch` and `ui.launcherActions` in the manifest. See the bilingual launcher developer guide.
启动台功能要求 Framely 0.4.3-preview.2 或更新版本。`actions.ts` 单独构建;在清单中调整 `engines.framely`、`ui.launch` 和 `ui.launcherActions`,详见中英双语启动台开发文档。
+2
View File
@@ -33,3 +33,5 @@ cargo run --manifest-path "$FRAMELY_REPO/Cargo.toml" --locked -- verify my-plugi
模板及随附 SDK 使用 `AGPL-3.0-only`。完整开发到发布说明见 Framely 的 `docs/zh-CN/developer-guide/README.md`;模板的生命周期回调不会删除用户数据。
用于社区 GitHub Release 登记时,源码可省略 `downloadUrl`,默认附件名为 `<id>-<version>.framely`。发布包要用包含自动生成地址的临时清单打包;完整命令、Release digest 校验、自定义地址和数据库窗口尺寸校验规则见 Framely 的 `docs/zh-CN/developer-guide/publishing.md`。上面的直接打包命令用于本体测试,不代表社区登记已经通过。
需要 GitHub 自动发现时,为仓库添加 `framely-plugin` topic,每个 Release 上传最终包及 `release.json`。使用 `framely release-json --packages ./release --output ./release/release.json` 自动生成元数据和发布图片,将发行目录内所有文件加入自己的发布流程;无需 npm 发布或目录文件。格式及旧 Release 兼容见 Framely 的 `docs/zh-CN/github-plugin-discovery.md`。
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {Store,catalogChoices,githubRepository,Entry,Source,CatalogResult} from '../ui/src/store';
import {configureLanguage} from '../ui/src/i18n';
import '../ui/src/style.css';
configureLanguage('zh-CN',[]);
const source:Source={id:'community',name:'社区插件源',url:'https://example.org/catalog.json',enabled:true,allowHttp:false};
const github:Source={id:'github-fixture',name:'example/plugin',url:'https://github.com/example/plugin',enabled:true,allowHttp:false};
const plugin=(version:string,sha='a',compatible=true):Entry=>({id:'example.test',name:'示例插件',author:'Example',description:'从 GitHub Release 自动发现,与现有插件库合并显示。',version,apiVersion:1,compatible,url:`https://github.com/example/plugin/releases/download/v${version}/plugin.framely`,sha256:sha.repeat(64),runAs:'steamos'});
const versions=[plugin('2.0.0','d',false),plugin('1.1.0-preview.10','b'),plugin('1.1.0-preview.10','c'),plugin('1.0.1','e')];
const data:CatalogResult[]=[{source,catalog:{name:source.name,plugins:[plugin('1.0.0')]}},{source:github,catalog:{name:github.name,plugins:[versions[1]]},versions}];
const requests:any[]=[],inspected:any[]=[];
window.fetch=async(_url,init)=>{
const {method,params}=JSON.parse(String(init?.body));requests.push({method,params});let result:any=true;
if(method==='catalog.start')result={job:'library'};
if(method==='catalog.versions.start')result={job:`history-${params.source}`};
if(method==='job.status')result=params.job==='library'?{id:params.job,phase:'done',result:data}:params.job==='history-community'?{id:params.job,phase:'failed',error:'历史版本请求失败'}:{id:params.job,phase:'done',result:versions};
return new Response(JSON.stringify({result}));
};
const root=createRoot(document.getElementById('root')!);
root.render(<div className="app manager"><header className="shell-header"><strong>framely</strong></header><div className="workspace"><nav><button>已安装</button><button className="active">插件库</button><button>插件源</button></nav><main className="content"><Store sources={[source]} plugins={{'example.test':{source:source.id,manifest:{version:'1.0.0'}}}} busy={false} onSources={()=>{}} onInspect={async request=>{inspected.push(request);}}/></main></div></div>);
const wait=(ms=50)=>new Promise(resolve=>setTimeout(resolve,ms));
async function until(test:()=>any){for(let i=0;i<100;i++){if(test())return;await wait();}throw Error('Timed out: '+document.body.innerText.slice(0,250));}
const click=(selector:string)=>{const button=document.querySelector<HTMLButtonElement>(selector);if(!button)throw Error('Missing '+selector);button.click();};
async function select(label:string,option:string){click(`[aria-label="${label}"]`);await until(()=>!!document.querySelector('[role=option]'));const button=[...document.querySelectorAll<HTMLButtonElement>('[role=option]')].find(b=>b.textContent?.includes(option));if(!button)throw Error('Missing option '+option);button.click();await wait();}
(window as any).runInstallReviewChecks=async()=>{try{
if(catalogChoices(data).length!==5)throw Error('Different hashes were deduplicated');
if(catalogChoices([...data,{source:{...github,id:'duplicate'},catalog:{name:'Duplicate',plugins:[versions[1]]}}]).length!==5)throw Error('Identical package was duplicated');
if(catalogChoices(data,'all','stable')[1].entry.version!=='1.0.1')throw Error('Stable versions were lost');
if(githubRepository({...github,url:'https://github.com.evil/example/plugin'}))throw Error('Similar host accepted');
await until(()=>document.querySelectorAll('.store-card').length===1);
if(document.querySelector('.store-version')?.textContent!=='1.1.0-preview.10')throw Error('Highest compatible version not recommended');
await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_GITHUB_STORE');
await select('版本通道','仅正式版');
if(document.querySelector('.store-version')?.textContent!=='1.0.1')throw Error('Stable filter incorrect');
await select('版本通道','所有版本');click('.store-title');
await until(()=>document.body.textContent?.includes('历史版本暂时不可用'));
click('[aria-label="可选安装版本"]');await wait();
const options=[...document.querySelectorAll<HTMLElement>('[role=option]')];
const duplicates=options.filter(o=>o.textContent?.includes('1.1.0-preview.10'));
if(duplicates.length!==2||duplicates[0].textContent===duplicates[1].textContent)throw Error('Same-version hash choices are ambiguous');
const menu=document.querySelector<HTMLElement>('[role=listbox]')!;menu.scrollIntoView({block:'nearest'});await wait();
const content=document.querySelector<HTMLElement>('.content')!;
if(content.scrollWidth>content.clientWidth+1||menu.scrollWidth>menu.clientWidth+1)throw Error('Version menu overflows horizontally');
await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_GITHUB_STORE_VERSIONS');
options.find(o=>o.textContent?.startsWith('1.0.1'))!.click();await wait();click('.detail-heading button');
if(inspected[0]?.githubRepository!=='example/plugin'||inspected[0]?.sha256!=='e'.repeat(64))throw Error('Install origin/hash not preserved');
click('.back-link');await wait();
const refresh=[...document.querySelectorAll<HTMLButtonElement>('.list-heading button')].find(b=>b.textContent==='刷新')!;refresh.click();
await until(()=>requests.some(r=>r.method==='catalog.start'&&r.params.refresh===true));
if(requests.filter(r=>r.method==='catalog.versions.start').some(r=>r.params.source===github.id&&r.params.githubRepository!=='example/plugin'))throw Error('History origin missing');
if(document.documentElement.scrollWidth>innerWidth+1)throw Error('Page overflows viewport');
console.log('FRAMELY_BRIDGE_PASS');
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}};
until(()=>!!document.querySelector('.store-card')).then(()=>console.log('FRAMELY_VIEW_READY'));
+28 -12
View File
@@ -106,18 +106,34 @@ class CEFRuntimeTests(unittest.TestCase):
requirement = json.loads((core / 'CEF_RUNTIME.json').read_text())
data = (folder / requirement['archive']).read_bytes()
# The downloaded helper imports the shipped bootstrap module.
for bad in (True, False):
store = folder / ('bad-store' if bad else 'download-store')
store.mkdir()
response = io.BytesIO(data[:-1] if bad else data)
with patch('urllib.request.OpenerDirector.open', return_value=response):
if bad:
with self.assertRaises(ValueError):
runtime.prepare(core, store)
self.assertFalse((store / 'cef' / requirement['id']).exists())
else:
cached = runtime.prepare(core, store)
self.assertEqual((cached / 'lib/cef/libcef.so').read_text(), 'browser')
tampered = data[:-1] + bytes([data[-1] ^ 1])
for case, payload, failure, message, attempts in [
('tampered', tampered, ValueError, 'SHA256 mismatch', 1),
('truncated', data[:-1], RuntimeError, 'IncompleteRead', 2),
('valid', data, None, '', 1)]:
with self.subTest(case=case):
store = folder / (case + '-store')
store.mkdir()
# Each request owns a fresh HTTP-like response, including
# the fallback request after a truncated primary body.
def response(*_args, **_kwargs):
result = io.BytesIO(payload)
result.headers = {'Content-Length': str(len(payload))}
return result
with patch.dict('os.environ', {'FRAMELY_GITHUB_AUTO_FALLBACK': '1'}), \
patch('urllib.request.OpenerDirector.open', side_effect=response) as request:
if failure:
with self.assertRaisesRegex(failure, message):
runtime.prepare(core, store)
self.assertFalse((store / 'cef' / requirement['id']).exists())
else:
cached = runtime.prepare(core, store)
self.assertEqual((cached / 'lib/cef/libcef.so').read_text(), 'browser')
self.assertEqual(request.call_count, attempts)
self.assertEqual(request.call_args_list[0].args[0].full_url, requirement['url'])
if attempts == 2:
self.assertEqual(request.call_args_list[1].args[0].full_url,
'https://gh-proxy.com/' + requirement['url'])
def test_runtime_extraction_rejects_links(self):
with tempfile.TemporaryDirectory() as temp:
+3 -1
View File
@@ -31,7 +31,9 @@ const SteamClient={{Apps:{{
def test_clears_every_injected_image_and_shortcut_icon(self):
result = self.run_cleanup('"/owned/./base.apk"')
self.assertTrue(result['ok'])
self.assertEqual(result['calls'], [['clear', 2452903544, kind] for kind in [0, 1, 3, 4]]
# Portrait, hero, transparent logo and landscape are artwork types
# 0..3. The shortcut icon is cleared separately, never as type 4.
self.assertEqual(result['calls'], [['clear', 2452903544, kind] for kind in [0, 1, 2, 3]]
+ [['icon', 2452903544, '']])
def test_refuses_another_shortcut_with_the_same_id(self):
+5
View File
@@ -3,6 +3,11 @@
"locale": "en-US",
"name": "English",
"messages": {
"浏览插件源及 GitHub Releases 中的插件。": "Browse plugins from sources and GitHub Releases.",
"版本通道": "Version channel",
"所有版本": "All versions",
"仅正式版": "Stable only",
"部分 GitHub 版本暂时不可用。": "Some GitHub releases are temporarily unavailable.",
"以文本编辑": "Edit as text",
"文本编辑器": "Text editor",
"纯文本": "Plain text",
+5
View File
@@ -3,6 +3,11 @@
"locale": "zh-CN",
"name": "简体中文",
"messages": {
"浏览插件源及 GitHub Releases 中的插件。": "浏览插件源及 GitHub Releases 中的插件。",
"版本通道": "版本通道",
"所有版本": "所有版本",
"仅正式版": "仅正式版",
"部分 GitHub 版本暂时不可用。": "部分 GitHub 版本暂时不可用。",
"以文本编辑": "以文本编辑",
"文本编辑器": "文本编辑器",
"纯文本": "纯文本",
+22 -14
View File
@@ -9,8 +9,15 @@ import {Icon} from './icons';
import {AppPlaceholder} from './app-placeholder';
export type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:boolean};
export type Presentation={authorUrl?:string|null;documentationUrl?:string|null;homepage?:string|null;icon?:string|null;details?:string;tags?:string[];screenshots?:string[];changelog?:string;dependencies?:Record<string,string|{version:string;source:string}>;optionalDependencies?:Record<string,string|{version:string;source:string}>;conflicts?:Record<string,string>;exclusiveResources?:string[]};
export type Entry=Presentation&{id:string;name:string;author:string;description:string;version:string;apiVersion:number;compatible?:boolean;engines?:{framely?:string};url:string;sha256:string;runAs?:RuntimeUser|null};
export type CatalogResult={source:Source;catalog?:{name:string;plugins:Entry[]};error?:string;cached?:boolean;fetchedAt?:number};
export type Entry=Presentation&{id:string;name:string;author:string;description:string;version:string;apiVersion:number;compatible?:boolean;prerelease?:boolean;engines?:{framely?:string};url:string;sha256:string;runAs?:RuntimeUser|null};
export type CatalogResult={source:Source;catalog?:{name:string;plugins:Entry[]};versions?:Entry[];error?:string;warning?:string;cached?:boolean;fetchedAt?:number};
export type CatalogChoice={entry:Entry;source:Source};
export function githubRepository(s:Source){try{const u=new URL(s.url);return u.protocol==='https:'&&u.hostname==='github.com'&&!u.username&&!u.password&&(!u.port||u.port==='443')&&/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/?$/.test(u.pathname)&&!u.search&&!u.hash?u.pathname.replace(/^\/|\/$/g,''):undefined;}catch{return undefined;}}
export function catalogChoices(data:CatalogResult[],source='all',channel='all',installedSource?:string){
const choices=data.flatMap(c=>(c.versions??c.catalog?.plugins??[]).map(entry=>({entry,source:c.source}))).filter(c=>(source==='all'||c.source.id===source)&&(channel!=='stable'||!c.entry.prerelease&&!versionParts(c.entry.version)?.pre));
choices.sort((a,b)=>(compareVersions(b.entry.version,a.entry.version)??0)||Number(b.source.id===installedSource)-Number(a.source.id===installedSource)||a.source.id.localeCompare(b.source.id));
const seen=new Set<string>();return choices.filter(c=>{const key=JSON.stringify([c.entry.id,c.entry.version,c.entry.sha256]);if(seen.has(key))return false;seen.add(key);return true;});
}
export type Job={id:string;kind:string;phase:string;received:number;verified?:number;total?:number;result?:any;error?:string;completedSources?:number;totalSources?:number;partial?:CatalogResult[]};
export async function runJob(method:string,params:unknown={},onUpdate?:(job:Job)=>void,statusMethod='job.status'):Promise<{job:string;result:any}>{
const started=await api<{job:string}>(method,params);
@@ -38,25 +45,26 @@ export function PluginLinks({plugin}:{plugin:Presentation}){
export function installedIcon(plugin:any){return plugin?.manifest.icon?`/plugin-assets/${plugin.manifest.id}/${plugin.manifest.icon}`:null;}
function versionParts(value:string){const m=value.match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([^+]+))?(?:\+.*)?$/);return m?{parts:[+m[1],+(m[2]??0),+(m[3]??0)],pre:m[4]??''}:null;}
export function compareVersions(a:string,b:string):number|null{const x=versionParts(a),y=versionParts(b);if(!x||!y)return a===b?0:null;for(let i=0;i<3;i++)if(x.parts[i]!==y.parts[i])return Math.sign(x.parts[i]-y.parts[i]);if(x.pre===y.pre)return 0;if(!x.pre)return 1;if(!y.pre)return -1;const p=x.pre.split('.'),q=y.pre.split('.');for(let i=0;i<Math.max(p.length,q.length);i++){if(p[i]===q[i])continue;if(p[i]===undefined)return-1;if(q[i]===undefined)return 1;const pn=/^\d+$/.test(p[i]),qn=/^\d+$/.test(q[i]);if(pn&&qn)return Math.sign(+p[i]-+q[i]);if(pn!==qn)return pn?-1:1;return p[i]<q[i]?-1:1;}return 0;}
function stateFor(p:Entry,installed:any,source:Source){if(p.apiVersion!==1||p.compatible===false)return{label:t("不兼容"),kind:'incompatible'};if(!installed)return{label:t("安装"),kind:'new'};if(installed.source&&installed.source!==source.id)return{label:t("其他来源"),kind:'other'};const diff=compareVersions(p.version,installed.manifest.version);if(diff===0)return{label:t("已安装"),kind:'installed'};if(diff!==null&&diff>0)return{label:t("可更新"),kind:'update'};if(diff!==null&&diff<0)return{label:t("较旧版本"),kind:'older'};return{label:t("不同版本"),kind:'different'};}
function stateFor(p:Entry,installed:any,source:Source){if(p.apiVersion!==1||p.compatible===false)return{label:t("不兼容"),kind:'incompatible'};if(!installed)return{label:t("安装"),kind:'new'};const diff=compareVersions(p.version,installed.manifest.version);if(diff===0&&installed.source&&installed.source!==source.id)return{label:t("其他来源"),kind:'other'};if(diff===0)return{label:t("已安装"),kind:'installed'};if(diff!==null&&diff>0)return{label:t("可更新"),kind:'update'};if(diff!==null&&diff<0)return{label:t("较旧版本"),kind:'older'};return{label:t("不同版本"),kind:'different'};}
export function Store({plugins,sources,onInspect,onSources,busy,target}:{target?:{plugin:string;source?:string}|null;plugins:Record<string,any>;sources:Source[];onInspect:(request:any)=>Promise<void>;onSources:()=>void;busy:boolean}){
const[data,setData]=useState<CatalogResult[]>([]),[loading,setLoading]=useState(false),[error,setError]=useState(''),[query,setQuery]=useState(''),[selectedTags,setSelectedTags]=useState<string[]>([]),[source,setSource]=useState('all'),[filter,setFilter]=useState('all'),[selected,setSelected]=useState<{entry:Entry;source:Source}|null>(null);
const[loadJob,setLoadJob]=useState<Job|null>(null);const loadSequence=useRef(0);
async function load(){const sequence=++loadSequence.current;setLoading(true);setError('');setLoadJob(null);try{const v=await runJob('catalog.start',{},job=>{if(sequence!==loadSequence.current)return;setLoadJob(job);if(Array.isArray(job.partial)&&job.partial.length)setData(job.partial);});if(sequence===loadSequence.current)setData(v.result);}catch(e){if(sequence===loadSequence.current)setError(String(e));}finally{if(sequence===loadSequence.current)setLoading(false);}}
const[loadJob,setLoadJob]=useState<Job|null>(null);const loadSequence=useRef(0);const[channel,setChannel]=useState('all');
async function load(refresh=false){const sequence=++loadSequence.current;setLoading(true);setError('');setLoadJob(null);try{const v=await runJob('catalog.start',{refresh},job=>{if(sequence!==loadSequence.current)return;setLoadJob(job);if(Array.isArray(job.partial)&&job.partial.length)setData(job.partial);});if(sequence===loadSequence.current)setData(v.result);}catch(e){if(sequence===loadSequence.current)setError(String(e));}finally{if(sequence===loadSequence.current)setLoading(false);}}
async function cancelLoad(){if(!loadJob)return;try{await api('job.cancel',{job:loadJob.id});++loadSequence.current;setLoading(false);setLoadJob(null);}catch(e){setError(String(e));}}
useEffect(()=>{void load();},[JSON.stringify(sources)]);
const all=useMemo(()=>data.flatMap(c=>(c.catalog?.plugins??[]).map(entry=>({entry,source:c.source,state:stateFor(entry,plugins[entry.id],c.source)}))),[data,plugins]);
const[history,setHistory]=useState<{key:string;entries:Entry[];loading:boolean;error:string}>({key:'',entries:[],loading:false,error:''});
const all=useMemo(()=>catalogChoices(data,source,channel).map(({entry,source})=>({entry,source,state:stateFor(entry,plugins[entry.id],source)})),[data,plugins,source,channel]);
const[history,setHistory]=useState<{key:string;entries:CatalogChoice[];loading:boolean;error:string}>({key:'',entries:[],loading:false,error:''});
const[historyRetry,setHistoryRetry]=useState(0);
const historyKey=selected?`${selected.source.url}:${selected.entry.id}`:'';
useEffect(()=>{if(!selected)return;let active=true;const key=historyKey;setHistory({key,entries:[],loading:true,error:''});void runJob('catalog.versions.start',{source:selected.source.id,pluginId:selected.entry.id}).then(result=>{if(active)setHistory({key,entries:result.result,loading:false,error:''});}).catch(e=>{if(active)setHistory({key,entries:[],loading:false,error:String(e)});});return()=>{active=false;};},[historyKey,historyRetry]);
const historyKey=selected?`${selected.entry.id}:${data.map(c=>c.source.id).join(',')}`:'';
useEffect(()=>{if(!selected)return;let active=true;const key=historyKey;setHistory({key,entries:[],loading:true,error:''});const candidates=data.filter(c=>c.catalog?.plugins.some(p=>p.id===selected.entry.id));void(async()=>{const entries:CatalogChoice[]=[],errors:string[]=[];for(const c of candidates){if(!active)return;try{const result=await runJob('catalog.versions.start',{source:c.source.id,pluginId:selected.entry.id,githubRepository:githubRepository(c.source)});entries.push(...(result.result as Entry[]).map(entry=>({entry,source:c.source})));}catch(error){errors.push(String(error));}}if(active)setHistory({key,entries,loading:false,error:errors.join('; ')});})();return()=>{active=false;};},[historyKey,historyRetry]);
const focused=useRef<string|null>(null);
useEffect(()=>{if(!target||loading||!data.length||focused.current===JSON.stringify(target))return;focused.current=JSON.stringify(target);const matches=all.filter(p=>p.entry.id===target.plugin);const own=matches.find(p=>p.source.id===target.source);if(own||matches.length===1){const p=own??matches[0];setSelected({entry:p.entry,source:p.source});}else{setQuery(plugins[target.plugin]?.manifest.name??target.plugin);setError(matches.length?t("请选择插件来源,再选择安装版本。"):t("当前插件源未提供此插件的版本,可刷新来源或导入插件包。"));}},[data,loading,target]);
useEffect(()=>{if(!target||loading||!data.length||focused.current===JSON.stringify(target))return;focused.current=JSON.stringify(target);const matches=all.filter(p=>p.entry.id===target.plugin);const own=matches.find(p=>p.source.id===target.source);if(matches.length){const p=matches.find(p=>p.state.kind!=='incompatible')??own??matches[0];setSelected({entry:p.entry,source:p.source});}else{setQuery(plugins[target.plugin]?.manifest.name??target.plugin);setError(matches.length?t("请选择插件来源,再选择安装版本。"):t("当前插件源未提供此插件的版本,可刷新来源或导入插件包。"));}},[data,loading,target]);
const tags=[...new Set(all.flatMap(x=>x.entry.tags??[]))].sort();
const shown=all.filter(x=>`${x.entry.name} ${x.entry.description} ${x.entry.author} ${(x.entry.tags??[]).join(' ')}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())&&(!selectedTags.length||(x.entry.tags??[]).some(tag=>selectedTags.includes(tag)))&&(source==='all'||x.source.id===source)&&(filter==='all'||filter==='installed'&&!!plugins[x.entry.id]||filter==='update'&&x.state.kind==='update'));
const request=(p:Entry,s:Source)=>({url:p.url,sha256:p.sha256,pluginId:p.id,version:p.version,source:s.id,allowHttp:s.allowHttp});
if(selected){const p=selected.entry,s=selected.source,installed=plugins[p.id],state=stateFor(p,installed,s);const latest=all.find(x=>x.entry.id===p.id&&x.source.id===s.id)?.entry??p;const versions=[latest,...(history.key===historyKey?history.entries:[]).filter(v=>v.version!==latest.version)];return <><button className="back-link" onClick={()=>setSelected(null)}><Icon name="arrow"/>{t("返回插件库")}</button><section className="store-detail"><div className="detail-heading"><PluginImage src={p.icon} name={p.name} size={80}/><div><h1>{p.name}</h1><p><PluginAuthor plugin={p}/> · {p.version}</p></div><button className="primary" disabled={busy||['incompatible'].includes(state.kind)} onClick={()=>void onInspect(request(p,s))}>{state.kind==='update'?t("更新"):state.kind==='new'?t("安装"):state.kind==='other'||state.kind==='different'?t("检查此版本"):state.kind==='installed'?t("重新安装"):state.kind==='older'?t("安装所选版本"):state.label}</button></div><RunUserNotice user={p.runAs}/><section className="settings-card">{history.key===historyKey&&history.loading&&<p className="sub" role="status">{t("正在加载历史版本…")}</p>}{history.key===historyKey&&history.error&&<p className="error" role="alert">{t("历史版本暂时不可用,仍可安装最新版。")}<button onClick={()=>setHistoryRetry(v=>v+1)}>{t("重试")}</button></p>}<Select label={t("可选安装版本")} value={p.version} onChange={version=>{const entry=versions.find(v=>v.version===version);if(entry)setSelected({entry,source:s});}} options={versions.map(v=>({value:v.version,label:v.version+(v.version===installed?.manifest.version?t(" · 已安装"):v.compatible===false?t(" · 不兼容"):v===versions[0]?t(" · 推荐版本"):"")}))}/></section><div className="badges"><span>{state.label}</span><span>{s.name}</span>{(p.tags??[]).map(t=><span key={t}>{t}</span>)}</div>{state.kind==='other'&&<p className="banner">{t("当前安装来自其他源,确认安装此版本后会更换来源。")}</p>}<p className="detail-summary">{p.description}</p><PluginLinks plugin={p}/>{p.engines?.framely&&<p className={p.compatible===false?'banner':'sub'}>{t('要求 Framely:{0}',{0:p.engines.framely})}</p>}{!!p.screenshots?.length&&<div className="screenshots">{p.screenshots.map((url,i)=><figure key={url}><img src={url} alt={t("{0}截图 {1}", {"0": p.name, "1": i+1})}/></figure>)}</div>}<h2>{t("关于插件")}</h2><p className="prose">{p.details||p.description||t("开发者尚未提供详细介绍。")}</p><h2>{t("依赖与冲突")}</h2>{Object.entries(p.dependencies??{}).map(([id,d])=><p key={id}>{t("必需:")}{id} · {typeof d==='string'?d:d.version}{typeof d==='object'&&<small className="source-url">{d.source}</small>}</p>)}{Object.entries(p.optionalDependencies??{}).map(([id,d])=><p key={id}>{t("可选:")}{id} · {typeof d==='string'?d:d.version}</p>)}{Object.entries(p.conflicts??{}).map(([id,range])=><p key={id}>{t("冲突:")}{id} · {range}</p>)}{!!p.exclusiveResources?.length&&<p>{t("独占资源:")}{p.exclusiveResources.join(', ')}</p>}<h2>{t("更新记录")}</h2><p className="prose">{p.changelog||t("开发者尚未提供更新记录。")}</p><section className="settings-card"><h2>{t("版本与来源")}</h2><p>{installed?t("本机版本:{0}", {"0": installed.manifest.version}):t("尚未安装")}{t("· 可用版本:")}{p.version}</p><p className="sub">{t("安装前会展示运行用户。插件库资料由插件源提供,以包内的信息为准。")}</p></section></section></>;}
return <><div className="list-heading"><div><h1>{t("插件库")}</h1><p>{t("浏览已添加插件源中的插件。")}</p></div><div className="row"><button onClick={onSources}><Icon name="link"/>{t("管理插件源")}</button><button disabled={loading} onClick={()=>void load()}><Icon name="refresh"/>{loading?t("刷新中…"):t("刷新")}</button></div></div>{loading&&<div className="banner" role="status"><span>{t("插件源加载进度:{0} / {1}",{0:loadJob?.completedSources??0,1:loadJob?.totalSources??sources.filter(s=>s.enabled).length})}</span><button disabled={!loadJob} onClick={()=>void cancelLoad()}>{t("取消加载")}</button></div>}<div className="store-filters"><div className="search-field"><Icon name="search"/><input aria-label={t("搜索插件库")} placeholder={t("搜索名称、作者或标签")} value={query} onChange={e=>setQuery(e.target.value)}/></div><Select multiple label={t("标签")} value={selectedTags} onChange={setSelectedTags} options={tags.map(value=>({value,label:value}))}/><Select label={t("插件源")} value={source} onChange={setSource} options={[{value:'all',label:t("所有来源")},...sources.filter(s=>s.enabled).map(s=>({value:s.id,label:s.name}))]}/><Select label={t("安装状态")} value={filter} onChange={setFilter} options={[{value:"all",label:t("全部状态")},{value:"installed",label:t("已安装")},{value:"update",label:t("可更新")}]}/></div>{error&&<p className="error">{error}<button onClick={()=>void load()}>{t("重试")}</button></p>}{data.filter(c=>c.error).map(c=><div className="banner" key={c.source?.id}><b>{c.source?.name??t("插件源")}{t("暂时不可用")}</b><p>{c.error}{c.cached?t(",正在显示上次成功读取的目录。"):''}</p><button disabled={loading} onClick={()=>void load()}>{t("重新连接")}</button></div>)}{loading&&!all.length?<div className="empty-state"><h2>{t("正在加载插件目录…")}</h2><p>{t("已安装插件仍可正常使用。")}</p></div>:!sources.some(s=>s.enabled)?<div className="empty-state"><Icon name="link" size={48}/><h2>{t("尚未添加插件源")}</h2><p>{t("配置你信任的目录地址,即可浏览和安装插件。")}</p><button className="primary" onClick={onSources}>{t("管理插件源")}</button></div>:!shown.length?<div className="empty-state"><h2>{all.length?t("没有符合条件的插件"):t("目录暂时没有插件")}</h2><p>{all.length?t("试试其他关键词或清除筛选。"):t("可以检查插件源连接,或导入本地插件包。")}</p>{!!all.length&&<button onClick={()=>{setQuery('');setSelectedTags([]);setSource('all');setFilter('all');}}>{t("清除筛选")}</button>}</div>:<><p className="sub">{shown.length}{t("个结果")}</p><div className="store-grid">{shown.map(({entry:p,source:s,state})=><article className="store-card" key={`${s.id}:${p.id}`} onClick={()=>setSelected({entry:p,source:s})}><PluginImage src={p.icon} name={p.name} size={80}/><div className="store-copy"><h2 className="store-title-row"><button className="store-title" onClick={()=>setSelected({entry:p,source:s})}>{p.name}</button><RunUserBadge user={p.runAs}/></h2><p>{p.description||t("查看插件详情")}</p><small><PluginAuthor plugin={p}/> · {s.name}</small></div><span className="store-version">{p.version}</span><span className={`badge ${state.kind==='update'?'accent':''}`}>{state.label}</span><Icon name="arrow"/></article>)}</div></>}</>;
const recommended=new Map<string,typeof all[number]>();for(const item of all){const old=recommended.get(item.entry.id);if(!old||old.state.kind==='incompatible'&&item.state.kind!=='incompatible')recommended.set(item.entry.id,item);}
const shown=[...recommended.values()].filter(x=>`${x.entry.name} ${x.entry.description} ${x.entry.author} ${(x.entry.tags??[]).join(' ')}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())&&(!selectedTags.length||(x.entry.tags??[]).some(tag=>selectedTags.includes(tag)))&&(filter==='all'||filter==='installed'&&!!plugins[x.entry.id]||filter==='update'&&x.state.kind==='update'));
const request=(p:Entry,s:Source)=>({url:p.url,sha256:p.sha256,pluginId:p.id,version:p.version,source:s.id,allowHttp:s.allowHttp,githubRepository:githubRepository(s)});
if(selected){const p=selected.entry,s=selected.source,installed=plugins[p.id],state=stateFor(p,installed,s);const choices=catalogChoices([...data,...(history.key===historyKey?history.entries:[]).map(v=>({source:v.source,catalog:{name:v.source.name,plugins:[v.entry]}}))],source,channel,installed?.source).filter(v=>v.entry.id===p.id);if(!choices.some(v=>v.entry.version===p.version&&v.entry.sha256===p.sha256&&v.source.id===s.id))choices.push({entry:p,source:s});const choiceKey=(v:CatalogChoice)=>`${v.source.id}:${v.entry.version}:${v.entry.sha256}`;const versions=choices;const latest=versions.find(v=>v.entry.compatible!==false)?.entry??p;return <><button className="back-link" onClick={()=>setSelected(null)}><Icon name="arrow"/>{t("返回插件库")}</button><section className="store-detail"><div className="detail-heading"><PluginImage src={p.icon} name={p.name} size={80}/><div><h1>{p.name}</h1><p><PluginAuthor plugin={p}/> · {p.version}</p></div><button className="primary" disabled={busy||['incompatible'].includes(state.kind)} onClick={()=>void onInspect(request(p,s))}>{state.kind==='update'?t("更新"):state.kind==='new'?t("安装"):state.kind==='other'||state.kind==='different'?t("检查此版本"):state.kind==='installed'?t("重新安装"):state.kind==='older'?t("安装所选版本"):state.label}</button></div><RunUserNotice user={p.runAs}/><section className="settings-card">{history.key===historyKey&&history.loading&&<p className="sub" role="status">{t("正在加载历史版本…")}</p>}{history.key===historyKey&&history.error&&<p className="error" role="alert">{t("历史版本暂时不可用,仍可安装最新版。")}<button onClick={()=>setHistoryRetry(v=>v+1)}>{t("重试")}</button></p>}<Select label={t("可选安装版本")} value={choiceKey({entry:p,source:s})} onChange={key=>{const choice=versions.find(v=>choiceKey(v)===key);if(choice)setSelected(choice);}} options={versions.map(v=>({value:choiceKey(v),label:`${v.entry.version} · ${v.source.name}${versions.some(other=>other!==v&&other.entry.version===v.entry.version&&other.entry.sha256!==v.entry.sha256)?` · ${v.entry.sha256.slice(0,8)}`:''}`+(v.entry.version===installed?.manifest.version?t(" · 已安装"):v.entry.compatible===false?t(" · 不兼容"):v.entry===latest?t(" · 推荐版本"):"")}))}/></section><div className="badges"><span>{state.label}</span><span>{s.name}</span>{(p.tags??[]).map(t=><span key={t}>{t}</span>)}</div>{installed?.source&&installed.source!==s.id&&<p className="banner">{t("当前安装来自其他源,确认安装此版本后会更换来源。")}</p>}<p className="detail-summary">{p.description}</p><PluginLinks plugin={p}/>{p.engines?.framely&&<p className={p.compatible===false?'banner':'sub'}>{t('要求 Framely:{0}',{0:p.engines.framely})}</p>}{!!p.screenshots?.length&&<div className="screenshots">{p.screenshots.map((url,i)=><figure key={url}><img src={url} alt={t("{0}截图 {1}", {"0": p.name, "1": i+1})}/></figure>)}</div>}<h2>{t("关于插件")}</h2><p className="prose">{p.details||p.description||t("开发者尚未提供详细介绍。")}</p><h2>{t("依赖与冲突")}</h2>{Object.entries(p.dependencies??{}).map(([id,d])=><p key={id}>{t("必需:")}{id} · {typeof d==='string'?d:d.version}{typeof d==='object'&&<small className="source-url">{d.source}</small>}</p>)}{Object.entries(p.optionalDependencies??{}).map(([id,d])=><p key={id}>{t("可选:")}{id} · {typeof d==='string'?d:d.version}</p>)}{Object.entries(p.conflicts??{}).map(([id,range])=><p key={id}>{t("冲突:")}{id} · {range}</p>)}{!!p.exclusiveResources?.length&&<p>{t("独占资源:")}{p.exclusiveResources.join(', ')}</p>}<h2>{t("更新记录")}</h2><p className="prose">{p.changelog||t("开发者尚未提供更新记录。")}</p><section className="settings-card"><h2>{t("版本与来源")}</h2><p>{installed?t("本机版本:{0}", {"0": installed.manifest.version}):t("尚未安装")}{t("· 可用版本:")}{p.version}</p><p className="sub">{t("安装前会展示运行用户。插件库资料由插件源提供,以包内的信息为准。")}</p></section></section></>;}
return <><div className="list-heading"><div><h1>{t("插件库")}</h1><p>{t("浏览插件源及 GitHub Releases 中的插件。")}</p></div><div className="row"><button onClick={onSources}><Icon name="link"/>{t("管理插件源")}</button><button disabled={loading} onClick={()=>void load(true)}><Icon name="refresh"/>{loading?t("刷新中…"):t("刷新")}</button></div></div>{loading&&<div className="banner" role="status"><span>{t("插件源加载进度:{0} / {1}",{0:loadJob?.completedSources??0,1:loadJob?.totalSources??sources.filter(s=>s.enabled).length})}</span><button disabled={!loadJob} onClick={()=>void cancelLoad()}>{t("取消加载")}</button></div>}<div className="store-filters"><div className="search-field"><Icon name="search"/><input aria-label={t("搜索插件库")} placeholder={t("搜索名称、作者或标签")} value={query} onChange={e=>setQuery(e.target.value)}/></div><Select multiple label={t("标签")} value={selectedTags} onChange={setSelectedTags} options={tags.map(value=>({value,label:value}))}/><Select label={t("插件源")} value={source} onChange={setSource} options={[{value:'all',label:t("所有来源")},...data.filter(c=>c.catalog).map(c=>({value:c.source.id,label:c.source.name}))]}/><Select label={t("版本通道")} value={channel} onChange={setChannel} options={[{value:"all",label:t("所有版本")},{value:"stable",label:t("仅正式版")}]}/><Select label={t("安装状态")} value={filter} onChange={setFilter} options={[{value:"all",label:t("全部状态")},{value:"installed",label:t("已安装")},{value:"update",label:t("可更新")}]}/></div>{error&&<p className="error">{error}<button onClick={()=>void load(true)}>{t("重试")}</button></p>}{data.filter(c=>c.warning).map(c=><p className="banner" role="status" key={c.source.id}>{t('部分 GitHub 版本暂时不可用。')} {c.warning}</p>)}{data.filter(c=>c.error).map(c=><div className="banner" key={c.source?.id}><b>{c.source?.name??t("插件源")}{t("暂时不可用")}</b><p>{c.error}{c.cached?t(",正在显示上次成功读取的目录。"):''}</p><button disabled={loading} onClick={()=>void load(true)}>{t("重新连接")}</button></div>)}{loading&&!all.length?<div className="empty-state"><h2>{t("正在加载插件目录…")}</h2><p>{t("已安装插件仍可正常使用。")}</p></div>:!data.some(c=>c.catalog)&&!sources.some(s=>s.enabled)?<div className="empty-state"><Icon name="link" size={48}/><h2>{t("尚未添加插件源")}</h2><p>{t("配置你信任的目录地址,即可浏览和安装插件。")}</p><button className="primary" onClick={onSources}>{t("管理插件源")}</button></div>:!shown.length?<div className="empty-state"><h2>{all.length?t("没有符合条件的插件"):t("目录暂时没有插件")}</h2><p>{all.length?t("试试其他关键词或清除筛选。"):t("可以检查插件源连接,或导入本地插件包。")}</p>{!!all.length&&<button onClick={()=>{setQuery('');setSelectedTags([]);setSource('all');setChannel('all');setFilter('all');}}>{t("清除筛选")}</button>}</div>:<><p className="sub">{shown.length}{t("个结果")}</p><div className="store-grid">{shown.map(({entry:p,source:s,state})=><article className="store-card" key={`${s.id}:${p.id}`} onClick={()=>setSelected({entry:p,source:s})}><PluginImage src={p.icon} name={p.name} size={80}/><div className="store-copy"><h2 className="store-title-row"><button className="store-title" onClick={()=>setSelected({entry:p,source:s})}>{p.name}</button><RunUserBadge user={p.runAs}/></h2><p>{p.description||t("查看插件详情")}</p><small><PluginAuthor plugin={p}/> · {s.name}</small></div><span className="store-version">{p.version}</span><span className={`badge ${state.kind==='update'?'accent':''}`}>{state.label}</span><Icon name="arrow"/></article>)}</div></>}</>;
}
export function Transfer({job,retry,cancel,close}:{job:Job;retry?:()=>void;cancel?:()=>void;close:()=>void}){const labels:Record<string,string>={queued:t("等待开始"),downloading:t("正在下载"),verifying:t("正在校验内容"),resolving:t("正在解析插件依赖"),installing:t("正在安装"),loading:t("正在连接"),done:t("已完成"),failed:t("操作失败"),cancelled:t("已取消")};const percent=job.total?Math.min(job.phase==='downloading'?99:100,Math.floor(job.received/job.total*100)):undefined;return <div className="modal transfer"><h2>{labels[job.phase]??job.phase}</h2>{job.phase==='downloading'&&<><progress max={100} value={percent}/><p>{(job.received/1024/1024).toFixed(1)} MiB{job.total?` / ${(job.total/1024/1024).toFixed(1)} MiB · ${percent}%`:''}</p></>}{job.error&&<p className="error">{job.error}</p>}{job.phase==='failed'&&retry&&<button className="primary" onClick={retry}>{t("重试")}</button>}{!['done','failed','cancelled','installing'].includes(job.phase)&&cancel&&<button onClick={cancel}>{t("取消")}</button>}{['done','failed','cancelled'].includes(job.phase)&&<button onClick={close}>{t("关闭")}</button>}{job.phase==='installing'&&<p className="sub">{t("请稍候,正在保存插件和配置。")}</p>}</div>}
export function Sources({sources,save,origins}:{origins?:Record<string,{manual:boolean;subscriptions:Record<string,string>}>;sources:Source[];save:(s:Source[])=>Promise<unknown>}){const[name,setName]=useState(''),[url,setUrl]=useState(''),[http,setHttp]=useState(false),[edit,setEdit]=useState<string|null>(null),[error,setError]=useState(''),[probe,setProbe]=useState(''),[testing,setTesting]=useState(false),[busy,setBusy]=useState(false),[deleting,setDeleting]=useState<Source|null>(null),[formOpen,setFormOpen]=useState(false);
+1
View File
@@ -69,6 +69,7 @@ a:focus-visible{outline:3px solid var(--blue);outline-offset:2px}
button{overflow-wrap:anywhere}
.shell-header .primary,.store-filters .framely-select>button{white-space:nowrap}
.store-filters .framely-select{max-width:100%;flex-shrink:0}
.store-detail .framely-select [role=listbox]{width:100%!important;min-width:0!important;max-width:100%!important;left:0!important}
.store-title{max-width:100%;overflow-wrap:anywhere}
.manage-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.language-import{margin:0;min-height:52px}