fix(plugins): check and update installed plugins with provenance guards
This commit is contained in:
1 parent
d715b16ba5
commit
cab674299a
26 files changed
+1180
-44
No files matched your search
@@ -512,4 +512,31 @@ describe("UpdateDialog aggregate update center", () => {
|
||||
|
||||
expect(installComponentUpdates).toHaveBeenCalledWith("drivers");
|
||||
});
|
||||
|
||||
it("marks only a plugin whose update source changed as needing confirmation in the Plugin Center", async () => {
|
||||
const listing = (id: string, provenance: Record<string, string>) => ({
|
||||
key: `official:${id}`,
|
||||
status: "update",
|
||||
artifact: { target: "universal", url: "https://example.com/plugin.dbxp", sha256: "hash", signingKeyId: "key-a" },
|
||||
repository: { id: "official" },
|
||||
plugin: { id, latestVersion: "1.1.0", publisher: "DBX" },
|
||||
installed: { manifest: { id, version: "1.0.0" }, provenance },
|
||||
name: `${id} plugin`,
|
||||
});
|
||||
await mountDialog(0, {}, undefined, {
|
||||
updateInfo: null,
|
||||
pluginUpdates: [listing("same", { repositoryId: "official" }), listing("moved", { repositoryId: "other-repo" })],
|
||||
});
|
||||
|
||||
const pluginsTab = document.body.querySelector<HTMLButtonElement>('[data-update-tab="plugins"]');
|
||||
pluginsTab?.click();
|
||||
await flushDialog();
|
||||
|
||||
// "Update all" skips the changed-source plugin, so the dialog has to explain why it stays.
|
||||
const hint = "Confirm the change in the Plugin Center first";
|
||||
const rows = [...document.body.querySelectorAll<HTMLElement>(".rounded-md.border.p-3")];
|
||||
const rowFor = (name: string) => rows.find((row) => row.textContent?.includes(name));
|
||||
expect(rowFor("moved plugin")?.textContent).toContain(hint);
|
||||
expect(rowFor("same plugin")?.textContent).not.toContain(hint);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import type { UpdateInfo } from "@/lib/backend/api";
|
||||
import type { AgentDriverInfo, McpServerStatus, UpdateDownloadSource } from "@/lib/backend/tauri";
|
||||
import type { JdbcPluginStatus } from "@/types/database";
|
||||
import type { MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
|
||||
import { pluginSourceChange, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
|
||||
import { mcpUpdateAvailability } from "@/lib/mcp/mcpUpdateStatus";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { isUpdatePreviewMockEnabled } from "@/lib/updates/updatePreviewMock";
|
||||
@@ -302,6 +302,9 @@ watch(
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium">{{ plugin.name }}</div>
|
||||
<div class="mt-0.5 text-xs text-muted-foreground">{{ plugin.installed?.manifest.version || t("updates.notInstalled") }} → {{ plugin.plugin.latestVersion }}</div>
|
||||
<!-- "Update all" deliberately skips a changed source: the user has to confirm it in
|
||||
the Plugin Center, so say so instead of leaving an item that never updates. -->
|
||||
<div v-if="pluginSourceChange(plugin)" class="mt-1 text-xs text-amber-600 dark:text-amber-400">{{ t("pluginPlatform.updateSourceChangeRequired") }}</div>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">{{ t("settings.updateAvailable") }}</span>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+132
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user