From 65664904165565cac3d36923b7d24dfb8d6de10a Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 22:00:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(community):=20=E9=80=9A=E8=BF=87=E5=B7=B2?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=9A=84=20Host=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=A1=8C=E9=9D=A2=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 9 ++ frontend/src-tauri/build.rs | 1 + frontend/src-tauri/capabilities/main.json | 3 +- .../autogenerated/extension_stage.toml | 11 ++ frontend/src-tauri/src/extension_commands.rs | 30 ++++ frontend/src-tauri/src/extension_store.rs | 51 ++++++ frontend/src-tauri/src/extension_trust.rs | 148 ++++++++++++++++-- frontend/src-tauri/src/main.rs | 1 + .../src/features/community/CommunityView.vue | 2 +- .../src/services/communityDesktop.spec.ts | 23 +++ frontend/src/services/communityService.ts | 10 ++ 11 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 frontend/src-tauri/permissions/autogenerated/extension_stage.toml create mode 100644 frontend/src/services/communityDesktop.spec.ts diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index c805558..53d5192 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -278,3 +278,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 来源发现保留服务端 source_id,Web 开发保留本地来源流程。对话框中的错误可见,忙碌时禁止重复确认。 - 前端全量 99 文件 / 521 项通过,两套 TypeScript 项目检查通过;日志 `.build/extension-trust-ui-tests.log`。新增服务参数/非法输入测试与 Vue 组件确认前无写入、Host 拒绝后不保存测试。测试使用 Host mock,不等同于真实桌面 UI 端到端验收。 - 桌面包下载暂存、安装确认 UI 与执行编排、沙箱、真实双端及部署验收仍未完成;整体生产化目标继续进行。 + + +## 增量:桌面 Host 下载和验证暂存 + +- 主窗口新增 extension_stage 命令。桌面社区按钮提交签名发行元数据给 Host,剥离 release_id/withdrawn/download_path 展示字段,Host 使用固定来源设置自行复核在线发行并取得归档地址;renderer 不再调用浏览器下载及旧 Python/主题安装路径。 +- 归档地址由复核的发行 ID 构造,并要求服务端 download_path 精确匹配。下载禁止重定向、限制主题 5 MiB/其他 10 MiB,并按签名声明大小执行流式上限;下载后重新验签、检查 ZIP 与类型清单,并核对信任时效,再写入不可变暂存对象及操作回执。 +- Host 下载在后台阻塞任务中执行,持有安装库锁保持串行。当前 UI 取消不取消已派发的原生暂存任务,任务最多受请求超时约束且只产生暂存;后续需接入统一取消/进度流程。 +- 32 项扩展回归、Clippy 全目标、6 项前端针对性测试和两套 TypeScript 项目检查通过。Rust 日志 `.build/extension-download-tests.log`;真实 socket 覆盖归档内容错误、无 Content-Length 超长流、302 拒绝和正确 Python 签名归档。 +- 桌面文案明确“已校验并暂存,尚未安装或启用”。缺失依赖自动获取、完整安装 UI、停止实例/健康探测、沙箱和真实 HTTPS 部署验收仍待完成,不视为整体生产化完成。 diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index a03a5ad..6aa3630 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -19,6 +19,7 @@ fn main() { "extension_trust_review", "extension_trust_confirm", "extension_install_preview", + "extension_stage", "record_get", "record_write", "sync_login", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index 0e76e44..4c10bb7 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -52,6 +52,7 @@ "allow-record-write", "allow-extension-trust-review", "allow-extension-trust-confirm", - "allow-extension-install-preview" + "allow-extension-install-preview", + "allow-extension-stage" ] } diff --git a/frontend/src-tauri/permissions/autogenerated/extension_stage.toml b/frontend/src-tauri/permissions/autogenerated/extension_stage.toml new file mode 100644 index 0000000..d9036cf --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/extension_stage.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-stage" +description = "Enables the extension_stage command without any pre-configured scope." +commands.allow = ["extension_stage"] + +[[permission]] +identifier = "deny-extension-stage" +description = "Denies the extension_stage command without any pre-configured scope." +commands.deny = ["extension_stage"] diff --git a/frontend/src-tauri/src/extension_commands.rs b/frontend/src-tauri/src/extension_commands.rs index 389dd5a..610ff63 100644 --- a/frontend/src-tauri/src/extension_commands.rs +++ b/frontend/src-tauri/src/extension_commands.rs @@ -146,6 +146,36 @@ pub async fn extension_install_preview( .map_err(|_| "EXTENSION_PREVIEW_FAILED".to_string())? } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StageRequest { + operation_id: String, + source: String, + release: notesagent_host::extension_package::Release, +} +#[tauri::command] +pub async fn extension_stage( + window: WebviewWindow, + host: State<'_, Host>, + request: StageRequest, +) -> Result { + main_window(&window)?; + let extensions = host.extensions.clone(); + tauri::async_runtime::spawn_blocking(move || { + let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?; + let store = store.as_mut().ok_or("EXTENSIONS_NOT_READY")?; + let receipt = tauri::async_runtime::block_on(store.stage_online( + &request.operation_id, + &request.source, + &request.release, + )) + .map_err(|e| e.code)?; + serde_json::to_value(receipt).map_err(|_| "EXTENSION_STAGE_FAILED".into()) + }) + .await + .map_err(|_| "EXTENSION_STAGE_FAILED".to_string())? +} + #[cfg(test)] mod tests { use super::*; diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index fa759f2..92df6e6 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -600,6 +600,57 @@ impl ExtensionStore { )?; Ok(preview.changes) } + pub async fn stage_online( + &mut self, + operation: &str, + source_url: &str, + release: &Release, + ) -> Result { + Uuid::parse_str(operation).map_err(|_| HostError::new("OPERATION_ID_INVALID"))?; + let origin = source(source_url)?; + let trusted = self + .trust_setting(&origin, &release.namespace, &release.key_id)? + .ok_or_else(|| HostError::new("EXTENSION_SOURCE_UNTRUSTED"))?; + if !trusted.enabled { + return Err(HostError::new("EXTENSION_SOURCE_UNTRUSTED")); + } + self.check_not_revoked(&origin, release, &trusted.public_key)?; + let client = crate::extension_trust::Client::new(&origin)?; + let result = client + .check( + crate::extension_trust::Pin { + source_id: &trusted.source_id, + key_id: &trusted.key_id, + namespace: &trusted.namespace, + public_key: &trusted.public_key, + }, + release, + ) + .await; + let checked = match result { + Ok(checked) => checked, + Err(error) => { + self.remember_revocation(&origin, release, &trusted.public_key, &error.code)?; + return Err(error); + } + }; + let archive = client + .download(&checked, release, &trusted.public_key) + .await?; + self.stage(Stage { + operation_id: operation, + source: &origin, + release, + archive: &archive, + withdrawn: false, + signer: Signer { + public_key: &trusted.public_key, + key_id: &trusted.key_id, + namespace: &trusted.namespace, + revoked: false, + }, + }) + } /// Online installation gate, using confirmed Host trust settings only. pub async fn switch_online( &mut self, diff --git a/frontend/src-tauri/src/extension_trust.rs b/frontend/src-tauri/src/extension_trust.rs index 1ba3238..01df7ab 100644 --- a/frontend/src-tauri/src/extension_trust.rs +++ b/frontend/src-tauri/src/extension_trust.rs @@ -23,6 +23,7 @@ pub struct Checked { release_hash: String, key_hash: String, checked_at: Instant, + archive_path: String, } impl Checked { /// Monotonic freshness avoids a wall-clock rollback extending validity. @@ -112,6 +113,55 @@ impl Client { } serde_json::from_slice(&bytes).map_err(|_| unavailable()) } + pub async fn download( + &self, + checked: &Checked, + release: &Release, + key: &[u8; 32], + ) -> Result> { + checked.matches(self.source.as_str(), release, key)?; + let limit = if release.kind == "theme" { + 5 * 1024 * 1024 + } else { + 10 * 1024 * 1024 + }; + if release.size > limit { + return Err(HostError::new("EXTENSION_ARCHIVE_LIMIT")); + } + let url = self + .source + .join(&checked.archive_path) + .map_err(|_| unavailable())?; + let mut response = self + .http + .get(url) + .header("Cache-Control", "no-cache, no-store") + .send() + .await + .map_err(|_| unavailable())?; + if response.status() != reqwest::StatusCode::OK + || response.content_length().is_some_and(|n| n != release.size) + { + return Err(unavailable()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| unavailable())? { + if bytes.len() as u64 + chunk.len() as u64 > release.size { + return Err(HostError::new("EXTENSION_ARCHIVE_LIMIT")); + } + bytes.extend_from_slice(&chunk); + } + release.verify_package( + key, + &release.key_id, + &release.namespace, + false, + false, + &bytes, + )?; + checked.matches(self.source.as_str(), release, key)?; + Ok(bytes) + } pub async fn check(&self, pin: Pin<'_>, release: &Release) -> Result { release.validate()?; let started = Instant::now(); @@ -163,17 +213,33 @@ impl Client { .remove("withdrawn") .and_then(|v| v.as_bool()) .ok_or_else(unavailable)?; - for field in ["release_id", "download_path"] { - if map - .remove(field) - .and_then(|v| v.as_str().map(str::to_owned)) - .is_none() - { - return Err(unavailable()); - } + let release_id = map + .remove("release_id") + .and_then(|v| v.as_str().map(str::to_owned)) + .ok_or_else(unavailable)?; + if release_id.is_empty() + || release_id.len() > 128 + || !release_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err(unavailable()); + } + let archive_path = format!("catalog/v1/releases/{release_id}/archive"); + if map + .remove("download_path") + .and_then(|v| v.as_str().map(str::to_owned)) + .as_deref() + != Some(format!("/{archive_path}").as_str()) + { + return Err(unavailable()); } let remote: Release = serde_json::from_value(item).map_err(|_| unavailable())?; - found.push((hash(&serde_json::to_vec(&remote).unwrap()), withdrawn)); + found.push(( + hash(&serde_json::to_vec(&remote).unwrap()), + withdrawn, + archive_path, + )); } let release_hash = hash(&serde_json::to_vec(release).unwrap()); if found.len() != 1 || found[0].0 != release_hash { @@ -187,6 +253,7 @@ impl Client { release_hash, key_hash: hash(pin.public_key), checked_at: started, + archive_path: found[0].2.clone(), }; checked.matches(self.source.as_str(), release, pin.public_key)?; Ok(checked) @@ -304,6 +371,69 @@ mod tests { } assert!(Client::new("http://catalog.example").is_err()); } + #[tokio::test] + async fn archive_download_verifies_real_fixture_and_enforces_stream_size() { + let mut fixture: Value = serde_json::from_str(include_str!( + "../../src/services/fixtures/community-python-vector.json" + )) + .unwrap(); + let key: [u8; 32] = STANDARD + .decode(fixture["key"]["public_key"].as_str().unwrap()) + .unwrap() + .try_into() + .unwrap(); + let bytes = STANDARD + .decode(fixture["archive_base64"].as_str().unwrap()) + .unwrap(); + for field in ["release_id", "withdrawn", "download_path"] { + fixture["release"].as_object_mut().unwrap().remove(field); + } + let release: Release = serde_json::from_value(fixture["release"].clone()).unwrap(); + for case in ["ok", "corrupt", "overlong", "redirect"] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let source = format!("http://{}/", listener.local_addr().unwrap()); + let mut body = bytes.clone(); + if case == "corrupt" { + body[0] ^= 1; + } + if case == "overlong" { + body.push(0); + } + let worker = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = [0; 8192]; + let mut count = 0; + while !buffer[..count].windows(4).any(|b| b == b"\r\n\r\n") { + let n = stream.read(&mut buffer[count..]).unwrap(); + assert!(n > 0); + count += n; + } + let status = if case == "redirect" { + "302 Found" + } else { + "200 OK" + }; + // No content length: exercise the streaming cap independently. + write!(stream, "HTTP/1.1 {status}\r\nConnection: close\r\n\r\n").unwrap(); + stream.write_all(&body).unwrap(); + }); + let mut client = Client::new("https://catalog.example/").unwrap(); + client.source = reqwest::Url::parse(&source).unwrap(); + let checked = Checked { + source, + release_hash: hash(&serde_json::to_vec(&release).unwrap()), + key_hash: hash(&key), + checked_at: Instant::now(), + archive_path: "catalog/v1/releases/fixture/archive".into(), + }; + let result = client.download(&checked, &release, &key).await; + assert_eq!(result.is_ok(), case == "ok", "{case}"); + if let Ok(actual) = result { + assert_eq!(actual, bytes); + } + worker.join().unwrap(); + } + } fn json_source(public: &[u8; 32]) -> Value { serde_json::json!({"schema_version":1,"source_id":"fixture","keys":[{"key_id":"test-key","namespace":"examples","public_key":STANDARD.encode(public),"revoked":false}]}) } diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 4920755..9fa3373 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -850,6 +850,7 @@ fn main() { extension_trust_review, extension_trust_confirm, extension_install_preview, + extension_stage, record_get, record_write, sync_login, diff --git a/frontend/src/features/community/CommunityView.vue b/frontend/src/features/community/CommunityView.vue index 25aca7c..4d4fdf2 100644 --- a/frontend/src/features/community/CommunityView.vue +++ b/frontend/src/features/community/CommunityView.vue @@ -143,7 +143,7 @@ function toggleSource() {
作者 / 来源
{{ detail.author_id }} / {{ detail.namespace }}
许可证
{{ detail.license }}
大小 / 摘要
{{ detail.size }} 字节
{{ detail.sha256 }}
兼容平台
{{ detail.platforms.join(', ') }} / {{ detail.architectures.join(', ') }}
权限
{{ detail.permissions.join(', ') || '无' }}
依赖
{{ JSON.stringify(detail.dependencies) }}
{{ detail.changelog }}

