From f301327eb8f2dfe8311d2935fae97b6491e29c6d Mon Sep 17 00:00:00 2001 From: toorux Date: Sat, 17 Oct 2026 18:08:23 +0800 Subject: [PATCH] feat(plugins): discover GitHub releases alongside catalogs --- docs/developer-guide/publishing.md | 2 + docs/github-plugin-discovery.md | 22 + docs/plugin-development.md | 2 + docs/zh-CN/developer-guide/publishing.md | 2 + docs/zh-CN/github-plugin-discovery.md | 79 ++ docs/zh-CN/plugin-development.md | 2 +- src/github_plugins.rs | 1200 ++++++++++++++++++++++ src/http.rs | 1 + src/http/github_fallback.rs | 11 +- src/jobs.rs | 30 +- src/main.rs | 22 +- src/model.rs | 2 + src/planner.rs | 21 +- src/session.rs | 71 +- templates/plugin/README.md | 2 + templates/plugin/README.zh-CN.md | 2 + tests/github_plugin_store_fixture.tsx | 55 + ui/locales/en-US.json | 5 + ui/locales/zh-CN.json | 5 + ui/src/store.tsx | 36 +- ui/src/style.css | 1 + 21 files changed, 1545 insertions(+), 28 deletions(-) create mode 100644 docs/github-plugin-discovery.md create mode 100644 docs/zh-CN/github-plugin-discovery.md create mode 100644 src/github_plugins.rs create mode 100644 tests/github_plugin_store_fixture.tsx diff --git a/docs/developer-guide/publishing.md b/docs/developer-guide/publishing.md index bd4c3f0..8e043b8 100644 --- a/docs/developer-guide/publishing.md +++ b/docs/developer-guide/publishing.md @@ -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 diff --git a/docs/github-plugin-discovery.md b/docs/github-plugin-discovery.md new file mode 100644 index 0000000..2da8440 --- /dev/null +++ b/docs/github-plugin-discovery.md @@ -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 `-.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. diff --git a/docs/plugin-development.md b/docs/plugin-development.md index e8811d9..8d65c57 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -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. diff --git a/docs/zh-CN/developer-guide/publishing.md b/docs/zh-CN/developer-guide/publishing.md index a161213..faade53 100644 --- a/docs/zh-CN/developer-guide/publishing.md +++ b/docs/zh-CN/developer-guide/publishing.md @@ -6,6 +6,8 @@ ## GitHub Release 默认地址 +现在也支持通过 GitHub topic 自动发现,无需目录文件。`release.json` 格式、生成命令和旧 Release 兼容规则见 [GitHub 插件自动发现](../github-plugin-discovery.md)。 + 公开 GitHub 插件仓库的**源码 `manifest.json` 可以省略 `downloadUrl`**。社区数据库从登记的仓库地址、版本和插件 ID 自动生成: ```text diff --git a/docs/zh-CN/github-plugin-discovery.md b/docs/zh-CN/github-plugin-discovery.md new file mode 100644 index 0000000..0be2be3 --- /dev/null +++ b/docs/zh-CN/github-plugin-discovery.md @@ -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` 或 ``,且必须与清单版本完全一致。 +3. 清单有 `downloadUrl` 时必须精确对应本 Release 的 `.framely` 附件;否则匹配 `-.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//`,来源 ID 根据仓库名稳定生成,安装确认成功才写入配置。后续更新、依赖和历史版本从该仓库 Releases 读取;禁用来源不会被自动发现重新启用。安装时再次核对整包哈希及包内 ID、版本、API、Framely 要求、实际运行用户、依赖与冲突;权限和来源切换使用现有确认流程。部分仓库失败不会隐藏其他来源已成功加载的插件。 diff --git a/docs/zh-CN/plugin-development.md b/docs/zh-CN/plugin-development.md index e8dbd96..234283b 100644 --- a/docs/zh-CN/plugin-development.md +++ b/docs/zh-CN/plugin-development.md @@ -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//versions.json;插件包可放在作者自己的 GitHub Release。在插件源管理中添加目录完整 URL;当前代码首次初始化加入社区 stable/testing 来源,实际内容以服务器为准。目录条目有版本、下载 URL、SHA256。每个源独立显示,插件记录所选来源;另一个源同 ID 不会静默替换。HTTP 必须用户明确允许;目录和包支持最多五次重定向,HTTPS 不允许降级为 HTTP。 +传统源可静态托管 catalog.json 及相对路径下的 plugins//versions.json;插件包可放在作者自己的 GitHub Release。在插件源管理中添加目录完整 URL;当前代码首次初始化加入社区 stable/testing 来源,实际内容以服务器为准。目录条目有版本、下载 URL、SHA256。插件库也支持 [GitHub 自动发现及 release.json](github-plugin-discovery.md),同 ID 合并展示并按兼容语义版本推荐,历史版本保留不同来源及哈希。插件记录所选来源,切换来源仍经过安装确认。HTTP 必须用户明确允许;目录和包支持最多五次重定向,HTTPS 不允许降级为 HTTP。 ### 悬停反馈 diff --git a/src/github_plugins.rs b/src/github_plugins.rs new file mode 100644 index 0000000..3083c16 --- /dev/null +++ b/src/github_plugins.rs @@ -0,0 +1,1200 @@ +//! GitHub-native plugin sources. Discovery reads metadata, never package bytes. +use crate::{jobs::Cancellation, model::*}; +use anyhow::{ensure, Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + io::Write, + path::{Path, PathBuf}, + sync::Mutex, + time::Duration, +}; + +const TOPIC: &str = "topic:framely-plugin archived:false fork:false"; +const TTL: u64 = 600_000; +const MAX_PAGES: usize = 10; +static REQUESTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +struct RequestPermit; +impl RequestPermit { + fn acquire(cancel: &Cancellation) -> Result { + use std::sync::atomic::Ordering; + loop { + cancel.check()?; + if REQUESTS + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| { + (n < 6).then_some(n + 1) + }) + .is_ok() + { + return Ok(Self); + } + std::thread::sleep(Duration::from_millis(10)); + } + } +} +impl Drop for RequestPermit { + fn drop(&mut self) { + REQUESTS.fetch_sub(1, std::sync::atomic::Ordering::Release); + } +} +#[derive(Clone, Deserialize, Serialize)] +struct Asset { + name: String, + browser_download_url: String, + size: u64, + #[serde(default)] + digest: Option, +} +#[derive(Deserialize)] +struct Release { + tag_name: String, + draft: bool, + prerelease: bool, + assets: Vec, +} +#[derive(Deserialize, Serialize)] +struct Cached { + at: u64, + etag: Option, + body: String, +} +pub struct Client<'a> { + proxy: &'a ProxySettings, + cancel: &'a Cancellation, + root: PathBuf, + refresh: bool, + pub warnings: Mutex>, +} +impl<'a> Client<'a> { + pub fn system( + proxy: &'a ProxySettings, + cancel: &'a Cancellation, + refresh: bool, + ) -> Result { + Ok(Self::new( + proxy, + cancel, + crate::steam::home()?.join(".cache/framely/github-plugins"), + refresh, + )) + } + fn new( + proxy: &'a ProxySettings, + cancel: &'a Cancellation, + root: PathBuf, + refresh: bool, + ) -> Self { + Self { + proxy, + cancel, + root, + refresh, + warnings: Mutex::default(), + } + } + fn warn(&self, message: String) { + self.warnings.lock().unwrap().push(message); + } + fn text(&self, url: &str, limit: usize) -> Result { + self.cancel.check()?; + let path = self + .root + .join(format!("{}.json", crate::package::digest(url.as_bytes()))); + let saved = fs::metadata(&path) + .ok() + .filter(|m| m.is_file() && m.len() <= (limit as u64 * 6 + 4096)) + .and_then(|_| fs::read(&path).ok()) + .and_then(|v| serde_json::from_slice::(&v).ok()) + .filter(|v| v.body.len() <= limit); + if !self.refresh { + if let Some(value) = &saved { + if crate::service::now_ms().saturating_sub(value.at) < TTL { + return Ok(value.body.clone()); + } + } + } + let _permit = RequestPermit::acquire(self.cancel)?; + let mut headers = vec![ + ("User-Agent", "Framely"), + ("Accept", "application/vnd.github+json"), + ]; + if let Some(etag) = saved + .as_ref() + .and_then(|v| v.etag.as_deref()) + .filter(|v| v.len() <= 1024 && !v.contains(['\r', '\n'])) + { + headers.push(("If-None-Match", etag)); + } + let mut retryable = false; + let result = crate::http::with_github_fallback(url, &headers, self.proxy, |proxy| { + let attempt = (|| { + let response = crate::http::get_with_proxy_cancel( + url, + false, + Duration::from_secs(15), + &headers, + proxy, + self.cancel, + )?; + if response.status() == 304 { + let value = saved.as_ref().context("Unexpected GitHub 304")?; + return Ok(Cached { + at: crate::service::now_ms(), + etag: value.etag.clone(), + body: value.body.clone(), + }); + } + let etag = response.header("ETag").map(str::to_owned); + let bytes = crate::http::read_cancel(response, limit, self.cancel)?; + Ok(Cached { + at: crate::service::now_ms(), + etag, + body: String::from_utf8(bytes)?, + }) + })(); + if let Err(error) = &attempt { + retryable = crate::http::github_fallback::retryable(error); + } + attempt + }); + self.cancel.check()?; + match result { + Ok(value) => { + // Cache failures do not prevent browsing; write atomically. + let _ = (|| -> Result<()> { + fs::create_dir_all(&self.root)?; + let mut file = tempfile::NamedTempFile::new_in(&self.root)?; + file.write_all(&serde_json::to_vec(&value)?)?; + file.as_file().sync_all()?; + file.persist(&path)?; + Ok(()) + })(); + Ok(value.body) + } + Err(error) => { + // Never conceal changed/missing releases, certificates or bad + // JSON behind old metadata. Only network failures use cache. + if retryable { + if let Some(value) = saved { + self.warn(format!( + "GitHub: {} (cached)", + crate::http::github_fallback::diagnostic(&error) + )); + return Ok(value.body); + } + } + Err(error) + } + } + } + fn json(&self, url: &str, limit: usize) -> Result { + Ok(serde_json::from_str(&self.text(url, limit)?)?) + } + pub fn discover(&self) -> Result> { + let mut sources = vec![]; + let mut seen = BTreeSet::new(); + for page in 1..=MAX_PAGES { + self.cancel.check()?; + let mut url = url::Url::parse("https://api.github.com/search/repositories")?; + url.query_pairs_mut() + .append_pair("q", TOPIC) + .append_pair("sort", "stars") + .append_pair("order", "desc") + .append_pair("per_page", "100") + .append_pair("page", &page.to_string()); + let value = self.json(url.as_str(), 4 * 1024 * 1024)?; + ensure!( + value["incomplete_results"] != true, + "Incomplete GitHub plugin search" + ); + let items = value["items"] + .as_array() + .context("Invalid GitHub plugin search")?; + ensure!(items.len() <= 100, "Invalid GitHub search page size"); + for item in items { + if item["archived"] != false + || item["fork"] != false + || !item["topics"] + .as_array() + .is_some_and(|v| v.iter().any(|t| t == "framely-plugin")) + { + continue; + } + if let Some(repo) = item["full_name"].as_str() { + if let Ok(source) = source(repo) { + if seen.insert(source.id.clone()) { + sources.push(source); + } + } + } + } + let total = value["total_count"].as_u64().unwrap_or(0); + if items.len() < 100 || (page * 100) as u64 >= total { + return Ok(sources); + } + } + self.warn("GitHub search reached its 1000-repository limit".into()); + Ok(sources) + } + pub fn entries(&self, repo: &str) -> Result> { + source(repo)?; + let mut releases = vec![]; + for page in 1..=MAX_PAGES { + let values = self.json( + &format!("https://api.github.com/repos/{repo}/releases?per_page=100&page={page}"), + 8 * 1024 * 1024, + )?; + let values = values.as_array().context("Invalid GitHub releases")?; + ensure!(values.len() <= 100, "Invalid GitHub release page size"); + for value in values { + let release: Release = serde_json::from_value(value.clone())?; + if !release.draft { + releases.push(release); + } + } + if values.len() < 100 { + break; + } + ensure!( + page < MAX_PAGES, + "GitHub release history exceeds 1000 entries" + ); + } + let mut entries = vec![]; + for chunk in releases.chunks(6) { + self.cancel.check()?; + let results = std::thread::scope(|scope| { + chunk + .iter() + .map(|release| scope.spawn(move || self.release(repo, release))) + .collect::>() + .into_iter() + .map(|h| { + h.join().unwrap_or_else(|_| { + Err(anyhow::anyhow!("Release metadata worker failed")) + }) + }) + .collect::>() + }); + self.cancel.check()?; + for (release, result) in chunk.iter().zip(results) { + match result { + Ok(mut values) => entries.append(&mut values), + Err(error) => self.warn(format!( + "{repo} {}: {}", + release.tag_name, + crate::http::github_fallback::diagnostic(&error) + )), + } + } + } + entries.sort_by(|a, b| { + version_cmp(&b.version, &a.version) + .then_with(|| a.id.cmp(&b.id)) + .then_with(|| a.sha256.cmp(&b.sha256)) + }); + entries.dedup_by(|a, b| a.id == b.id && a.version == b.version && a.sha256 == b.sha256); + Ok(entries) + } + fn release(&self, repo: &str, release: &Release) -> Result> { + let mut names = BTreeSet::new(); + ensure!( + release.assets.iter().all(|a| names.insert(&a.name)), + "Duplicate release asset names" + ); + if !release.assets.iter().any(|a| a.name.ends_with(".framely")) { + return Ok(vec![]); + } + for asset in release.assets.iter().filter(|a| { + a.name.ends_with(".framely") || matches!(a.name.as_str(), "release.json" | "SHA256SUMS") + }) { + asset.validate(repo, &release.tag_name)?; + } + let checksums = if let Some(asset) = release.assets.iter().find(|a| a.name == "SHA256SUMS") + { + Some(self.text(&asset.browser_download_url, 1024 * 1024)?) + } else { + None + }; + if let Some(asset) = release.assets.iter().find(|a| a.name == "release.json") { + let value = self.json(&asset.browser_download_url, 2 * 1024 * 1024)?; + return parse_descriptor(value, repo, release, checksums.as_deref()); + } + // Old releases are bound to the tag's resolved commit, never HEAD. + let encoded: String = + url::form_urlencoded::byte_serialize(release.tag_name.as_bytes()).collect(); + let commit = self.json( + &format!("https://api.github.com/repos/{repo}/commits/{encoded}"), + 2 * 1024 * 1024, + )?; + let sha = commit["sha"].as_str().context("Missing release commit")?; + ensure!( + sha.len() == 40 && sha.bytes().all(|c| c.is_ascii_hexdigit()), + "Invalid release commit" + ); + let manifest = self.json( + &format!("https://raw.githubusercontent.com/{repo}/{sha}/manifest.json"), + 2 * 1024 * 1024, + )?; + let version = manifest["version"] + .as_str() + .context("Missing legacy version")?; + ensure!( + release + .tag_name + .strip_prefix('v') + .unwrap_or(&release.tag_name) + == version, + "Legacy release tag/version mismatch" + ); + let id = manifest["id"].as_str().context("Missing plugin ID")?; + let filename = format!("{id}-{version}.framely"); + let asset = if let Some(url) = manifest["downloadUrl"].as_str() { + release + .assets + .iter() + .find(|a| a.browser_download_url == url) + } else { + release.assets.iter().find(|a| a.name == filename) + } + .context("Missing legacy package asset")?; + ensure!( + asset.name.ends_with(".framely"), + "Legacy downloadUrl does not select a plugin package" + ); + asset.validate(repo, &release.tag_name)?; + let digest = + asset_hash(asset, checksums.as_deref())?.context("Legacy release has no SHA256")?; + if let Some(declared) = manifest["downloadSha256"].as_str() { + ensure!( + declared.eq_ignore_ascii_case(&digest), + "Legacy SHA256 mismatch" + ); + } + let mut value = manifest.clone(); + value["runAs"] = if manifest.get("backend").is_some_and(|b| !b.is_null()) + || manifest.get("lifecycle").is_some_and(|b| !b.is_null()) + { + json!(manifest["backend"]["runAs"] + .as_str() + .or_else(|| manifest["lifecycle"]["runAs"].as_str()) + .unwrap_or("steamos")) + } else { + Value::Null + }; + value["url"] = json!(asset.browser_download_url); + value["sha256"] = json!(digest); + value["icon"] = match manifest["publish"]["icon"].as_str() { + Some(url) => json!(url), + None => manifest["icon"] + .as_str() + .map(|p| pinned_image(repo, sha, p)) + .transpose()? + .map_or(Value::Null, |s| json!(s)), + }; + value["screenshots"] = manifest["publish"]["screenshots"] + .as_array() + .cloned() + .map_or(json!([]), |v| json!(v)); + let mut entry: CatalogEntry = serde_json::from_value(value)?; + validate_entry(&mut entry, release.prerelease)?; + Ok(vec![entry]) + } +} +impl Asset { + fn validate(&self, repo: &str, tag: &str) -> Result<()> { + ensure!( + !self.name.is_empty() + && !self.name.contains(['/', '\\', '\r', '\n']) + && self.size > 0 + && self.size <= 2 * 1024 * 1024 * 1024, + "Invalid release asset" + ); + let url = url::Url::parse(&self.browser_download_url)?; + let prefix = format!("/{repo}/releases/download/{tag}/"); + let path = percent_decode(url.path())?; + ensure!( + url.scheme() == "https" + && url.host_str() == Some("github.com") + && url.port_or_known_default() == Some(443) + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + && path == format!("{prefix}{}", self.name), + "Release asset is outside its release" + ); + Ok(()) + } +} +fn percent_decode(value: &str) -> Result { + // form_urlencoded decodes '+' as space; preserve literal '+' in URL paths. + let encoded = format!("p={}", value.replace('+', "%2B")); + Ok(url::form_urlencoded::parse(encoded.as_bytes()) + .next() + .context("Invalid URL path")? + .1 + .into_owned()) +} +fn pinned_image(repo: &str, commit: &str, path: &str) -> Result { + crate::model::safe_path(path)?; + Ok(format!( + "https://raw.githubusercontent.com/{repo}/{commit}/{path}" + )) +} +pub fn repository(source: &Source) -> Option { + if source.allow_http { + return None; + } + let url = url::Url::parse(&source.url).ok()?; + if url.scheme() != "https" + || url.host_str() != Some("github.com") + || url.port_or_known_default() != Some(443) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return None; + } + let repo = url.path().trim_matches('/'); + valid_repo(repo).ok()?; + Some(repo.to_owned()) +} +fn valid_repo(repo: &str) -> Result<()> { + let parts: Vec<_> = repo.split('/').collect(); + ensure!( + parts.len() == 2 + && parts.iter().all(|p| !p.is_empty() + && p.len() <= 100 + && *p != "." + && *p != ".." + && p.bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))), + "Invalid GitHub plugin repository" + ); + Ok(()) +} +pub fn source(repo: &str) -> Result { + valid_repo(repo)?; + Ok(Source { + id: format!( + "github-{}", + &crate::package::digest(repo.to_ascii_lowercase().as_bytes())[..20] + ), + name: repo.chars().take(120).collect(), + url: format!("https://github.com/{repo}"), + enabled: true, + allow_http: false, + }) +} +fn hex_hash(value: &str) -> Result { + ensure!( + value.len() == 64 && value.bytes().all(|c| c.is_ascii_hexdigit()), + "Invalid release SHA256" + ); + Ok(value.to_ascii_lowercase()) +} +fn asset_hash(asset: &Asset, checksums: Option<&str>) -> Result> { + let digest = asset + .digest + .as_deref() + .map(|v| { + hex_hash( + v.strip_prefix("sha256:") + .context("Unsupported asset digest")?, + ) + }) + .transpose()?; + let mut checksum = None; + if let Some(text) = checksums { + for line in text.lines().filter(|l| !l.trim().is_empty()) { + ensure!( + line.len() > 66 && line.is_char_boundary(64) && line.is_char_boundary(66), + "Invalid SHA256SUMS" + ); + let bytes = line.as_bytes(); + ensure!( + bytes[64] == b' ' && matches!(bytes[65], b' ' | b'*'), + "Invalid SHA256SUMS" + ); + let hash = hex_hash(&line[..64])?; + if line[66..] == asset.name { + ensure!(checksum.is_none(), "Duplicate SHA256SUMS entry"); + checksum = Some(hash); + } + } + ensure!(checksum.is_some(), "Package missing from SHA256SUMS"); + } + if let (Some(a), Some(b)) = (&digest, &checksum) { + ensure!(a == b, "Asset digest/SHA256SUMS mismatch"); + } + Ok(digest.or(checksum)) +} +fn validate_entry(entry: &mut CatalogEntry, prerelease: bool) -> Result<()> { + let version = semver::Version::parse(&entry.version)?; + entry.validate(false)?; + entry.compatible = Some( + entry.api_version == API_VERSION + && entry + .engines + .as_ref() + .is_none_or(|e| e.matches(env!("CARGO_PKG_VERSION"))), + ); + // GitHub's prerelease flag is retained separately from SemVer, including + // releases tagged stable but marked prerelease in the API. + entry.prerelease = prerelease || !version.pre.is_empty(); + Ok(()) +} +fn parse_descriptor( + value: Value, + repo: &str, + release: &Release, + checksums: Option<&str>, +) -> Result> { + ensure!( + value["schemaVersion"] == 1, + "Unsupported release.json schema" + ); + let plugins = value["plugins"] + .as_array() + .context("Missing release.json plugins")?; + ensure!( + !plugins.is_empty() && plugins.len() <= 100, + "Invalid release.json plugin count" + ); + let mut ids = BTreeSet::new(); + let mut entries = vec![]; + for plugin in plugins { + ensure!( + plugin.get("runAs").is_some(), + "release.json must declare runAs" + ); + let asset_name = plugin["asset"].as_str().context("Missing package asset")?; + ensure!( + asset_name.ends_with(".framely"), + "Invalid plugin package extension" + ); + let asset = release + .assets + .iter() + .find(|a| a.name == asset_name) + .context("release.json package missing")?; + asset.validate(repo, &release.tag_name)?; + ensure!( + plugin["size"].as_u64() == Some(asset.size), + "release.json package size mismatch" + ); + let hash = hex_hash(plugin["sha256"].as_str().context("Missing SHA256")?)?; + if let Some(actual) = asset_hash(asset, checksums)? { + ensure!(actual == hash, "release.json SHA256 mismatch"); + } + let image = |value: &str| -> Result { + if let Some(asset) = release.assets.iter().find(|a| a.name == value) { + asset.validate(repo, &release.tag_name)?; + return Ok(asset.browser_download_url.clone()); + } + validate_url(value, false)?; + Ok(value.into()) + }; + let mut normalized = plugin.clone(); + normalized["url"] = json!(asset.browser_download_url); + normalized["sha256"] = json!(hash); + if let Some(icon) = plugin["icon"].as_str() { + normalized["icon"] = json!(image(icon)?); + } + if let Some(screenshots) = plugin["screenshots"].as_array() { + normalized["screenshots"] = json!(screenshots + .iter() + .map(|v| image(v.as_str().context("Invalid screenshot")?)) + .collect::>>()?); + } + let mut entry: CatalogEntry = serde_json::from_value(normalized)?; + ensure!( + ids.insert(entry.id.clone()), + "Duplicate release.json plugin ID" + ); + validate_entry(&mut entry, release.prerelease)?; + entries.push(entry); + } + Ok(entries) +} +pub fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering { + match (semver::Version::parse(a), semver::Version::parse(b)) { + (Ok(a), Ok(b)) => a.cmp_precedence(&b), + _ => a.cmp(b), + } +} +pub fn catalog(name: &str, entries: &[CatalogEntry]) -> Catalog { + let mut groups = BTreeMap::>::new(); + for entry in entries { + groups + .entry(entry.id.clone()) + .or_default() + .push(entry.clone()); + } + Catalog { + schema_version: 1, + name: name.into(), + plugins: groups + .into_values() + .map(|versions| { + versions + .iter() + .find(|v| v.compatible == Some(true)) + .unwrap_or(&versions[0]) + .clone() + }) + .collect(), + } +} +pub fn check_package(entry: &CatalogEntry, manifest: &Manifest) -> Result<()> { + ensure!( + entry.id == manifest.id + && entry.version == manifest.version + && entry.api_version == manifest.api_version + && serde_json::to_value(&entry.engines)? == serde_json::to_value(&manifest.engines)? + && entry.run_as + == (manifest.backend.is_some() || manifest.lifecycle.is_some()) + .then(|| manifest.run_as()) + && serde_json::to_value(entry.relations())? + == serde_json::to_value(manifest.relations())?, + "Plugin package declarations differ from GitHub release metadata" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + struct Mock { + routes: crate::http::github_fallback::TestRoutes, + responses: std::sync::Arc>>, + seen: std::sync::Arc>>, + stop: std::sync::Arc, + worker: Option>, + } + impl Mock { + fn new(routes: Vec<(String, u16, String)>) -> Self { + let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); + let address = format!("http://{}", server.server_addr()); + let responses = std::sync::Arc::new(Mutex::new(BTreeMap::new())); + let mut urls = vec![]; + for (i, (url, status, body)) in routes.into_iter().enumerate() { + let path = format!("/{i}"); + responses + .lock() + .unwrap() + .insert(path.clone(), (status, body)); + urls.push((url, format!("{address}{path}"))); + } + let seen = std::sync::Arc::new(Mutex::new(vec![])); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let worker = { + let responses = responses.clone(); + let seen = seen.clone(); + let stop = stop.clone(); + std::thread::spawn(move || { + while !stop.load(std::sync::atomic::Ordering::Acquire) { + if let Some(request) = + server.recv_timeout(Duration::from_millis(20)).unwrap() + { + let conditional = request.headers().iter().any(|h| { + h.field.equiv("If-None-Match") && h.value.as_str() == "\"test\"" + }); + seen.lock() + .unwrap() + .push((request.url().into(), conditional)); + let (status, body) = responses + .lock() + .unwrap() + .get(request.url()) + .unwrap() + .clone(); + request + .respond( + tiny_http::Response::from_string(body) + .with_status_code(status) + .with_header( + tiny_http::Header::from_bytes("ETag", "\"test\"") + .unwrap(), + ), + ) + .unwrap(); + } + } + }) + }; + Self { + routes: crate::http::github_fallback::TestRoutes::new(urls), + responses, + seen, + stop, + worker: Some(worker), + } + } + } + impl Drop for Mock { + fn drop(&mut self) { + self.stop.store(true, std::sync::atomic::Ordering::Release); + self.worker.take().unwrap().join().unwrap(); + let _ = &self.routes; + } + } + fn asset(repo: &str, tag: &str, name: &str, hash: Option<&str>) -> Asset { + Asset { + name: name.into(), + browser_download_url: format!( + "https://github.com/{repo}/releases/download/{tag}/{name}" + ), + size: 123, + digest: hash.map(|h| format!("sha256:{h}")), + } + } + fn plugin(version: &str) -> Value { + json!({"id":"example.test","name":"Test","version":version,"apiVersion":1,"author":"Author","description":"Test plugin", "asset":"plugin.framely","size":123,"sha256":"a".repeat(64),"runAs":null}) + } + #[test] + fn release_descriptor_validates_identity_hash_size_and_image_assets() { + let release = Release { + tag_name: "v1.0.0".into(), + draft: false, + prerelease: false, + assets: vec![ + asset( + "example/test", + "v1.0.0", + "plugin.framely", + Some(&"a".repeat(64)), + ), + asset("example/test", "v1.0.0", "icon.png", None), + ], + }; + let mut p = plugin("1.0.0"); + p["icon"] = json!("icon.png"); + let entry = parse_descriptor( + json!({"schemaVersion":1,"plugins":[p]}), + "example/test", + &release, + None, + ) + .unwrap() + .remove(0); + assert!(entry.icon.unwrap().ends_with("/v1.0.0/icon.png")); + assert_eq!(entry.compatible, Some(true)); + for change in [ + json!({"size":122}), + json!({"sha256":"b".repeat(64)}), + json!({"asset":"missing.framely"}), + json!({"icon":"../icon.png"}), + json!({"version":"invalid"}), + ] { + let mut p = plugin("1.0.0"); + for (k, v) in change.as_object().unwrap() { + p[k] = v.clone(); + } + assert!(parse_descriptor( + json!({"schemaVersion":1,"plugins":[p]}), + "example/test", + &release, + None + ) + .is_err()); + } + let mut p = plugin("1.0.0"); + p.as_object_mut().unwrap().remove("runAs"); + assert!(parse_descriptor( + json!({"schemaVersion":1,"plugins":[p]}), + "example/test", + &release, + None + ) + .is_err()); + assert!(parse_descriptor( + json!({"schemaVersion":1,"plugins":[plugin("1.0.0"),plugin("1.0.0")]}), + "example/test", + &release, + None + ) + .is_err()); + assert!(parse_descriptor( + json!({"schemaVersion":2,"plugins":[plugin("1.0.0")]}), + "example/test", + &release, + None + ) + .is_err()); + assert!(parse_descriptor( + json!({"schemaVersion":1,"plugins":[plugin("1.0.0")]}), + "example/test", + &release, + Some(&format!("{} plugin.framely\n", "b".repeat(64))) + ) + .is_err()); + assert!(source("../escape").is_err()); + assert!(source("owner/repo?token=x").is_err()); + assert!(repository(&Source { + url: "https://github.com.evil/owner/repo".into(), + ..source("owner/repo").unwrap() + }) + .is_none()); + } + #[test] + fn release_version_precedence_and_package_metadata_checks() { + assert!(version_cmp("1.0.0-preview.10", "1.0.0-preview.9").is_gt()); + assert!(version_cmp("1.0.0", "1.0.0-preview.10").is_gt()); + let release = Release { + tag_name: "v1.0.0".into(), + draft: false, + prerelease: true, + assets: vec![asset("example/test", "v1.0.0", "plugin.framely", None)], + }; + let entry = parse_descriptor( + json!({"schemaVersion":1,"plugins":[plugin("1.0.0")]}), + "example/test", + &release, + None, + ) + .unwrap() + .remove(0); + assert!(entry.prerelease); + let mut manifest: Manifest=serde_json::from_value(json!({"schemaVersion":1,"apiVersion":1,"id":"example.test","name":"Test","version":"1.0.0","author":"Author","files":{},"ui":{}})).unwrap(); + check_package(&entry, &manifest).unwrap(); + manifest.api_version = 2; + assert!(check_package(&entry, &manifest).is_err()); + manifest.api_version = 1; + manifest.dependencies.insert( + "example.dep".into(), + crate::relations::Dependency::Version("^1.0.0".into()), + ); + assert!(check_package(&entry, &manifest).is_err()); + } + #[test] + fn github_metadata_cache_revalidates_and_only_network_failures_use_stale_data() { + let url = "https://api.github.com/repos/cache/test/releases?per_page=100&page=1"; + let mock = Mock::new(vec![(url.into(), 200, "[]".into())]); + let root = tempfile::tempdir().unwrap(); + let cancel = Cancellation::default(); + let proxy = ProxySettings { + github_auto_fallback: false, + ..Default::default() + }; + let client = Client::new(&proxy, &cancel, root.path().into(), false); + assert_eq!(client.text(url, 100).unwrap(), "[]"); + assert_eq!(client.text(url, 100).unwrap(), "[]"); + assert_eq!(mock.seen.lock().unwrap().len(), 1); + mock.responses + .lock() + .unwrap() + .insert("/0".into(), (304, String::new())); + let refresh = Client::new(&proxy, &cancel, root.path().into(), true); + assert_eq!(refresh.text(url, 100).unwrap(), "[]"); + assert!(mock.seen.lock().unwrap()[1].1); + mock.responses + .lock() + .unwrap() + .insert("/0".into(), (503, "unavailable".into())); + assert_eq!(refresh.text(url, 100).unwrap(), "[]"); + assert_eq!(refresh.warnings.lock().unwrap().len(), 1); + mock.responses + .lock() + .unwrap() + .insert("/0".into(), (404, "missing".into())); + assert!(refresh.text(url, 100).is_err()); + cancel.stop(); + assert!(client.text(url, 100).is_err()); + } + #[test] + fn github_discovery_paginates_and_filters_forks_archives_and_missing_topics() { + let url = |page: usize| { + let mut url = url::Url::parse("https://api.github.com/search/repositories").unwrap(); + url.query_pairs_mut() + .append_pair("q", TOPIC) + .append_pair("sort", "stars") + .append_pair("order", "desc") + .append_pair("per_page", "100") + .append_pair("page", &page.to_string()); + url.to_string() + }; + let item = |name: String| json!({"full_name":name,"archived":false,"fork":false,"topics":["framely-plugin"]}); + let first: Vec<_> = (0..100) + .map(|i| item(format!("fixture/plugin-{i}"))) + .collect(); + let mut fork = item("fixture/fork".into()); + fork["fork"] = json!(true); + let mut archived = item("fixture/archive".into()); + archived["archived"] = json!(true); + let mut other = item("fixture/other".into()); + other["topics"] = json!([]); + let mock=Mock::new(vec![(url(1),200,json!({"total_count":104,"incomplete_results":false,"items":first}).to_string()), + (url(2),200,json!({"total_count":104,"incomplete_results":false,"items":[fork,archived,other,item("fixture/final".into())]}).to_string())]); + let root = tempfile::tempdir().unwrap(); + let cancel = Cancellation::default(); + let proxy = ProxySettings::default(); + let results = Client::new(&proxy, &cancel, root.path().into(), false) + .discover() + .unwrap(); + assert_eq!(results.len(), 101); + assert_eq!(results.last().unwrap().name, "fixture/final"); + assert_eq!(mock.seen.lock().unwrap().len(), 2); + assert_eq!( + source("Fixture/Final").unwrap().id, + results.last().unwrap().id + ); + } + #[test] + fn github_releases_read_unsorted_new_and_legacy_metadata_without_downloading_packages() { + let repo = "fixture/releases"; + let commit = "c".repeat(40); + let hash = "a".repeat(64); + let releases = ["1.0.0-preview.9", "1.0.0-preview.10", "1.0.0"]; + let mut pages = vec![]; + let mut routes = vec![]; + for version in releases { + let tag = format!("v{version}"); + let name = format!("example.test-{version}.framely"); + let package = asset(repo, &tag, &name, Some(&hash)); + let mut assets = vec![package]; + if version == "1.0.0" { + let manifest = json!({"schemaVersion":1,"apiVersion":1,"id":"example.test","name":"Test","version":version,"author":"Author","description":"Test plugin","icon":"icon.png","files":{},"ui":{}}); + routes.push(( + format!("https://api.github.com/repos/{repo}/commits/{tag}"), + 200, + json!({"sha":commit}).to_string(), + )); + routes.push(( + format!("https://raw.githubusercontent.com/{repo}/{commit}/manifest.json"), + 200, + manifest.to_string(), + )); + } else { + let descriptor = asset(repo, &tag, "release.json", None); + let mut p = plugin(version); + p["asset"] = json!(name); + routes.push(( + descriptor.browser_download_url.clone(), + 200, + json!({"schemaVersion":1,"plugins":[p]}).to_string(), + )); + assets.push(descriptor); + } + pages.push( + json!({"tag_name":tag,"draft":false,"prerelease":version!="1.0.0","assets":assets}), + ); + } + routes.push(( + format!("https://api.github.com/repos/{repo}/releases?per_page=100&page=1"), + 200, + json!(pages).to_string(), + )); + let mock = Mock::new(routes); + let root = tempfile::tempdir().unwrap(); + let cancel = Cancellation::default(); + let proxy = ProxySettings { + github_auto_fallback: false, + ..Default::default() + }; + let client = Client::new(&proxy, &cancel, root.path().into(), false); + let entries = client.entries(repo).unwrap(); + assert_eq!( + entries + .iter() + .map(|p| p.version.as_str()) + .collect::>(), + vec!["1.0.0", "1.0.0-preview.10", "1.0.0-preview.9"] + ); + assert!(entries[0].icon.as_ref().unwrap().contains(&commit)); + assert!(client.warnings.lock().unwrap().is_empty()); + assert_eq!(mock.seen.lock().unwrap().len(), 5); + assert_eq!(catalog("Test", &entries).plugins.len(), 1); + } + #[test] + fn invalid_new_release_never_falls_back_to_legacy_manifest() { + let repo = "fixture/invalid"; + let release = Release { + tag_name: "v1.0.0".into(), + draft: false, + prerelease: false, + assets: vec![ + asset(repo, "v1.0.0", "plugin.framely", None), + asset(repo, "v1.0.0", "release.json", None), + ], + }; + let mock = Mock::new(vec![( + release.assets[1].browser_download_url.clone(), + 200, + "{broken".into(), + )]); + let root = tempfile::tempdir().unwrap(); + let cancel = Cancellation::default(); + let proxy = ProxySettings::default(); + assert!(Client::new(&proxy, &cancel, root.path().into(), false) + .release(repo, &release) + .is_err()); + assert_eq!(mock.seen.lock().unwrap().len(), 1); + assert!(asset_hash(&release.assets[0], None).unwrap().is_none()); + assert!(asset_hash( + &release.assets[0], + Some(&format!("{} plugin.framely", "a".repeat(64))) + ) + .unwrap() + .is_some()); + assert!(asset_hash( + &release.assets[0], + Some(&format!("{}é plugin.framely", "a".repeat(63))) + ) + .is_err()); + } + #[test] + fn generated_release_uses_verified_packages_and_sources_wait_for_confirmation() { + let root = tempfile::tempdir().unwrap(); + crate::tests::fixture(root.path(), "example.test", "1.0.0", 0, None); + let manifest_path = root.path().join("manifest-1.0.0-0.json"); + let mut manifest: Value = + serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap(); + manifest["icon"] = json!("icon.png"); + manifest["screenshots"] = json!(["icon.png"]); + fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap(); + let icon = fs::read("assets/branding/icons/64.png").unwrap(); + fs::write(root.path().join("payload-1.0.0-0/icon.png"), &icon).unwrap(); + let output = root.path().join("1.0.0-0.framely"); + crate::package::pack( + &manifest_path, + &root.path().join("payload-1.0.0-0"), + &output, + ) + .unwrap(); + let bytes = fs::read(output).unwrap(); + let value = release_json(root.path()).unwrap(); + assert_eq!( + value["plugins"][0]["sha256"], + crate::package::digest(&bytes) + ); + assert_eq!(value["plugins"][0]["size"], bytes.len()); + assert!(value["plugins"][0]["runAs"].is_null()); + assert_eq!(value["plugins"][0]["icon"], "example.test-1.0.0-icon.png"); + assert_eq!( + fs::read(root.path().join("example.test-1.0.0-icon.png")).unwrap(), + icon + ); + assert_eq!( + fs::read(root.path().join("example.test-1.0.0-screen-0.png")).unwrap(), + icon + ); + let source = source("example/test").unwrap(); + let mut db = Database::default(); + let staged = crate::package::Staged::from_bytes(&bytes).unwrap(); + let prepared = crate::planner::prepare_staged_sources( + &db, + staged.clone(), + Some(source.id.clone()), + Default::default(), + Default::default(), + vec![source.clone()], + ) + .unwrap(); + assert_eq!(prepared.plan.add_sources.len(), 1); + assert!(db.sources.is_empty()); + db.sources.push(Source { + enabled: false, + ..source.clone() + }); + assert!(crate::planner::prepare_staged_sources( + &db, + staged, + Some(source.id.clone()), + Default::default(), + Default::default(), + vec![source] + ) + .is_err()); + } +} +pub fn release_json(packages: &Path) -> Result { + let mut plugins = vec![]; + let mut ids = BTreeSet::new(); + let mut paths = fs::read_dir(packages)? + .map(|p| p.map(|e| e.path())) + .collect::>>()?; + paths.sort(); + for path in paths + .into_iter() + .filter(|p| p.extension().is_some_and(|e| e == "framely")) + { + let bytes = fs::read(&path)?; + let verified = crate::package::verify(&bytes)?; + let manifest = verified.manifest; + ensure!( + ids.insert(manifest.id.clone()), + "Duplicate plugin ID in release" + ); + semver::Version::parse(&manifest.version)?; + let mut value = serde_json::to_value(&manifest)?; + for key in [ + "schemaVersion", + "backend", + "ui", + "files", + "publish", + "downloadUrl", + "lifecycle", + "icon", + "screenshots", + ] { + value.as_object_mut().unwrap().remove(key); + } + value["asset"] = json!(path + .file_name() + .context("Missing asset name")? + .to_str() + .context("Invalid asset name")?); + value["size"] = json!(bytes.len()); + value["sha256"] = json!(crate::package::digest(&bytes)); + value["runAs"] = + json!((manifest.backend.is_some() || manifest.lifecycle.is_some()) + .then(|| manifest.run_as())); + let export_image = |label: &str, path: &str| -> Result { + let data = verified.files.get(path).context("Missing packaged image")?; + let ext = Path::new(path) + .extension() + .and_then(|v| v.to_str()) + .context("Missing image extension")?; + let name = format!("{}-{}-{label}.{ext}", manifest.id, manifest.version); + let output = packages.join(&name); + if output.exists() { + ensure!( + fs::read(&output)? == *data, + "Refusing to replace a different release image: {name}" + ); + } else { + let mut file = tempfile::NamedTempFile::new_in(packages)?; + file.write_all(data)?; + file.as_file().sync_all()?; + file.persist_noclobber(&output)?; + } + Ok(name) + }; + let publish = manifest.publish.as_ref(); + if let Some(icon) = publish.and_then(|p| p.icon.clone()) { + value["icon"] = json!(icon); + } else if let Some(icon) = &manifest.icon { + value["icon"] = json!(export_image("icon", icon)?); + } + value["screenshots"] = + if let Some(images) = publish.map(|p| &p.screenshots).filter(|s| !s.is_empty()) { + json!(images) + } else { + json!(manifest + .screenshots + .iter() + .enumerate() + .map(|(i, p)| export_image(&format!("screen-{i}"), p)) + .collect::>>()?) + }; + plugins.push(value); + } + ensure!(!plugins.is_empty(), "No .framely packages found"); + Ok(json!({"schemaVersion":1,"plugins":plugins})) +} diff --git a/src/http.rs b/src/http.rs index f4561e1..01556ff 100644 --- a/src/http.rs +++ b/src/http.rs @@ -25,6 +25,7 @@ pub fn get_headers( ) -> Result { get_with_proxy(initial, allow_http, timeout, headers, &Default::default()) } +#[cfg(test)] pub fn get_with_proxy( initial: &str, allow_http: bool, diff --git a/src/http/github_fallback.rs b/src/http/github_fallback.rs index ac30e50..a9a84ed 100644 --- a/src/http/github_fallback.rs +++ b/src/http/github_fallback.rs @@ -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", diff --git a/src/jobs.rs b/src/jobs.rs index 41efe7f..90fa5ca 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -258,6 +258,28 @@ impl Jobs { let key = id.clone(); std::thread::spawn(move || { let result = (|| -> Result { + 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 = 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); diff --git a/src/main.rs b/src/main.rs index 0267bd8..dac03b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(), diff --git a/src/model.rs b/src/model.rs index bce8d8d..f8feb40 100644 --- a/src/model.rs +++ b/src/model.rs @@ -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, diff --git a/src/planner.rs b/src/planner.rs index 1ad4f87..8e90c21 100644 --- a/src/planner.rs +++ b/src/planner.rs @@ -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, choices: BTreeMap, cancel: crate::jobs::Cancellation, +) -> Result { + prepare_staged_sources(db, bytes, source, choices, cancel, vec![]) +} +pub fn prepare_staged_sources( + db: &Database, bytes: std::sync::Arc, source: Option, + choices: BTreeMap, cancel: crate::jobs::Cancellation, additional: Vec, ) -> Result { 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(), diff --git a/src/session.rs b/src/session.rs index b10f38f..1de0fe4 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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 = 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 = 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 { let status = self.core("status", json!({}))?; let sources: Vec = 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 { 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> { 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, proxy: crate::model::ProxySettings, @@ -1897,6 +1913,49 @@ fn fetch_catalogs_cancel( ) -> Result { fetch_catalogs_progress(sources, proxy, cache, cancel, &|_| {}) } +fn fetch_library( + sources: Vec, proxy: ProxySettings, + cache: Arc>>, cancel: &crate::jobs::Cancellation, + progress: &(dyn Fn(Value) + Send + Sync), refresh: bool, +) -> Result { + 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::>().into_iter().map(|h| h.join().unwrap_or_else(|_| json!({"error":"GitHub release worker failed"}))).collect::>()); + 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::>().join("\n")})); + } + result +} fn fetch_catalogs_progress( sources: Vec, proxy: crate::model::ProxySettings, diff --git a/templates/plugin/README.md b/templates/plugin/README.md index e2454d4..7a425ac 100644 --- a/templates/plugin/README.md +++ b/templates/plugin/README.md @@ -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 `-.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`,详见中英双语启动台开发文档。 diff --git a/templates/plugin/README.zh-CN.md b/templates/plugin/README.zh-CN.md index cb44cb3..de7cdfd 100644 --- a/templates/plugin/README.zh-CN.md +++ b/templates/plugin/README.zh-CN.md @@ -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`,默认附件名为 `-.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`。 diff --git a/tests/github_plugin_store_fixture.tsx b/tests/github_plugin_store_fixture.tsx new file mode 100644 index 0000000..391e70a --- /dev/null +++ b/tests/github_plugin_store_fixture.tsx @@ -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(
framely
{}} onInspect={async request=>{inspected.push(request);}}/>
); +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(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('[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('[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('[role=listbox]')!;menu.scrollIntoView({block:'nearest'});await wait(); + const content=document.querySelector('.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('.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')); diff --git a/ui/locales/en-US.json b/ui/locales/en-US.json index 516f1fd..27dcf94 100644 --- a/ui/locales/en-US.json +++ b/ui/locales/en-US.json @@ -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", diff --git a/ui/locales/zh-CN.json b/ui/locales/zh-CN.json index d0a0fd5..784ee54 100644 --- a/ui/locales/zh-CN.json +++ b/ui/locales/zh-CN.json @@ -3,6 +3,11 @@ "locale": "zh-CN", "name": "简体中文", "messages": { + "浏览插件源及 GitHub Releases 中的插件。": "浏览插件源及 GitHub Releases 中的插件。", + "版本通道": "版本通道", + "所有版本": "所有版本", + "仅正式版": "仅正式版", + "部分 GitHub 版本暂时不可用。": "部分 GitHub 版本暂时不可用。", "以文本编辑": "以文本编辑", "文本编辑器": "文本编辑器", "纯文本": "纯文本", diff --git a/ui/src/store.tsx b/ui/src/store.tsx index 8af1e07..3f6d9d4 100644 --- a/ui/src/store.tsx +++ b/ui/src/store.tsx @@ -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;optionalDependencies?:Record;conflicts?:Record;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();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;i0)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;sources:Source[];onInspect:(request:any)=>Promise;onSources:()=>void;busy:boolean}){ const[data,setData]=useState([]),[loading,setLoading]=useState(false),[error,setError]=useState(''),[query,setQuery]=useState(''),[selectedTags,setSelectedTags]=useState([]),[source,setSource]=useState('all'),[filter,setFilter]=useState('all'),[selected,setSelected]=useState<{entry:Entry;source:Source}|null>(null); - const[loadJob,setLoadJob]=useState(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(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(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 <>

{p.name}

· {p.version}

{history.key===historyKey&&history.loading&&

{t("正在加载历史版本…")}

}{history.key===historyKey&&history.error&&

{t("历史版本暂时不可用,仍可安装最新版。")}

}setQuery(e.target.value)}/>s.enabled).map(s=>({value:s.id,label:s.name}))]}/>{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(" · 推荐版本"):"")}))}/>
{state.label}{s.name}{(p.tags??[]).map(t=>{t})}
{installed?.source&&installed.source!==s.id&&

{t("当前安装来自其他源,确认安装此版本后会更换来源。")}

}

