feat(plugins): discover GitHub releases alongside catalogs

This commit is contained in:
toorux committed 2026-10-17 18:08:23 +08:00
1 parent ed51e913bb
commit f301327eb8
21 files changed
+1545 -28

No files matched your search

+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.
+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。
### 悬停反馈
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,
+9 -2
View File
@@ -84,8 +84,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 {
@@ -190,6 +194,9 @@ mod tests {
"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(),
+65 -6
View File
@@ -1442,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())?;
@@ -1450,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" => {
@@ -1467,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),
@@ -1744,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(
@@ -1758,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));
}
@@ -1798,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));
@@ -1881,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,
@@ -1897,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,
+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'));
+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}