fix(plugins): check and update installed plugins with provenance guards

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-21 00:09:50 +08:00
1 parent d715b16ba5
commit cab674299a
26 files changed
+1180 -44

No files matched your search

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { BadgeCheck, Check, ChevronRight, CircleAlert, Download, ExternalLink, FileUp, FolderTree, Globe, Info, LayoutGrid, Link2, List, Loader2, PackageCheck, Pencil, Pin, PinOff, Plus, RefreshCw, RotateCcw, Search, Settings2, ShieldCheck, Store, Trash2 } from "@lucide/vue";
import { ArrowUp, BadgeCheck, Check, ChevronRight, CircleAlert, Download, ExternalLink, FileUp, FolderTree, Globe, Info, LayoutGrid, Link2, List, Loader2, PackageCheck, Pencil, Pin, PinOff, Plus, RefreshCw, RotateCcw, Search, Settings2, ShieldCheck, Store, Trash2 } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/composables/useToast";
import PluginIcon from "@/components/plugins/PluginIcon.vue";
@@ -17,9 +18,20 @@ import { loadPinnedPluginIds, savePinnedPluginIds, sortPluginsPinnedFirst } from
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
import { beaconPluginInstall, buildMarketplacePluginListings, filterMarketplacePluginListings, listingRepositoryCanVerify, marketplaceHomepageUrl, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
import {
beaconPluginInstall,
buildInstalledUpdateIndex,
buildMarketplacePluginListings,
filterMarketplacePluginListings,
listingRepositoryCanVerify,
marketplaceHomepageUrl,
pluginSourceChange,
type InstalledPluginUpdateEntry,
type MarketplacePluginListing,
type PluginSourceChange,
} from "@/lib/plugins/pluginMarketplace";
import { isBatchSelectableListing, runBatch } from "@/lib/plugins/pluginBatch";
import { COMPONENT_PLUGINS_UPDATED_EVENT, notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents";
import { COMPONENT_PLUGINS_UPDATED_EVENT, notifyComponentPluginsUpdated, notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents";
import { formatBytes } from "@/lib/database/serverMetrics";
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -74,6 +86,8 @@ const installUrl = ref("");
const urlInstalling = ref(false);
const urlDownloadProgress = ref<{ downloaded: number; total: number | null } | null>(null);
const marketplaceInstallingKey = ref("");
const marketplaceUnavailable = ref(false);
const installedUpdateProgress = ref<{ current: number; total: number } | null>(null);
const operating = ref(false);
const error = ref("");
const selectedPluginId = ref("");
@@ -133,6 +147,41 @@ const batchUpdatableListings = computed(() => filteredMarketplaceListings.value.
const batchSelectedListings = computed(() => filteredMarketplaceListings.value.filter((listing) => isBatchSelectableListing(listing.status) && selectedListingKeys.value.has(listing.key)));
const batchSelectedInstalled = computed(() => definitions.value.filter((definition) => selectedInstalledIds.value.has(definition.plugin.manifest.id)));
const catalogErrors = computed(() => catalogResults.value.filter((result) => result.error));
// Installed-tab update join: the same marketplace listings the store tab renders, indexed by
// plugin id so installed rows/detail can show "update available" without a second fetch.
const installedUpdateIndex = computed(() => buildInstalledUpdateIndex(marketplaceListings.value));
const installedUpdateCount = computed(() => installedUpdateIndex.value.size);
const installedUpdateEntries = computed(() => definitions.value.map((definition) => installedUpdateIndex.value.get(definition.plugin.manifest.id)).filter((entry): entry is InstalledPluginUpdateEntry => !!entry));
const selectedUpdateEntry = computed(() => (selectedDefinition.value && installedUpdateIndex.value.get(selectedDefinition.value.plugin.manifest.id)) || null);
const repositoriesEnabled = computed(() => repositories.value.some((repository) => repository.enabled));
// "Checked" means a catalog actually loaded: a failed/missing fetch must never read as up to date.
const catalogChecked = computed(() => !marketplaceLoading.value && !marketplaceUnavailable.value && repositoriesEnabled.value && catalogResults.value.some((result) => result.catalog));
// The backend answers a mixed fetch with successful catalogs AND per-repository errors, so
// "some catalog loaded" is not enough: one failing enabled repository makes the whole check
// incomplete (plugins whose only source is that repo would read as "not in repositories" /
// "all up to date").
const catalogPartialFailure = computed(() => repositoriesEnabled.value && catalogResults.value.some((result) => result.error));
function installedUpdateEntryFor(pluginId: string): InstalledPluginUpdateEntry | null {
return installedUpdateIndex.value.get(pluginId) || null;
}
function pluginInCatalog(pluginId: string): boolean {
return marketplaceListings.value.some((listing) => listing.plugin.id === pluginId);
}
// Catalog listings that do exist for an installed plugin but ship no artifact for this platform:
// their update status cannot be checked either, so it gets the same visible treatment as the
// "not in any catalog" case instead of reading as up to date.
const installedUnsupportedListings = computed(() => {
const byId = new Map<string, MarketplacePluginListing>();
for (const listing of marketplaceListings.value) {
if (listing.status !== "unsupported" || !listing.installed) continue;
if (!byId.has(listing.plugin.id)) byId.set(listing.plugin.id, listing);
}
return byId;
});
function installedUnsupportedListingFor(pluginId: string): MarketplacePluginListing | null {
if (installedUpdateIndex.value.has(pluginId)) return null;
return installedUnsupportedListings.value.get(pluginId) || null;
}
const customRepositories = computed(() => repositories.value.filter((repository) => !repository.managed));
const showCustomRepositoryTrustSettings = computed(() => customRepositories.value.length > 0 || trustedKeys.value.length > 0);
const pluginDevelopmentDocsUrl = computed(() => `https://dbxio.com/${appLocale.value.startsWith("zh") ? "cn" : "en"}/docs/plugin-development`);
@@ -175,8 +224,12 @@ async function refreshMarketplace() {
marketplaceLoading.value = true;
try {
catalogResults.value = await api.fetchPluginMarketplaceCatalogs();
marketplaceUnavailable.value = false;
} catch (cause) {
catalogResults.value = [];
// Track total failure separately: catalogErrors is derived from catalogResults, which is
// empty here, so without this flag the installed tab would silently read as "up to date".
marketplaceUnavailable.value = true;
toast(cause instanceof Error ? cause.message : String(cause), 5000);
} finally {
marketplaceLoading.value = false;
@@ -198,24 +251,48 @@ function notifyPluginRuntimeReplaced(pluginId: string) {
if (!isTauriRuntime()) emit("pluginRuntimeReplaced", pluginId);
}
async function installListing(listing: MarketplacePluginListing): Promise<PluginInstallResult> {
// A recorded provenance that differs from the candidate listing (repository / publisher / signing
// key) requires an explicit confirmation before the plugin may be replaced. The backend enforces
// the same rule and rejects the install without allowSourceChange; this pre-check keeps that
// rejection out of the user's way by asking up front.
const pendingSourceChange = ref<{ listing: MarketplacePluginListing; change: PluginSourceChange } | null>(null);
function confirmUpdateSourceChange(listing: MarketplacePluginListing): boolean {
const change = pluginSourceChange(listing);
if (!change) return true;
pendingSourceChange.value = { listing, change };
return false;
}
function proceedUpdateSourceChange() {
const pending = pendingSourceChange.value;
pendingSourceChange.value = null;
if (pending) void installMarketplaceListing(pending.listing, { allowSourceChange: true, skipSourceConfirmation: true });
}
function cancelUpdateSourceChange() {
pendingSourceChange.value = null;
}
async function installListing(listing: MarketplacePluginListing, allowSourceChange = false): Promise<PluginInstallResult> {
const result = await api.installMarketplacePlugin({
repositoryId: listing.repository.id,
pluginId: listing.plugin.id,
version: listing.plugin.latestVersion,
allowSourceChange: allowSourceChange || undefined,
});
notifyPluginRuntimeReplaced(result.plugin.manifest.id);
beaconPluginInstall(listing.plugin.id, listing.plugin.latestVersion, listing.status === "update" ? "update" : "install");
return result;
}
async function installMarketplaceListing(listing: MarketplacePluginListing) {
async function installMarketplaceListing(listing: MarketplacePluginListing, options: { allowSourceChange?: boolean; skipSourceConfirmation?: boolean } = {}) {
if (mutationRunning.value || !listing.artifact || !isBatchSelectableListing(listing.status)) return;
if (!options.skipSourceConfirmation && !confirmUpdateSourceChange(listing)) return;
marketplaceInstallingKey.value = listing.key;
try {
const result = await installListing(listing);
const result = await installListing(listing, options.allowSourceChange === true);
toast(t(listing.status === "update" ? "pluginPlatform.updateSuccess" : "pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
installedPlugins.value = await api.listPlugins();
// The COMPONENT_PLUGINS_UPDATED_EVENT handler does the panel-side refresh (icon cache +
// installed list); notifyComponentUpdatesChanged drives the App-level update-center badge.
notifyComponentPluginsUpdated();
notifyComponentUpdatesChanged();
selectPlugin(result.plugin.manifest.id);
} catch (cause) {
@@ -248,6 +325,44 @@ async function refreshAfterBatch() {
}
}
async function updateInstalledPlugin(pluginId: string) {
const entry = installedUpdateEntryFor(pluginId);
if (!entry || mutationRunning.value) return;
await installMarketplaceListing(entry.listing);
}
async function runUpdateAllInstalled() {
const entries = installedUpdateEntries.value.filter((entry) => !pluginSourceChange(entry.listing));
const sourceChanged = installedUpdateEntries.value.filter((entry) => pluginSourceChange(entry.listing));
if (sourceChanged.length) toast(t("pluginPlatform.batchSourceChangeSkipped", { names: sourceChanged.map((entry) => entry.listing.name).join("、") }), 8000);
if (!entries.length || mutationRunning.value) return;
batchRunning.value = true;
error.value = "";
installedUpdateProgress.value = { current: 0, total: entries.length };
try {
const outcome = await runBatch(
entries,
(entry) => entry.listing.name,
async (entry) => {
await installListing(entry.listing);
},
(current, total) => {
installedUpdateProgress.value = { current, total };
},
);
if (outcome.succeeded.length) {
clearPluginIconCache();
notifyComponentPluginsUpdated();
notifyComponentUpdatesChanged();
}
reportBatchSummary(outcome);
await refreshAfterBatch();
} finally {
batchRunning.value = false;
installedUpdateProgress.value = null;
}
}
async function refreshAfterExternalPluginUpdate() {
clearPluginIconCache();
try {
@@ -314,6 +429,8 @@ async function runBatchInstallUpdate() {
pluginIds.add(listing.plugin.id);
}
if (duplicateIds.size) return toast(t("pluginPlatform.batchDuplicateSources", { names: [...duplicateIds].join("、") }), 8000);
const sourceChangedListings = targets.filter((listing) => pluginSourceChange(listing));
if (sourceChangedListings.length) return toast(t("pluginPlatform.batchSourceChangeSkipped", { names: sourceChangedListings.map((listing) => listing.name).join("、") }), 8000);
batchRunning.value = true;
error.value = "";
try {
@@ -546,6 +663,7 @@ async function finishInstall(result: PluginInstallResult) {
reportInstallBeacon(result);
toast(t("pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
clearPluginIconCache();
notifyComponentPluginsUpdated();
installedPlugins.value = await api.listPlugins();
notifyComponentUpdatesChanged();
selectPlugin(result.plugin.manifest.id);
@@ -776,7 +894,10 @@ onBeforeUnmount(() => {
<Tabs v-model="activeSection" class="min-h-0 flex-1 gap-3">
<TabsList class="grid h-9 w-full grid-cols-3">
<TabsTrigger value="marketplace" class="gap-1.5 text-xs"><Store class="size-3.5" />{{ t("pluginPlatform.marketplace") }}</TabsTrigger>
<TabsTrigger value="installed" class="gap-1.5 text-xs"><PackageCheck class="size-3.5" />{{ t("pluginPlatform.installed") }}</TabsTrigger>
<TabsTrigger value="installed" class="gap-1.5 text-xs">
<PackageCheck class="size-3.5" />{{ t("pluginPlatform.installed") }}
<span v-if="installedUpdateCount" class="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold text-amber-950 dark:text-amber-950">{{ installedUpdateCount > 99 ? "99+" : installedUpdateCount }}</span>
</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5 text-xs"><Settings2 class="size-3.5" />{{ t("pluginPlatform.settings") }}</TabsTrigger>
</TabsList>
@@ -1019,6 +1140,55 @@ onBeforeUnmount(() => {
</Button>
<Button variant="ghost" size="sm" class="h-8 text-xs" :disabled="batchRunning" @click="toggleBatchMode">{{ t("common.cancel") }}</Button>
</template>
<Button variant="ghost" size="icon-sm" class="shrink-0" :class="batchMode ? '' : 'ml-auto'" :disabled="marketplaceLoading" :title="t('pluginPlatform.refresh')" :aria-label="t('pluginPlatform.refresh')" @click="refreshMarketplace"
><RefreshCw class="size-3.5" :class="marketplaceLoading ? 'animate-spin' : ''"
/></Button>
</div>
<!-- Partial catalog failure (some enabled repositories errored): the check is incomplete,
so it is surfaced on its own and must never degrade into "all up to date". -->
<div v-if="catalogPartialFailure" class="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
<CircleAlert class="mt-0.5 size-3.5 shrink-0" />
<div>{{ t("pluginPlatform.updateCheckPartialFailure") }}</div>
</div>
<div v-for="result in catalogErrors" :key="`installed-${result.repository.id}`" class="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
<CircleAlert class="mt-0.5 size-3.5 shrink-0" />
<div>
<span class="font-medium">{{ result.repository.name }}:</span> {{ result.error }}
</div>
</div>
<!-- Honest update-check states: a failed or missing catalog must never read as "up to date". -->
<div v-if="marketplaceUnavailable" class="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
<CircleAlert class="mt-0.5 size-3.5 shrink-0" />
<div class="min-w-0 flex-1">
<span class="font-medium">{{ t("pluginPlatform.updateCheckUnavailable") }}</span> · {{ t("pluginPlatform.updateCheckUnavailableDescription") }}
</div>
<Button variant="outline" size="sm" class="h-6 shrink-0 text-xs" :disabled="marketplaceLoading" @click="refreshMarketplace"><RefreshCw class="size-3" :class="marketplaceLoading ? 'animate-spin' : ''" />{{ t("pluginPlatform.refresh") }}</Button>
</div>
<div v-else-if="!repositoriesEnabled && installedPlugins.length" class="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs text-muted-foreground">
<CircleAlert class="mt-0.5 size-3.5 shrink-0" />
<div>{{ t("pluginPlatform.noRepositoriesEnabled") }}</div>
</div>
<div v-else-if="installedUpdateCount" class="flex flex-wrap items-center gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-2.5">
<div class="min-w-0">
<div class="text-sm font-semibold">{{ t("pluginPlatform.installedUpdatesAvailableTitle", { count: installedUpdateCount }) }}</div>
<p class="text-xs text-muted-foreground">{{ t("pluginPlatform.installedUpdatesAvailableDescription") }}</p>
</div>
<div class="ml-auto flex shrink-0 items-center gap-2">
<Button size="sm" class="h-7 text-xs" :disabled="mutationRunning" @click="runUpdateAllInstalled">
<Loader2 v-if="installedUpdateProgress" class="size-3 animate-spin" />
<Download v-else class="size-3" />
{{ installedUpdateProgress ? t("pluginPlatform.updatingProgress", installedUpdateProgress) : t("pluginPlatform.updateAll") }}
</Button>
</div>
</div>
<div v-else-if="catalogChecked && !catalogPartialFailure && installedPlugins.length" class="flex items-center gap-2 rounded-lg border px-3 py-2 text-xs text-muted-foreground">
<BadgeCheck class="size-3.5 text-emerald-600 dark:text-emerald-400" />
<div>{{ t("pluginPlatform.allPluginsUpToDate") }}</div>
</div>
<div v-else-if="marketplaceLoading && installedPlugins.length" class="flex items-center gap-2 rounded-lg border px-3 py-2 text-xs text-muted-foreground">
<Loader2 class="size-3.5 animate-spin" />
<div>{{ t("pluginPlatform.checkingForUpdates") }}</div>
</div>
<div class="grid min-h-0 flex-1 gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
<div class="space-y-1 rounded-lg border bg-muted/10 p-2">
@@ -1042,7 +1212,24 @@ onBeforeUnmount(() => {
<span class="block truncate text-sm font-medium">{{ definition.plugin.manifest.name }}</span>
<span class="mt-1 flex flex-wrap gap-1">
<Badge variant="outline" class="h-4 px-1.5 text-[10px]">v{{ definition.plugin.manifest.version || "-" }}</Badge>
<span
v-if="installedUpdateEntryFor(definition.plugin.manifest.id)"
class="inline-flex h-4 items-center gap-0.5 rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 text-[10px] font-medium text-amber-700 dark:text-amber-300"
:title="t('pluginPlatform.installedVersionUpdatable', { installed: definition.plugin.manifest.version || '0.0.0', latest: installedUpdateEntryFor(definition.plugin.manifest.id)?.listing.plugin.latestVersion })"
>
<ArrowUp class="size-2.5" />v{{ installedUpdateEntryFor(definition.plugin.manifest.id)?.listing.plugin.latestVersion }}
</span>
<Badge :variant="definition.plugin.compatibility.compatible ? 'secondary' : 'destructive'" class="h-4 px-1.5 text-[10px]">{{ definition.plugin.compatibility.compatible ? t("pluginPlatform.compatible") : t("pluginPlatform.blocked") }}</Badge>
<Badge v-if="catalogChecked && !catalogPartialFailure && !pluginInCatalog(definition.plugin.manifest.id)" variant="outline" class="h-4 border-dashed px-1.5 text-[10px] text-muted-foreground" :title="t('pluginPlatform.notInRepositoriesHint')">{{
t("pluginPlatform.notInRepositories")
}}</Badge>
<Badge
v-else-if="installedUnsupportedListingFor(definition.plugin.manifest.id)"
variant="outline"
class="h-4 border-dashed px-1.5 text-[10px] text-muted-foreground"
:title="t('pluginPlatform.unsupportedTarget', { target: installedUnsupportedListingFor(definition.plugin.manifest.id)?.target })"
>{{ t("pluginPlatform.marketplaceStatus.unsupported") }}</Badge
>
</span>
</span>
</button>
@@ -1098,9 +1285,18 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<div class="flex gap-2">
<Button size="sm" variant="outline" class="gap-1.5" :disabled="mutationRunning" @click="rollbackSelectedPlugin"><RotateCcw class="size-3.5" />{{ t("pluginPlatform.rollback") }}</Button>
<Button size="sm" variant="outline" class="gap-1.5 text-destructive" :disabled="mutationRunning" @click="uninstallSelectedPlugin"><Trash2 class="size-3.5" />{{ t("pluginPlatform.uninstall") }}</Button>
<div class="flex flex-col items-end gap-1.5">
<div class="flex gap-2">
<Button v-if="selectedUpdateEntry" size="sm" class="gap-1.5" :disabled="mutationRunning" @click="updateInstalledPlugin(selectedUpdateEntry.listing.plugin.id)">
<Loader2 v-if="marketplaceInstallingKey === selectedUpdateEntry.listing.key" class="size-3.5 animate-spin" />
<Download v-else class="size-3.5" />{{ t("pluginPlatform.updateToVersion", { version: selectedUpdateEntry.listing.plugin.latestVersion }) }}
</Button>
<Button size="sm" variant="outline" class="gap-1.5" :disabled="mutationRunning" @click="rollbackSelectedPlugin"><RotateCcw class="size-3.5" />{{ t("pluginPlatform.rollback") }}</Button>
<Button size="sm" variant="outline" class="gap-1.5 text-destructive" :disabled="mutationRunning" @click="uninstallSelectedPlugin"><Trash2 class="size-3.5" />{{ t("pluginPlatform.uninstall") }}</Button>
</div>
<span v-if="selectedUpdateEntry" class="text-[11px] text-muted-foreground"
>{{ t("pluginPlatform.installedVersionUpdatable", { installed: selectedDefinition?.plugin.manifest.version || "0.0.0", latest: selectedUpdateEntry.listing.plugin.latestVersion }) }} · {{ selectedUpdateEntry.repositoryName }}</span
>
</div>
</div>
@@ -1278,6 +1474,62 @@ onBeforeUnmount(() => {
</TabsContent>
</Tabs>
<Dialog
:open="!!pendingSourceChange"
@update:open="
(open: boolean) => {
if (!open) cancelUpdateSourceChange();
}
"
>
<DialogContent class="max-w-md gap-4">
<DialogHeader>
<DialogTitle class="text-sm">{{ t("pluginPlatform.updateSourceChangeTitle") }}</DialogTitle>
<DialogDescription class="text-xs leading-5">{{ t("pluginPlatform.updateSourceChangeBody") }}</DialogDescription>
</DialogHeader>
<div v-if="pendingSourceChange" class="grid grid-cols-2 gap-2 text-xs">
<div class="rounded-lg border p-3">
<div class="mb-2 text-[11px] font-semibold text-muted-foreground">{{ t("pluginPlatform.provenanceCurrentInstall") }}</div>
<dl class="space-y-1">
<div class="flex justify-between gap-2">
<dt class="shrink-0 text-muted-foreground">{{ t("pluginPlatform.provenanceRepository") }}</dt>
<dd class="truncate">{{ pendingSourceChange.listing.installed?.provenance?.repositoryId || "-" }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="shrink-0 text-muted-foreground">{{ t("pluginPlatform.provenancePublisher") }}</dt>
<dd class="truncate">{{ pendingSourceChange.listing.installed?.provenance?.publisher || "-" }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="shrink-0 text-muted-foreground">{{ t("pluginPlatform.provenanceSigningKey") }}</dt>
<dd class="truncate font-mono text-[10px]">{{ pendingSourceChange.listing.installed?.provenance?.signingKeyId || "-" }}</dd>
</div>
</dl>
</div>
<div class="rounded-lg border border-amber-500/40 p-3">
<div class="mb-2 text-[11px] font-semibold text-amber-700 dark:text-amber-300">{{ t("pluginPlatform.provenanceCandidate") }}</div>
<dl class="space-y-1">
<div class="flex justify-between gap-2">
<dt class="shrink-0 text-muted-foreground">{{ t("pluginPlatform.provenanceRepository") }}</dt>
<dd class="truncate">{{ pendingSourceChange.listing.repository.id }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="shrink-0 text-muted-foreground">{{ t("pluginPlatform.provenancePublisher") }}</dt>
<dd class="truncate">{{ pendingSourceChange.listing.plugin.publisher }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="shrink-0 text-muted-foreground">{{ t("pluginPlatform.provenanceSigningKey") }}</dt>
<dd class="truncate font-mono text-[10px]">{{ pendingSourceChange.listing.artifact?.signingKeyId || "-" }}</dd>
</div>
</dl>
</div>
</div>
<DialogFooter class="gap-2">
<Button variant="outline" size="sm" @click="cancelUpdateSourceChange">{{ t("common.cancel") }}</Button>
<Button size="sm" :disabled="mutationRunning" @click="proceedUpdateSourceChange">{{ t("pluginPlatform.updateSourceChangeConfirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div v-if="draggingPackage" class="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-primary/5">
<FileUp class="size-8 text-primary" />
<div class="text-sm font-medium text-primary">{{ t("pluginPlatform.dropToInstall") }}</div>
@@ -68,6 +68,7 @@ type PanelState = {
marketplaceListings: MarketplacePluginListing[];
catalogResults: PluginRepositoryCatalogResult[];
installedPlugins: InstalledPlugin[];
repositories: PluginRepositoryCatalogResult["repository"][];
selectedListingKeys: Set<string>;
selectedInstalledIds: Set<string>;
selectedPluginId: string;
@@ -94,6 +95,18 @@ type PanelState = {
removeTrustedKey: (keyId: string) => Promise<void>;
toggleBatchMode: () => void;
toggleInstalledSelection: (pluginId: string) => void;
installedUpdateIndex: Map<string, { listing: MarketplacePluginListing; repositoryName: string }>;
installedUpdateCount: number;
installedUpdateProgress: { current: number; total: number } | null;
catalogChecked: boolean;
marketplaceUnavailable: boolean;
catalogPartialFailure: boolean;
pendingSourceChange: { listing: MarketplacePluginListing } | null;
installedUnsupportedListingFor: (pluginId: string) => MarketplacePluginListing | null;
updateInstalledPlugin: (pluginId: string) => Promise<void>;
runUpdateAllInstalled: () => Promise<void>;
proceedUpdateSourceChange: () => void;
refreshMarketplace: () => Promise<void>;
};
function installed(id: string, version = "1.0.0"): InstalledPlugin {
@@ -125,6 +138,14 @@ function catalog(repositoryId: string, ids: string[], version = "3.0.0"): Plugin
};
}
function unsupportedCatalog(repositoryId: string, ids: string[], version = "3.0.0"): PluginRepositoryCatalogResult {
const result = catalog(repositoryId, ids, version);
// No artifact for this platform on the catalog's latest version: the listing is "unsupported"
// rather than updatable, so the installed tab has to say so instead of reading as up to date.
for (const plugin of result.catalog!.plugins) plugin.versions[0].artifacts = [];
return result;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (cause: unknown) => void;
@@ -318,6 +339,117 @@ describe("PluginContributionsPanel installed plugin pin controls", () => {
});
});
describe("PluginContributionsPanel installed-tab updates", () => {
it("updates an installed plugin through the marketplace install path", async () => {
state.batchMode = false;
state.selectedPluginId = "a";
await nextTick();
expect(state.installedUpdateCount).toBe(3);
await state.updateInstalledPlugin("a");
expect(mocks.installMarketplacePlugin).toHaveBeenCalledExactlyOnceWith({ repositoryId: "first", pluginId: "a", version: "3.0.0" });
expect(mocks.listPlugins).toHaveBeenCalledTimes(1);
});
it("updates all installed updatable plugins in one batch run", async () => {
state.batchMode = false;
mocks.installMarketplacePlugin.mockRejectedValueOnce(new Error("update denied"));
await state.runUpdateAllInstalled();
expect(mocks.installMarketplacePlugin).toHaveBeenCalledTimes(3);
expect(state.error).toBe("a: update denied");
expect(state.installedUpdateProgress).toBeNull();
expect(state.batchRunning).toBe(false);
});
it("keeps source-changed plugins out of batch update and reports them", async () => {
const moved = installed("a");
moved.provenance = { repositoryId: "other-repo", publisher: "DBX", signingKeyId: "test.key", source: "marketplace" };
state.installedPlugins = [moved, installed("b"), installed("c")];
await nextTick();
state.batchMode = false;
await state.runUpdateAllInstalled();
expect(mocks.installMarketplacePlugin).toHaveBeenCalledTimes(2);
expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("pluginPlatform.batchSourceChangeSkipped"), 8000);
});
it("confirms a source change before updating with allowSourceChange", async () => {
const moved = installed("a");
moved.provenance = { repositoryId: "other-repo", publisher: "DBX", signingKeyId: "test.key", source: "marketplace" };
state.installedPlugins = [moved, installed("b"), installed("c")];
await nextTick();
state.batchMode = false;
state.selectedPluginId = "a";
await state.updateInstalledPlugin("a");
expect(mutationCount()).toBe(0);
expect(state.pendingSourceChange).not.toBeNull();
state.proceedUpdateSourceChange();
await flushUi();
expect(mocks.installMarketplacePlugin).toHaveBeenCalledExactlyOnceWith({ repositoryId: "first", pluginId: "a", version: "3.0.0", allowSourceChange: true });
expect(state.pendingSourceChange).toBeNull();
});
it("treats a failing enabled repository as an incomplete check, not as up to date", async () => {
state.batchMode = false;
state.repositories = [
{ id: "first", name: "first", kind: "custom", enabled: true, managed: false },
{ id: "second", name: "second", kind: "custom", enabled: true, managed: false },
];
// "first" answers fine (a is up to date through it); "second" — b's only source — errors.
state.catalogResults = [catalog("first", ["a"]), { ...catalog("second", ["b"]), catalog: undefined, error: "catalog offline" }];
state.installedPlugins = [installed("a", "3.0.0"), installed("b", "1.0.0")];
await nextTick();
expect(state.catalogPartialFailure).toBe(true);
// a is up to date via the first catalog, but the check is incomplete: no "all up to date".
expect(host.textContent).not.toContain("pluginPlatform.allPluginsUpToDate");
// b's only repository failed: it must not be labelled "not in repositories".
expect(host.textContent).not.toContain("pluginPlatform.notInRepositories");
// The incomplete state is surfaced instead, with the failing repository named.
expect(host.textContent).toContain("pluginPlatform.updateCheckPartialFailure");
expect(host.textContent).toContain("second: catalog offline");
});
it("flips to the up-to-date state only after a real catalog check", async () => {
state.batchMode = false;
state.repositories = [{ id: "first", name: "first", kind: "custom", enabled: true, managed: false }];
await nextTick();
expect(state.catalogChecked).toBe(true);
expect(state.installedUpdateCount).toBe(3);
mocks.fetchPluginMarketplaceCatalogs.mockRejectedValueOnce(new Error("offline"));
await state.refreshMarketplace();
expect(state.marketplaceUnavailable).toBe(true);
expect(state.catalogChecked).toBe(false);
await state.refreshMarketplace();
expect(state.marketplaceUnavailable).toBe(false);
state.installedPlugins = [installed("a", "3.0.0"), installed("b", "3.0.0"), installed("c", "3.0.0")];
await nextTick();
expect(state.catalogChecked).toBe(true);
expect(state.installedUpdateCount).toBe(0);
});
it("surfaces installed plugins whose catalog version has no artifact for this platform", async () => {
state.batchMode = false;
state.catalogResults = [unsupportedCatalog("first", ["a", "b", "c"])];
await nextTick();
expect(state.installedUpdateCount).toBe(0);
expect(state.installedUnsupportedListingFor("a")?.target).toBe("darwin-arm64");
expect(host.querySelector('[data-plugin-id="a"]')?.textContent).toContain("pluginPlatform.marketplaceStatus.unsupported:");
// The unsupported badge replaces the amber update marker, never both.
state.catalogResults = [catalog("first", ["a", "b", "c"])];
await nextTick();
expect(state.installedUnsupportedListingFor("a")).toBeNull();
expect(host.querySelector('[data-plugin-id="a"]')?.textContent).not.toContain("pluginPlatform.marketplaceStatus.unsupported:");
});
});
describe("PluginContributionsPanel external component updates", () => {
it("refreshes installed plugins when the update center updates plugins", async () => {
expect(state.marketplaceListings.every((listing) => listing.status === "update")).toBe(true);