安装不会自动启用包或其依赖。人设、模板、MCP 与模型方案仅保存为可检查的候选。

- + diff --git a/frontend/src/services/communityDesktop.spec.ts b/frontend/src/services/communityDesktop.spec.ts new file mode 100644 index 0000000..d6d33cb --- /dev/null +++ b/frontend/src/services/communityDesktop.spec.ts @@ -0,0 +1,23 @@ +import { afterEach, expect, it, vi } from 'vitest' +const native = vi.hoisted(() => ({ invoke: vi.fn() })) +vi.mock('@tauri-apps/api/core', () => ({ invoke: native.invoke })) +vi.mock('./platform/desktop', () => ({ isDesktop: () => true })) +import { installRelease } from './communityService' +import vector from './fixtures/community-python-vector.json' +import type { CommunityRelease, CommunitySource } from '@/contracts/community' +afterEach(() => { vi.unstubAllGlobals(); native.invoke.mockReset() }) +it('desktop stages through Host without renderer download or legacy installation', async () => { + const fetch = vi.fn(); vi.stubGlobal('fetch', fetch) + native.invoke.mockResolvedValue({ state: 'staged' }) + const source: CommunitySource = { id: 'fixture', url: 'https://catalog.example/', keys: [], enabled: true } + const release = vector.release as CommunityRelease + expect(await installRelease(source, release)).toContain('尚未安装或启用') + expect(fetch).not.toHaveBeenCalled() + const [command, args] = native.invoke.mock.calls[0]! + expect(command).toBe('extension_stage') + expect(args.request.release.signature).toBe(release.signature) + expect(args.request.release).not.toHaveProperty('withdrawn') + expect(args.request.release).not.toHaveProperty('download_path') + expect(args.request.release).not.toHaveProperty('release_id') + expect(vector.release).toHaveProperty('release_id') +}) diff --git a/frontend/src/services/communityService.ts b/frontend/src/services/communityService.ts index 934c9ff..aac19d0 100644 --- a/frontend/src/services/communityService.ts +++ b/frontend/src/services/communityService.ts @@ -1,6 +1,8 @@ /** 只请求用户配置来源;公钥固定、签名及摘要检查先于任何安装 API。 */ import type { CommunityCatalog, CommunityRelease, CommunitySource, CommunityKey } from '@/contracts/community' import { valid, gt, lt } from 'semver' +import { invoke } from '@tauri-apps/api/core' +import { isDesktop } from './platform/desktop' import appPackage from '../../package.json' import { decodeThemePackage, inspectThemePackage, installTheme } from './themePackageService' import { installSkill } from './skillService' @@ -89,6 +91,14 @@ export async function verifyRelease(release: CommunityRelease, pinned: Community } export async function installRelease(source: CommunitySource, selected: CommunityRelease, signal?: AbortSignal): Promise { + if (isDesktop()) { + signal?.throwIfAborted() + if (!source.enabled || selected.withdrawn) throw new Error('来源已停用或发行已撤回') + const release = { ...selected } as Partial + delete release.release_id; delete release.withdrawn; delete release.download_path + await invoke('extension_stage', { request: { operation_id: crypto.randomUUID(), source: source.url, release } }) + return '已校验并暂存到桌面安装库,尚未安装或启用' + } const catalog = await fetchCatalog(source, '', selected.type, signal) const release = catalog.items.find(item => item.release_id === selected.release_id) if (!release || release.sha256 !== selected.sha256 || release.withdrawn) throw new Error('发行已变更或撤回,请刷新目录')