fix(plugins): detect latest releases and streamline upgrades (#293)

Refresh explicit GitHub release checks while preserving concurrent fetch sharing and fixed-release download caching.

Compare verified package versions and offer a direct upgrade with pinned digests, revision checks, permission disclosure, and configuration fallback.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-26 19:00:23 +08:00
committed by GitHub
co-authored by Codex
parent b981e42342
commit cfcf7c486a
15 changed files with 263 additions and 54 deletions
@@ -26,6 +26,7 @@ use super::{
pub(super) struct CachedRelease {
result: Result<ResolvedRelease, AdminError>,
until: Instant,
fetched_at: Instant,
}
#[derive(Clone)]
@@ -57,7 +58,9 @@ impl HttpPluginDistribution {
mut query: GithubReleaseQuery,
credentials: &[SourceCredential],
egress: Option<&PluginDistributionEgress>,
refresh: bool,
) -> Result<ResolvedRelease, AdminError> {
let requested_at = Instant::now();
validate_repository(&query.repository)?;
query.repository.make_ascii_lowercase();
let url = self.release_url(&query)?;
@@ -91,7 +94,11 @@ impl HttpPluginDistribution {
tokio::time::timeout(Duration::from_secs(60), async {
// 同一来源共用锁和结果;取消请求释放锁,下一位查询者可重新发起。
let mut cached = slot.lock().await;
if let Some(entry) = cached.as_ref().filter(|entry| entry.until > Instant::now()) {
// 显式查询刷新成功结果;等待同一次请求的调用者仍共享结果,失败保留短缓存限流。
if let Some(entry) = cached.as_ref().filter(|entry| {
entry.until > Instant::now()
&& (!refresh || entry.fetched_at >= requested_at || entry.result.is_err())
}) {
return entry.result.clone();
}
let _permit = self
@@ -105,9 +112,11 @@ impl HttpPluginDistribution {
} else {
Duration::from_secs(30)
};
let fetched_at = Instant::now();
*cached = Some(CachedRelease {
result: result.clone(),
until: Instant::now() + ttl,
until: fetched_at + ttl,
fetched_at,
});
result
})
@@ -213,7 +222,7 @@ impl HttpPluginDistribution {
credentials: &[SourceCredential],
egress: Option<&PluginDistributionEgress>,
) -> Result<(Url, Option<String>, PluginSource, bool), AdminError> {
let release = self.release(query, credentials, egress).await?;
let release = self.release(query, credentials, egress, false).await?;
let selected = release
.assets
.iter()
@@ -153,7 +153,7 @@ impl PluginDistribution for HttpPluginDistribution {
egress: Option<PluginDistributionEgress>,
) -> Result<PluginRelease, AdminError> {
Ok(self
.release(query, &credentials, egress.as_ref())
.release(query, &credentials, egress.as_ref(), true)
.await?
.view)
}
@@ -109,6 +109,69 @@ async fn concurrent_queries_share_a_single_fetch_and_return_cache_times() {
}
}
#[tokio::test]
async fn explicit_queries_refresh_a_new_release_before_the_cache_expires() {
let server = MockServer::start().await;
let calls = std::sync::atomic::AtomicUsize::new(0);
Mock::given(path("/repos/example/plugins/releases/latest"))
.respond_with(move |_: &wiremock::Request| {
let mut metadata = release();
if calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0 {
metadata["tag_name"] = json!("model-trace-v0.1.3");
}
ResponseTemplate::new(200).set_body_json(metadata)
})
.expect(2)
.mount(&server)
.await;
let distribution = transport(&server);
let previous = distribution
.query_release(query(None), vec![], None)
.await
.unwrap();
let latest = distribution
.query_release(query(None), vec![], None)
.await
.unwrap();
assert_eq!(previous.tag, "v1.0.0");
assert_eq!(latest.tag, "model-trace-v0.1.3");
assert!(latest.queried_at < previous.expires_at);
}
#[tokio::test]
async fn artifact_downloads_reuse_the_checked_fixed_release() {
let server = MockServer::start().await;
Mock::given(path("/repos/example/plugins/releases/tags/v1.0.0"))
.respond_with(ResponseTemplate::new(200).set_body_json(release()))
.expect(1)
.mount(&server)
.await;
Mock::given(path("/repos/example/plugins/releases/assets/1"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"package"))
.mount(&server)
.await;
let distribution = transport(&server);
distribution
.query_release(query(Some("v1.0.0")), vec![], None)
.await
.unwrap();
let downloaded = distribution
.download(
RemotePluginLocation::Github {
repository: "example/plugins".into(),
tag: "v1.0.0".into(),
asset: "example_1.0.0_linux_x86_64.tar.gz".into(),
allow_prerelease: false,
sha256: None,
},
vec![],
None,
)
.await
.unwrap();
assert_eq!(downloaded.sha256, digest(b"package"));
}
#[tokio::test]
async fn rate_limit_applies_to_other_repositories_using_the_same_identity_and_egress() {
let server = MockServer::start().await;
@@ -60,7 +60,7 @@ async fn cancelling_the_fetch_owner_releases_the_shared_slot_for_the_next_query(
response
}
})
.expect(2)
.expect(3)
.mount(&server)
.await;
let distribution = Arc::new(transport(&server));
@@ -84,12 +84,12 @@ async fn cancelling_the_fetch_owner_releases_the_shared_slot_for_the_next_query(
.unwrap();
assert_eq!(recovered.tag, "v1.0.0");
assert_eq!(calls.load(Ordering::SeqCst), 2);
let cached = distribution
let refreshed = distribution
.query_release(query("example/plugins"), vec![], None)
.await
.unwrap();
assert_eq!(cached.queried_at, recovered.queried_at);
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert!(refreshed.queried_at > recovered.queried_at);
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
@@ -101,7 +101,7 @@ async fn cancelling_a_cache_waiter_does_not_cancel_or_poison_the_fetch_owner() {
.set_body_json(release())
.set_delay(Duration::from_millis(500)),
)
.expect(1)
.expect(2)
.mount(&server)
.await;
let distribution = Arc::new(transport(&server));
@@ -124,12 +124,15 @@ async fn cancelling_a_cache_waiter_does_not_cancel_or_poison_the_fetch_owner() {
.unwrap()
.unwrap()
.unwrap();
let cached = distribution
assert_eq!(first.tag, "v1.0.0");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
// 等待者取消不影响持有者完成,也不阻止之后的显式查询刷新结果。
let refreshed = distribution
.query_release(query("example/plugins"), vec![], None)
.await
.unwrap();
assert_eq!(cached.queried_at, first.queried_at);
assert_eq!(server.received_requests().await.unwrap().len(), 1);
assert!(refreshed.queried_at > first.queried_at);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
+1 -1
View File
@@ -1725,7 +1725,7 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta
查询请求为 `{ "query": { "repository": "owner/repo", "tag": null, "allowPrerelease": false }, "credentialIds": [], "outboundProxyId": null }`。
`tag: null` 查询最新稳定版;预发行版须指定 tag 并允许预发行。响应包含固定 tag、产物列表、`queriedAt`
和 `expiresAt`。成功缓存 1 小时,失败缓存 30 秒,同一查询合并并发;限流返回错误,不转换成空列表。
和 `expiresAt`。显式查询刷新成功结果,同一批并发查询共享结果;下载固定 tag 的产物可复用 1 小时内的元数据,失败缓存 30 秒;限流返回错误,不转换成空列表。
#### 下载认证与代理
+2
View File
@@ -30,6 +30,7 @@
"gsap": "^3.15.0",
"pinia": "^4.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"semver": "^7.8.5",
"vue": "^3.5.43",
"vue-router": "^5.3.1"
},
@@ -38,6 +39,7 @@
"@tailwindcss/vite": "^4.3.3",
"@types/css-tree": "^3.2.0",
"@types/node": "^26.6.2",
"@types/semver": "^7.8.0",
"@vitejs/plugin-vue": "^6.0.9",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.11.0",
+11
View File
@@ -208,6 +208,9 @@ importers:
pinia-plugin-persistedstate:
specifier: ^4.7.1
version: 4.7.1(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.43(typescript@6.0.3)))
semver:
specifier: ^7.8.5
version: 7.8.5
vue:
specifier: ^3.5.43
version: 3.5.43(typescript@6.0.3)
@@ -227,6 +230,9 @@ importers:
'@types/node':
specifier: ^26.6.2
version: 26.6.2
'@types/semver':
specifier: ^7.8.0
version: 7.8.0
'@vitejs/plugin-vue':
specifier: ^6.0.9
version: 6.0.9(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
@@ -900,6 +906,9 @@ packages:
'@types/node@26.6.2':
resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==}
'@types/semver@7.8.0':
resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -3170,6 +3179,8 @@ snapshots:
dependencies:
undici-types: 8.9.0
'@types/semver@7.8.0': {}
'@types/trusted-types@2.0.7':
optional: true
@@ -68,7 +68,7 @@ watch(() => props.initialSection, value => section.value = value)
<BaseIconButton v-if="plugin.source?.source.kind === 'github' || plugin.source?.source.kind === 'url'" label="检查更新" variant="secondary" :disabled="busy" @click="$emit('checkUpdate', plugin)">
<RefreshCw class="size-4" />
</BaseIconButton>
<BaseIconButton label="更新插件" variant="secondary" :disabled="busy" @click="$emit('installVersion', plugin)">
<BaseIconButton label="手动安装版本" variant="secondary" :disabled="busy" @click="$emit('installVersion', plugin)">
<ArrowInDownSquareHalf pack="filled" class="size-5" />
</BaseIconButton>
</div>
@@ -19,8 +19,8 @@ import { CheckCircle2, Download, FileArchive, Link, PackageOpen } from '@lucide/
import { useEventListener, useSessionStorage } from '@vueuse/core'
import { isEqual } from 'es-toolkit'
import { computed, nextTick, reactive, shallowRef, watch } from 'vue'
import { formatDateTime } from '@/utils/date'
import { formatPluginFileSize, normalizePluginRepository, pluginInstallSelectionKey } from '../utils/model'
import { selectPluginReleaseAsset } from '../utils/updates'
import PluginAssetPicker from './PluginAssetPicker.vue'
import PluginDownloadAuthentication from './PluginDownloadAuthentication.vue'
import PluginHelpPopover from './PluginHelpPopover.vue'
@@ -80,7 +80,6 @@ interface InstallDraft {
github: Omit<typeof githubForm, 'asset'>
}
const drafts = useSessionStorage<Record<string, InstallDraft>>('cp-plugin-install-drafts', {}, { flush: 'sync' })
const draftKey = computed(() => props.updateSource ? `plugin:${props.updateSource.pluginId}` : 'install')
let activeDraftKey: string | null = null
const queriedGithubKey = shallowRef('')
const pendingAuthentication = shallowRef(false)
@@ -204,7 +203,7 @@ async function verifySelected() {
}
function reset() {
activeDraftKey = props.acceptanceArtifact ? null : draftKey.value
activeDraftKey = props.acceptanceArtifact || props.updateSource ? null : 'install'
uploadFile.value = null
Object.assign(urlForm, {
url: '',
@@ -236,7 +235,6 @@ function reset() {
}
if (previousGithub?.kind === 'github') {
githubForm.repository = previousGithub.repository
githubForm.tag = previousGithub.tag
githubForm.credentialIds = previousGithub.credential_ids.filter(id => props.credentials.some(credential => credential.id === id))
githubForm.outboundProxyId = previousGithub.outbound_proxy?.id ?? ''
}
@@ -254,12 +252,11 @@ function reset() {
}
githubForm.repository = source.source.repository
githubForm.outboundProxyId = source.outboundProxyId ?? ''
if (source.policy.kind !== 'manual')
githubForm.tag = source.policy.kind === 'pinned' ? source.policy.tag : ''
githubForm.tag = source.policy.kind === 'pinned' ? source.policy.tag : ''
githubForm.allowPrerelease = source.policy.kind === 'pinned' && source.policy.allow_prerelease
}
const draft = drafts.value[draftKey.value]
if (draft && !props.acceptanceArtifact) {
const draft = activeDraftKey ? drafts.value[activeDraftKey] : null
if (draft) {
Object.assign(urlForm, draft.url, { credentialIds: draft.url.credentialIds.filter(id => props.credentials.some(credential => credential.id === id)) })
Object.assign(githubForm, draft.github, { credentialIds: draft.github.credentialIds.filter(id => props.credentials.some(credential => credential.id === id)) })
emit('changeMode', draft.mode)
@@ -410,8 +407,8 @@ watch(
watch(
() => props.release,
async (release) => {
// 唯一归档可直接解析,多个归档交给用户选择,平台兼容性仍由包检查器确认。
githubForm.asset = release && releaseAssets.value.length === 1 ? releaseAssets.value[0]!.name : ''
const previous = props.installedArtifacts.find(artifact => artifact.metadata.pluginId === props.updateSource?.pluginId)
githubForm.asset = release ? selectPluginReleaseAsset(release, previous)?.name ?? '' : ''
if (githubForm.asset) {
await nextTick()
await verifySelected()
@@ -598,7 +595,7 @@ watch(
<BaseButton v-if="verified && !acceptanceArtifact" variant="ghost" :disabled="busy" class="mr-auto" @click="$emit('resetVerification')">
返回
</BaseButton>
<BaseButton v-if="!verified && mode === 'github' && release" variant="secondary" :disabled="busy || authenticationIncomplete" :title="`缓存至 ${formatDateTime(release.expiresAt)}`" @click="queryRelease">
<BaseButton v-if="!verified && mode === 'github' && release" variant="secondary" :disabled="busy || authenticationIncomplete" @click="queryRelease">
重新查询
</BaseButton>
<BaseButton variant="secondary" :disabled="busy" @click="open = false">
@@ -67,11 +67,12 @@ const tabOptions = [
</div>
<PluginDetailModal
v-model="management.showDetail.value"
:model-value="management.showDetail.value && !management.updateCheck.open.value"
:plugin="management.selectedPlugin.value"
:initial-section="management.detailSection.value"
:views="management.extensions.value"
:busy="management.savingInstance.value || management.uninstall.busy.value || Boolean(management.busyInstanceId.value || management.busyDigest.value)"
@update:model-value="management.showDetail.value = $event"
@accept="management.openAcceptance"
@edit="management.openEditInstance"
@enable="management.requestInstanceEnable"
@@ -116,7 +117,9 @@ const tabOptions = [
:plugin="management.updateCheck.plugin.value"
:result="management.updateCheck.result.value"
:checking="management.updateCheck.checking.value"
:upgrading="management.installing.value"
@install="management.updateCheck.open.value = false; management.openCheckedUpdate($event)"
@upgrade="management.upgradeCheckedPlugin"
/>
<PluginConfigurationModal
@@ -2,49 +2,87 @@
import type { PluginUpdateSelection } from '../composables/usePluginUpdateCheck'
import type { InstalledPlugin } from '../utils/catalog'
import { BaseButton, BaseModal, BaseTag } from '@codex-proxy/ui'
import { LoaderCircle } from '@lucide/vue'
import { ArrowRight, Download, LoaderCircle, ShieldCheck } from '@lucide/vue'
import { computed } from 'vue'
import { isNewerPluginVersion } from '../utils/updates'
import PluginHelpPopover from './PluginHelpPopover.vue'
import PluginPermissionSummary from './PluginPermissionSummary.vue'
const props = defineProps<{ plugin: InstalledPlugin | null, result: PluginUpdateSelection | null, checking: boolean }>()
defineEmits<{ install: [selection: PluginUpdateSelection] }>()
const props = defineProps<{ plugin: InstalledPlugin | null, result: PluginUpdateSelection | null, checking: boolean, upgrading: boolean }>()
defineEmits<{ install: [selection: PluginUpdateSelection], upgrade: [selection: PluginUpdateSelection] }>()
const open = defineModel<boolean>({ required: true })
const installed = computed(() => props.plugin?.artifacts.some(artifact => artifact.acceptedAt && (props.result?.artifact
? artifact.metadata.sha256 === props.result.artifact.metadata.sha256
: props.result?.release && artifact.source.kind === 'github' && artifact.source.repository === props.result.release.repository && artifact.source.tag === props.result.release.tag)))
const current = computed(() => props.plugin?.artifact.metadata)
const candidate = computed(() => props.result?.artifact?.metadata)
const newer = computed(() => Boolean(current.value && candidate.value && isNewerPluginVersion(current.value, candidate.value)))
const addedPermissions = computed(() => candidate.value?.permissionDescriptions.filter(permission => !current.value?.requestedPermissions.includes(permission.permission)) ?? [])
const canInstall = computed(() => props.result?.artifact || props.result?.release?.assets.some(asset => /\.(?:tar\.gz|tgz)$/i.test(asset.name)))
const status = computed(() => {
if (!candidate.value)
return '请选择插件包以确认版本与兼容性'
if (current.value?.sha256 === candidate.value.sha256)
return '当前已使用此版本'
if (newer.value)
return '发现新版本,插件包已校验'
return current.value?.version === candidate.value.version ? '版本号相同,插件包内容不同' : '未发现更高版本'
})
</script>
<template>
<BaseModal v-model="open" title="检查更新" description="仅查询来源,不安装或切换版本" size="md">
<BaseModal v-model="open" title="插件更新" :description="plugin?.artifact.metadata.displayName" size="md" :dismissible="!upgrading">
<div v-if="checking" role="status" class="flex items-center gap-2 py-4 text-cp-sm text-cp-text-secondary">
<LoaderCircle class="size-4 animate-spin motion-reduce:animate-none" />
{{ plugin?.source?.source.kind === 'url' ? '正在下载并解析插件包' : '正在查询发布版本' }}
正在检查版本并校验插件包
</div>
<div v-else-if="result" class="grid gap-3 text-cp-sm">
<div class="flex flex-wrap items-center gap-2">
<span class="min-w-0 flex-1 break-words">{{ plugin?.artifact.metadata.displayName }}</span>
<BaseTag type="primary">
{{ result.artifact?.metadata.version ?? result.release?.tag }}
</BaseTag>
<BaseTag v-if="result.release?.prerelease" type="warning">
预发行版
</BaseTag>
<div v-else-if="result" class="grid gap-4 text-cp-sm">
<div class="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-4 rounded-cp bg-cp-fill-alter p-4" aria-label="版本对比">
<div class="grid min-w-0 gap-2">
<span class="text-cp-xs text-cp-text-secondary">当前版本</span>
<strong class="break-all font-mono text-cp font-emphasis">{{ current?.version }}</strong>
</div>
<ArrowRight class="size-4 text-cp-text-quaternary" aria-hidden="true" />
<div class="grid min-w-0 gap-2">
<div class="flex flex-wrap items-center gap-2 text-cp-xs text-cp-text-secondary">
<span>{{ candidate ? '目标版本' : '发布标签' }}</span>
<BaseTag v-if="result.release?.prerelease" type="warning" size="sm">
预发行版
</BaseTag>
</div>
<strong class="break-all font-mono text-cp font-emphasis" :class="newer ? 'text-cp-primary-text' : 'text-cp-text'">{{ candidate?.version ?? result.release?.tag }}</strong>
</div>
</div>
<div class="flex items-center gap-1.5 text-cp-xs text-cp-text-secondary">
<span>{{ installed ? result.artifact ? '此版本已安装' : '此发布已有已安装版本' : result.artifact ? '发现未安装版本' : '发现未安装发布' }}</span>
<span role="status">{{ status }}</span>
<PluginHelpPopover label="更新检查说明">
GitHub 查询发布标签,标签不等于包内版本,继续安装时仍需校验插件包,URL 按包内版本和摘要比对,本地上传不支持检查更新
按插件包内版本判断升级,沿用当前设置,配置不兼容时保留当前版本并打开设置
</PluginHelpPopover>
</div>
<div v-if="newer && addedPermissions.length" class="grid gap-3">
<p class="m-0 font-emphasis">
新增访问权限
</p>
<PluginPermissionSummary :permissions="addedPermissions" />
<p class="m-0 text-cp-xs text-cp-text-secondary">
升级即授权新增权限,请确认信任此插件
</p>
</div>
<p v-else-if="newer" class="m-0 flex items-center gap-2 text-cp-xs text-cp-text-secondary">
<ShieldCheck class="size-4 shrink-0" aria-hidden="true" />
没有新增访问权限
</p>
<span v-if="!canInstall" class="text-cp-xs text-cp-text-secondary">此发布没有 tar.gz 或 tgz 插件包</span>
</div>
<template #footer>
<BaseButton variant="secondary" @click="open = false">
<BaseButton variant="secondary" :disabled="upgrading" @click="open = false">
{{ checking ? '取消' : '关闭' }}
</BaseButton>
<BaseButton v-if="result && canInstall" variant="primary" @click="$emit('install', result)">
{{ installed ? '查看安装选项' : '继续安装' }}
<BaseButton v-if="result?.instance && result.request && newer" variant="primary" :loading="upgrading" :disabled="upgrading" @click="$emit('upgrade', result)">
<template #icon>
<Download class="size-4" />
</template>
升级到 {{ candidate?.version }}
</BaseButton>
<BaseButton v-else-if="result && canInstall && (!candidate || (newer && !result.instance))" variant="primary" :disabled="upgrading" @click="$emit('install', result)">
{{ candidate ? '继续安装' : '选择插件包' }}
</BaseButton>
</template>
</BaseModal>
@@ -3,7 +3,7 @@ import type { PluginInstallMode } from '../components/PluginInstallModal.vue'
import type { InstalledPlugin } from '../utils/catalog'
import type { PluginInstallSelection } from '../utils/model'
import type { PluginUpdateSelection } from './usePluginUpdateCheck'
import type { PluginArtifact, PluginArtifactMutationResponse, PluginRelease, PluginUpdateSourceBinding, QueryPluginReleaseRequest, VerifiedPluginArtifact } from '@/api'
import type { PluginArtifact, PluginArtifactMutationResponse, PluginInstance, PluginRelease, PluginUpdateSourceBinding, QueryPluginReleaseRequest, VerifiedPluginArtifact } from '@/api'
import { toast } from '@codex-proxy/ui'
import { isEqual } from 'es-toolkit'
import { onScopeDispose, shallowRef, watch } from 'vue'
@@ -13,7 +13,7 @@ import { pluginInstallSelectionKey } from '../utils/model'
interface InstallationContext {
runAction: <T>(flag: Ref<boolean>, title: string, task: () => Promise<T>) => Promise<T | undefined>
notifyError: (title: string, error: unknown) => void
onInstalled: (result: PluginArtifactMutationResponse) => Promise<void>
onInstalled: (result: PluginArtifactMutationResponse, switchTarget?: PluginInstance) => Promise<void>
onSourceSaved: (source: PluginUpdateSourceBinding) => void
}
@@ -151,6 +151,29 @@ export function usePluginInstallation({ runAction, notifyError, onInstalled, onS
await onInstalled(result)
}
async function installUpdate(selection: PluginUpdateSelection) {
const { artifact, request, instance, binding } = selection
if (!artifact || !request || !instance)
return false
const completed = await runAction(installing, '插件升级失败', async () => {
const latest = (await getPluginUpdateSources({ silent: true })).find(value => value.pluginId === binding.pluginId)
if (!isEqual(binding, latest))
throw new Error('安装来源已变更,请重新检查更新')
const { pluginId, version, sha256 } = artifact.metadata
const result = await installRemotePlugin({
pluginId,
version,
credentialIds: request.credentialIds,
outboundProxyId: request.outboundProxyId,
location: { ...request.location, sha256 },
}, { silent: true })
// 使用检查时的实例 revision,避免下载期间的设置变更被升级覆盖。
await onInstalled(result, instance)
return true
})
return completed === true
}
async function acceptArtifact(artifact: PluginArtifact) {
if (acceptanceArtifact.value?.metadata.sha256 !== artifact.metadata.sha256) {
toast.warning('请选择待安装的插件版本')
@@ -231,6 +254,7 @@ export function usePluginInstallation({ runAction, notifyError, onInstalled, onS
resetReleaseQuery,
queryRelease,
installArtifact,
installUpdate,
acceptArtifact,
}
}
@@ -1,4 +1,5 @@
import type { Ref } from 'vue'
import type { PluginUpdateSelection } from './usePluginUpdateCheck'
import type {
ConfigurePluginInstanceRequest,
CreatePluginSourceCredentialRequest,
@@ -173,13 +174,22 @@ export function usePluginManagement() {
})
const updateCheck = usePluginUpdateCheck(credentials, notifyError)
async function onInstalled({ artifact, defaultInstanceId, configurationRequired }: PluginArtifactMutationResponse) {
async function upgradeCheckedPlugin(selection: PluginUpdateSelection) {
if (await installation.installUpdate(selection))
updateCheck.open.value = false
}
async function onInstalled({ artifact, defaultInstanceId, configurationRequired }: PluginArtifactMutationResponse, switchTarget?: PluginInstance) {
const artifactIndex = artifacts.value.findIndex(value => value.metadata.sha256 === artifact.metadata.sha256)
artifacts.value = artifactIndex < 0
? [...artifacts.value, artifact]
: artifacts.value.map((value, index) => index === artifactIndex ? artifact : value)
selectedPluginId.value = artifact.metadata.pluginId
await refresh(true)
if (switchTarget) {
await applyVersionSwitch(switchTarget, artifact)
return
}
if (configurationRequired && defaultInstanceId) {
const instance = instances.value.find(instance => instance.id === defaultInstanceId)
if (instance) {
@@ -361,6 +371,10 @@ export function usePluginManagement() {
if (!pendingVersionSwitch.value || busyInstanceId.value)
return
const { instance, artifact } = pendingVersionSwitch.value
await applyVersionSwitch(instance, artifact)
}
async function applyVersionSwitch(instance: PluginInstance, artifact: PluginArtifact) {
busyInstanceId.value = instance.id
try {
await switchPluginVersion({
@@ -552,6 +566,7 @@ export function usePluginManagement() {
return {
...installation,
updateCheck,
upgradeCheckedPlugin,
catalog,
selectedPlugin,
showDetail,
@@ -1,15 +1,19 @@
import type { Ref } from 'vue'
import type { InstalledPlugin } from '../utils/catalog'
import type { PluginArtifact, PluginRelease, PluginSourceCredential, PluginUpdateSourceBinding, VerifiedPluginArtifact } from '@/api'
import type { PluginArtifact, PluginInstance, PluginRelease, PluginSourceCredential, PluginUpdateSourceBinding, VerifiedPluginArtifact, VerifyRemotePluginRequest } from '@/api'
import { isEqual } from 'es-toolkit'
import { onScopeDispose, shallowRef, watch } from 'vue'
import { checkPluginUpdate, getPluginUpdateSources, queryPluginRelease, verifyRemotePlugin } from '@/api'
import { currentPluginInstance } from '../utils/catalog'
import { selectPluginReleaseAsset } from '../utils/updates'
export interface PluginUpdateSelection {
binding: PluginUpdateSourceBinding
credentialIds: string[]
release?: PluginRelease
artifact?: VerifiedPluginArtifact
request?: VerifyRemotePluginRequest
instance?: PluginInstance
}
export function usePluginUpdateCheck(credentials: Ref<PluginSourceCredential[]>, notifyError: (title: string, error: unknown) => void) {
@@ -53,11 +57,23 @@ export function usePluginUpdateCheck(credentials: Ref<PluginSourceCredential[]>,
? await queryPluginRelease({ query: { repository: source.repository, tag: null, allowPrerelease: false }, credentialIds, outboundProxyId: binding.outboundProxyId }, options)
: (await checkPluginUpdate({ pluginId: target.id, credentialIds }, options)).release
candidate = { binding, credentialIds, release }
const asset = selectPluginReleaseAsset(release, target.artifact)
if (asset) {
candidate.request = {
expectedPluginId: target.id,
credentialIds,
outboundProxyId: binding.outboundProxyId,
location: { kind: 'github', repository: release.repository, tag: release.tag, asset: asset.name, allow_prerelease: release.prerelease, sha256: asset.sha256 },
}
candidate.artifact = await verifyRemotePlugin(candidate.request, options)
}
}
else {
const artifact = await verifyRemotePlugin({ expectedPluginId: target.id, credentialIds, outboundProxyId: binding.outboundProxyId, location: { kind: 'url', url: source.url, sha256: null } }, options)
candidate = { binding, credentialIds, artifact }
const request: VerifyRemotePluginRequest = { expectedPluginId: target.id, credentialIds, outboundProxyId: binding.outboundProxyId, location: { kind: 'url', url: source.url, sha256: null } }
const artifact = await verifyRemotePlugin(request, options)
candidate = { binding, credentialIds, artifact, request }
}
candidate.instance = currentPluginInstance(target)
const latest = (await getPluginUpdateSources(options)).find(value => value.pluginId === target.id)
if (!isEqual(binding, latest))
throw new Error('安装来源已变更,请重新检查')
@@ -0,0 +1,28 @@
import type { PluginArtifact, PluginArtifactMetadata, PluginRelease } from '@/api'
import gt from 'semver/functions/gt'
import valid from 'semver/functions/valid'
export function isNewerPluginVersion(current: PluginArtifactMetadata, candidate: PluginArtifactMetadata) {
return current.pluginId === candidate.pluginId
&& current.sha256 !== candidate.sha256
&& Boolean(valid(current.version) && valid(candidate.version))
&& gt(candidate.version, current.version)
}
export function selectPluginReleaseAsset(release: PluginRelease, previous?: PluginArtifact) {
const assets = release.assets.filter(asset => /\.(?:tar\.gz|tgz)$/i.test(asset.name))
if (assets.length === 1)
return assets[0]
if (previous?.source.kind !== 'github' || previous.source.repository !== release.repository)
return undefined
// 仅沿用已验证包的命名与平台后缀;无法唯一匹配时交由用户选择,最终仍校验包内平台。
const { asset } = previous.source
const version = previous.metadata.version
const index = asset.indexOf(version)
if (index < 0 || index !== asset.lastIndexOf(version))
return undefined
const prefix = asset.slice(0, index)
const suffix = asset.slice(index + version.length)
const matches = assets.filter(candidate => candidate.name.startsWith(prefix) && candidate.name.endsWith(suffix))
return matches.length === 1 ? matches[0] : undefined
}