{p.description}

{p.engines?.framely&&

{t('要求 Framely:{0}',{0:p.engines.framely})}

}{!!p.screenshots?.length&&
{p.screenshots.map((url,i)=>
{t("{0}截图
)}
}

{t("关于插件")}

{p.details||p.description||t("开发者尚未提供详细介绍。")}

{t("依赖与冲突")}

{Object.entries(p.dependencies??{}).map(([id,d])=>

{t("必需:")}{id} · {typeof d==='string'?d:d.version}{typeof d==='object'&&{d.source}}

)}{Object.entries(p.optionalDependencies??{}).map(([id,d])=>

{t("可选:")}{id} · {typeof d==='string'?d:d.version}

)}{Object.entries(p.conflicts??{}).map(([id,range])=>

{t("冲突:")}{id} · {range}

)}{!!p.exclusiveResources?.length&&

{t("独占资源:")}{p.exclusiveResources.join(', ')}

}

{t("更新记录")}

{p.changelog||t("开发者尚未提供更新记录。")}

{t("版本与来源")}

{installed?t("本机版本:{0}", {"0": installed.manifest.version}):t("尚未安装")}{t("· 可用版本:")}{p.version}

{t("安装前会展示运行用户。插件库资料由插件源提供,以包内的信息为准。")}

;} + return <>

{t("插件库")}

{t("浏览插件源及 GitHub Releases 中的插件。")}

{loading&&
{t("插件源加载进度:{0} / {1}",{0:loadJob?.completedSources??0,1:loadJob?.totalSources??sources.filter(s=>s.enabled).length})}
}
setQuery(e.target.value)}/>
c.catalog).map(c=>({value:c.source.id,label:c.source.name}))]}/>
{error&&

{error}

}{data.filter(c=>c.warning).map(c=>

{t('部分 GitHub 版本暂时不可用。')} {c.warning}

)}{data.filter(c=>c.error).map(c=>
{c.source?.name??t("插件源")}{t("暂时不可用")}

{c.error}{c.cached?t(",正在显示上次成功读取的目录。"):''}

)}{loading&&!all.length?

{t("正在加载插件目录…")}

{t("已安装插件仍可正常使用。")}

:!data.some(c=>c.catalog)&&!sources.some(s=>s.enabled)?

{t("尚未添加插件源")}

{t("配置你信任的目录地址,即可浏览和安装插件。")}

:!shown.length?

{all.length?t("没有符合条件的插件"):t("目录暂时没有插件")}

{all.length?t("试试其他关键词或清除筛选。"):t("可以检查插件源连接,或导入本地插件包。")}

{!!all.length&&}
:<>

{shown.length}{t("个结果")}

{shown.map(({entry:p,source:s,state})=>
setSelected({entry:p,source:s})}>

{p.description||t("查看插件详情")}

· {s.name}
{p.version}{state.label}
)}
}; } export function Transfer({job,retry,cancel,close}:{job:Job;retry?:()=>void;cancel?:()=>void;close:()=>void}){const labels:Record={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

{labels[job.phase]??job.phase}

{job.phase==='downloading'&&<>

{(job.received/1024/1024).toFixed(1)} MiB{job.total?` / ${(job.total/1024/1024).toFixed(1)} MiB · ${percent}%`:''}

}{job.error&&

{job.error}

}{job.phase==='failed'&&retry&&}{!['done','failed','cancelled','installing'].includes(job.phase)&&cancel&&}{['done','failed','cancelled'].includes(job.phase)&&}{job.phase==='installing'&&

{t("请稍候,正在保存插件和配置。")}

}
} export function Sources({sources,save,origins}:{origins?:Record}>;sources:Source[];save:(s:Source[])=>Promise}){const[name,setName]=useState(''),[url,setUrl]=useState(''),[http,setHttp]=useState(false),[edit,setEdit]=useState(null),[error,setError]=useState(''),[probe,setProbe]=useState(''),[testing,setTesting]=useState(false),[busy,setBusy]=useState(false),[deleting,setDeleting]=useState(null),[formOpen,setFormOpen]=useState(false); diff --git a/ui/src/style.css b/ui/src/style.css index 9a30276..9cf411a 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -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}