feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -278,3 +278,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- 来源发现保留服务端 source_id,Web 开发保留本地来源流程。对话框中的错误可见,忙碌时禁止重复确认。
|
- 来源发现保留服务端 source_id,Web 开发保留本地来源流程。对话框中的错误可见,忙碌时禁止重复确认。
|
||||||
- 前端全量 99 文件 / 521 项通过,两套 TypeScript 项目检查通过;日志 `.build/extension-trust-ui-tests.log`。新增服务参数/非法输入测试与 Vue 组件确认前无写入、Host 拒绝后不保存测试。测试使用 Host mock,不等同于真实桌面 UI 端到端验收。
|
- 前端全量 99 文件 / 521 项通过,两套 TypeScript 项目检查通过;日志 `.build/extension-trust-ui-tests.log`。新增服务参数/非法输入测试与 Vue 组件确认前无写入、Host 拒绝后不保存测试。测试使用 Host mock,不等同于真实桌面 UI 端到端验收。
|
||||||
- 桌面包下载暂存、安装确认 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 部署验收仍待完成,不视为整体生产化完成。
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ fn main() {
|
|||||||
"extension_trust_review",
|
"extension_trust_review",
|
||||||
"extension_trust_confirm",
|
"extension_trust_confirm",
|
||||||
"extension_install_preview",
|
"extension_install_preview",
|
||||||
|
"extension_stage",
|
||||||
"record_get",
|
"record_get",
|
||||||
"record_write",
|
"record_write",
|
||||||
"sync_login",
|
"sync_login",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"allow-record-write",
|
"allow-record-write",
|
||||||
"allow-extension-trust-review",
|
"allow-extension-trust-review",
|
||||||
"allow-extension-trust-confirm",
|
"allow-extension-trust-confirm",
|
||||||
"allow-extension-install-preview"
|
"allow-extension-install-preview",
|
||||||
|
"allow-extension-stage"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -146,6 +146,36 @@ pub async fn extension_install_preview(
|
|||||||
.map_err(|_| "EXTENSION_PREVIEW_FAILED".to_string())?
|
.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<Value, String> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -600,6 +600,57 @@ impl ExtensionStore {
|
|||||||
)?;
|
)?;
|
||||||
Ok(preview.changes)
|
Ok(preview.changes)
|
||||||
}
|
}
|
||||||
|
pub async fn stage_online(
|
||||||
|
&mut self,
|
||||||
|
operation: &str,
|
||||||
|
source_url: &str,
|
||||||
|
release: &Release,
|
||||||
|
) -> Result<Receipt> {
|
||||||
|
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.
|
/// Online installation gate, using confirmed Host trust settings only.
|
||||||
pub async fn switch_online(
|
pub async fn switch_online(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub struct Checked {
|
|||||||
release_hash: String,
|
release_hash: String,
|
||||||
key_hash: String,
|
key_hash: String,
|
||||||
checked_at: Instant,
|
checked_at: Instant,
|
||||||
|
archive_path: String,
|
||||||
}
|
}
|
||||||
impl Checked {
|
impl Checked {
|
||||||
/// Monotonic freshness avoids a wall-clock rollback extending validity.
|
/// Monotonic freshness avoids a wall-clock rollback extending validity.
|
||||||
@@ -112,6 +113,55 @@ impl Client {
|
|||||||
}
|
}
|
||||||
serde_json::from_slice(&bytes).map_err(|_| unavailable())
|
serde_json::from_slice(&bytes).map_err(|_| unavailable())
|
||||||
}
|
}
|
||||||
|
pub async fn download(
|
||||||
|
&self,
|
||||||
|
checked: &Checked,
|
||||||
|
release: &Release,
|
||||||
|
key: &[u8; 32],
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
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<Checked> {
|
pub async fn check(&self, pin: Pin<'_>, release: &Release) -> Result<Checked> {
|
||||||
release.validate()?;
|
release.validate()?;
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
@@ -163,17 +213,33 @@ impl Client {
|
|||||||
.remove("withdrawn")
|
.remove("withdrawn")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.ok_or_else(unavailable)?;
|
.ok_or_else(unavailable)?;
|
||||||
for field in ["release_id", "download_path"] {
|
let release_id = map
|
||||||
if map
|
.remove("release_id")
|
||||||
.remove(field)
|
.and_then(|v| v.as_str().map(str::to_owned))
|
||||||
.and_then(|v| v.as_str().map(str::to_owned))
|
.ok_or_else(unavailable)?;
|
||||||
.is_none()
|
if release_id.is_empty()
|
||||||
{
|
|| release_id.len() > 128
|
||||||
return Err(unavailable());
|
|| !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())?;
|
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());
|
let release_hash = hash(&serde_json::to_vec(release).unwrap());
|
||||||
if found.len() != 1 || found[0].0 != release_hash {
|
if found.len() != 1 || found[0].0 != release_hash {
|
||||||
@@ -187,6 +253,7 @@ impl Client {
|
|||||||
release_hash,
|
release_hash,
|
||||||
key_hash: hash(pin.public_key),
|
key_hash: hash(pin.public_key),
|
||||||
checked_at: started,
|
checked_at: started,
|
||||||
|
archive_path: found[0].2.clone(),
|
||||||
};
|
};
|
||||||
checked.matches(self.source.as_str(), release, pin.public_key)?;
|
checked.matches(self.source.as_str(), release, pin.public_key)?;
|
||||||
Ok(checked)
|
Ok(checked)
|
||||||
@@ -304,6 +371,69 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(Client::new("http://catalog.example").is_err());
|
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 {
|
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}]})
|
serde_json::json!({"schema_version":1,"source_id":"fixture","keys":[{"key_id":"test-key","namespace":"examples","public_key":STANDARD.encode(public),"revoked":false}]})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -850,6 +850,7 @@ fn main() {
|
|||||||
extension_trust_review,
|
extension_trust_review,
|
||||||
extension_trust_confirm,
|
extension_trust_confirm,
|
||||||
extension_install_preview,
|
extension_install_preview,
|
||||||
|
extension_stage,
|
||||||
record_get,
|
record_get,
|
||||||
record_write,
|
record_write,
|
||||||
sync_login,
|
sync_login,
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ function toggleSource() {
|
|||||||
<dl><dt>作者 / 来源</dt><dd>{{ detail.author_id }} / {{ detail.namespace }}</dd><dt>许可证</dt><dd>{{ detail.license }}</dd><dt>大小 / 摘要</dt><dd>{{ detail.size }} 字节<br />{{ detail.sha256 }}</dd><dt>兼容平台</dt><dd>{{ detail.platforms.join(', ') }} / {{ detail.architectures.join(', ') }}</dd><dt>权限</dt><dd>{{ detail.permissions.join(', ') || '无' }}</dd><dt>依赖</dt><dd>{{ JSON.stringify(detail.dependencies) }}</dd></dl>
|
<dl><dt>作者 / 来源</dt><dd>{{ detail.author_id }} / {{ detail.namespace }}</dd><dt>许可证</dt><dd>{{ detail.license }}</dd><dt>大小 / 摘要</dt><dd>{{ detail.size }} 字节<br />{{ detail.sha256 }}</dd><dt>兼容平台</dt><dd>{{ detail.platforms.join(', ') }} / {{ detail.architectures.join(', ') }}</dd><dt>权限</dt><dd>{{ detail.permissions.join(', ') || '无' }}</dd><dt>依赖</dt><dd>{{ JSON.stringify(detail.dependencies) }}</dd></dl>
|
||||||
<pre>{{ detail.changelog }}</pre>
|
<pre>{{ detail.changelog }}</pre>
|
||||||
<p>安装不会自动启用包或其依赖。人设、模板、MCP 与模型方案仅保存为可检查的候选。</p>
|
<p>安装不会自动启用包或其依赖。人设、模板、MCP 与模型方案仅保存为可检查的候选。</p>
|
||||||
<button class="btn btn-primary" :disabled="busy || detail.withdrawn || offline" @click="install">校验并安装</button>
|
<button class="btn btn-primary" :disabled="busy || detail.withdrawn || offline" @click="install">{{ isDesktop() ? '校验并暂存' : '校验并安装' }}</button>
|
||||||
</template>
|
</template>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
/** 只请求用户配置来源;公钥固定、签名及摘要检查先于任何安装 API。 */
|
/** 只请求用户配置来源;公钥固定、签名及摘要检查先于任何安装 API。 */
|
||||||
import type { CommunityCatalog, CommunityRelease, CommunitySource, CommunityKey } from '@/contracts/community'
|
import type { CommunityCatalog, CommunityRelease, CommunitySource, CommunityKey } from '@/contracts/community'
|
||||||
import { valid, gt, lt } from 'semver'
|
import { valid, gt, lt } from 'semver'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { isDesktop } from './platform/desktop'
|
||||||
import appPackage from '../../package.json'
|
import appPackage from '../../package.json'
|
||||||
import { decodeThemePackage, inspectThemePackage, installTheme } from './themePackageService'
|
import { decodeThemePackage, inspectThemePackage, installTheme } from './themePackageService'
|
||||||
import { installSkill } from './skillService'
|
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<string> {
|
export async function installRelease(source: CommunitySource, selected: CommunityRelease, signal?: AbortSignal): Promise<string> {
|
||||||
|
if (isDesktop()) {
|
||||||
|
signal?.throwIfAborted()
|
||||||
|
if (!source.enabled || selected.withdrawn) throw new Error('来源已停用或发行已撤回')
|
||||||
|
const release = { ...selected } as Partial<CommunityRelease>
|
||||||
|
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 catalog = await fetchCatalog(source, '', selected.type, signal)
|
||||||
const release = catalog.items.find(item => item.release_id === selected.release_id)
|
const release = catalog.items.find(item => item.release_id === selected.release_id)
|
||||||
if (!release || release.sha256 !== selected.sha256 || release.withdrawn) throw new Error('发行已变更或撤回,请刷新目录')
|
if (!release || release.sha256 !== selected.sha256 || release.withdrawn) throw new Error('发行已变更或撤回,请刷新目录')
|
||||||
|
|||||||
Reference in New Issue
Block a user