mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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
@@ -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 秒;限流返回错误,不转换成空列表。
|
||||
|
||||
#### 下载认证与代理
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+11
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user