diff --git a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue
index cf1afd29e8..0e6074e380 100644
--- a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue
+++ b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue
@@ -205,6 +205,10 @@ async function installMarketplaceListing(listing: MarketplacePluginListing) {
selectPlugin(result.plugin.manifest.id);
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 8000);
+ // A failed install can still have mutated the store (a partially replaced version directory, for
+ // instance), so re-read the installed list instead of leaving the card on state it may no longer
+ // describe.
+ installedPlugins.value = await api.listPlugins().catch(() => installedPlugins.value);
} finally {
marketplaceInstallingKey.value = "";
}
@@ -870,6 +874,9 @@ onBeforeUnmount(() => {
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 56f138533e..f11444d20b 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -184,6 +184,7 @@ export default {
permissionsCount: "{count} permissions",
unsupportedTarget: "Not available for {target}",
installedVersion: "Installed v{version}",
+ installedVersionUpdatable: "Installed v{installed} · Update to v{latest}",
licenseUnknown: "License not specified",
sourceRepository: "Source repository",
pluginHomepage: "Homepage",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 40b12a045f..cd5bb6091d 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -187,6 +187,7 @@ export default withEnglishFallback({
permissionsCount: "{count} permisos",
unsupportedTarget: "No disponible para {target}",
installedVersion: "Instalado v{version}",
+ installedVersionUpdatable: "Instalado v{installed} · Actualizar a v{latest}",
licenseUnknown: "Licencia no especificada",
sourceRepository: "Repositorio de código fuente",
pluginHomepage: "Página principal",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 5bb9e5c4f0..9e4397c5b5 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -186,6 +186,7 @@ export default withEnglishFallback({
permissionsCount: "{count} autorizzazioni",
unsupportedTarget: "Non disponibile per {target}",
installedVersion: "Installato v{version}",
+ installedVersionUpdatable: "Installato v{installed} · Aggiorna a v{latest}",
licenseUnknown: "Licenza non specificata",
sourceRepository: "Repository del codice sorgente",
pluginHomepage: "Sito web",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 05e7bf871e..88c87d5115 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -187,6 +187,7 @@ export default withEnglishFallback({
permissionsCount: "権限 {count} 件",
unsupportedTarget: "{target} では利用できません",
installedVersion: "インストール済み v{version}",
+ installedVersionUpdatable: "インストール済み v{installed} · v{latest} に更新可能",
licenseUnknown: "ライセンス未指定",
sourceRepository: "ソースリポジトリ",
pluginHomepage: "ホームページ",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 1265b5f369..c9569e2e6b 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -186,6 +186,7 @@ export default withEnglishFallback({
permissionsCount: "권한 {count}개",
unsupportedTarget: "{target}에서는 사용할 수 없음",
installedVersion: "설치된 버전 v{version}",
+ installedVersionUpdatable: "설치된 버전 v{installed} · v{latest}(으)로 업데이트 가능",
licenseUnknown: "라이선스 미지정",
sourceRepository: "소스 저장소",
pluginHomepage: "홈페이지",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index f49483f0be..9dc2b322b9 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -187,6 +187,7 @@ export default withEnglishFallback({
permissionsCount: "{count} permissões",
unsupportedTarget: "Indisponível para {target}",
installedVersion: "Instalado v{version}",
+ installedVersionUpdatable: "Instalado v{installed} · Atualizar para v{latest}",
licenseUnknown: "Licença não informada",
sourceRepository: "Repositório do código-fonte",
pluginHomepage: "Página inicial",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 5f57670945..f87fcaf7d9 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -109,6 +109,7 @@ export default withEnglishFallback({
permissionsCount: "{count} 项权限",
unsupportedTarget: "暂不支持 {target}",
installedVersion: "已安装 v{version}",
+ installedVersionUpdatable: "已安装 v{installed} · 可更新至 v{latest}",
licenseUnknown: "未声明许可证",
sourceRepository: "源码仓库",
pluginHomepage: "主页",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 9b5b6fbb93..25f1c771ce 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -187,6 +187,7 @@ export default withEnglishFallback({
permissionsCount: "{count} 項權限",
unsupportedTarget: "暫不支援 {target}",
installedVersion: "已安裝 v{version}",
+ installedVersionUpdatable: "已安裝 v{installed} · 可更新至 v{latest}",
licenseUnknown: "未指定授權條款",
sourceRepository: "原始碼倉庫",
pluginHomepage: "主頁",
diff --git a/apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts b/apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts
index 4bab5f9a19..cee1a7adb7 100644
--- a/apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts
+++ b/apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts
@@ -65,6 +65,16 @@ describe("plugin marketplace listings", () => {
expect(buildMarketplacePluginListings([unsupported], [], "en")[0].status).toBe("unsupported");
});
+ it("keeps a newer installed version installed instead of offering a downgrade", () => {
+ const listings = buildMarketplacePluginListings([result], [installed("1.2.0")], "en");
+
+ // The catalog lags behind the installed build: the card must stay on the installed state.
+ expect(listings[0]).toMatchObject({ status: "installed", installed: { manifest: { version: "1.2.0" } } });
+ // Only "update" swaps the card's bottom-left line to the two-version text, so this state renders the
+ // plain installed line for 1.2.0 rather than the catalog's older 1.1.0.
+ expect(listings[0].plugin.latestVersion).toBe("1.1.0");
+ });
+
it("uses a universal artifact when the current target has no exact artifact", () => {
const universal = structuredClone(result);
universal.target = "linux-x64";
diff --git a/crates/dbx-core/src/plugins/installer.rs b/crates/dbx-core/src/plugins/installer.rs
index bbd461e525..c73e286801 100644
--- a/crates/dbx-core/src/plugins/installer.rs
+++ b/crates/dbx-core/src/plugins/installer.rs
@@ -380,19 +380,36 @@ impl PluginPackageInstaller {
let activations_dir = container_dir.join(ACTIVATIONS_DIR);
std::fs::create_dir_all(&versions_dir).map_err(|error| error.to_string())?;
std::fs::create_dir_all(&activations_dir).map_err(|error| error.to_string())?;
- let version_dir = versions_dir.join(version.to_string());
- if version_dir.exists() && !matches!(policy, PluginInstallPolicy::LocalDevelopment) {
+ let version_string = version.to_string();
+ let version_dir = versions_dir.join(&version_string);
+ let current = read_latest_activation(&container_dir)?;
+ // Only the version the newest activation record resolves to *and* that is still a usable
+ // install counts as "already installed". Directory existence alone is not enough: rollback
+ // deliberately retains the version it replaces as the next rollback target, so an
+ // installed-but-inactive directory has to stay replaceable, and a broken active directory
+ // (missing or unparsable manifest) has to stay replaceable so Update can repair it.
+ if !matches!(policy, PluginInstallPolicy::LocalDevelopment)
+ && version_dir.exists()
+ && current.as_ref().is_some_and(|record| record.version == version_string)
+ && version_dir_is_usable(&version_dir, &manifest.id, &version_string)
+ {
return Err(format!("Plugin '{}' version {} is already installed", manifest.id, version));
}
- let current = read_latest_activation(&container_dir)?;
- let version_string = version.to_string();
- let previous_version = current.as_ref().and_then(|record| {
- if record.version == version_string {
- record.previous_version.clone()
- } else {
- Some(record.version.clone())
- }
- });
+ let previous_version = current
+ .as_ref()
+ .and_then(|record| {
+ if record.version == version_string {
+ record.previous_version.clone()
+ } else {
+ Some(record.version.clone())
+ }
+ })
+ // Never record a rollback target that cannot be rolled back to: `rollback_locked`
+ // requires versions//manifest.json to have the matching identity.
+ .filter(|previous| {
+ previous != &version_string
+ && version_dir_is_usable(&versions_dir.join(previous), &manifest.id, previous)
+ });
let replaced_version_dir = if version_dir.exists() {
let mut backup = versions_dir.join(format!(".{version_string}.replaced"));
let mut suffix = 0u32;
@@ -408,10 +425,13 @@ impl PluginPackageInstaller {
None
};
if let Err(error) = std::fs::rename(&package_dir, &version_dir) {
+ let mut message = format!("Failed to store plugin '{}' version {}: {error}", manifest.id, version);
if let Some(backup) = &replaced_version_dir {
- let _ = std::fs::rename(backup, &version_dir);
+ if let Err(restore) = std::fs::rename(backup, &version_dir) {
+ message.push_str(&format!("; previous copy kept at {}: {restore}", backup.display()));
+ }
}
- return Err(format!("Failed to store plugin '{}' version {}: {error}", manifest.id, version));
+ return Err(message);
}
let activation = PluginActivationRecord {
@@ -423,10 +443,13 @@ impl PluginPackageInstaller {
};
if let Err(error) = write_activation_record(&container_dir, &activation) {
let _ = std::fs::remove_dir_all(&version_dir);
+ let mut message = error;
if let Some(backup) = &replaced_version_dir {
- let _ = std::fs::rename(backup, &version_dir);
+ if let Err(restore) = std::fs::rename(backup, &version_dir) {
+ message.push_str(&format!("; previous copy kept at {}: {restore}", backup.display()));
+ }
}
- return Err(error);
+ return Err(message);
}
if let Some(backup) = replaced_version_dir {
if let Err(error) = std::fs::remove_dir_all(backup) {
@@ -482,6 +505,31 @@ impl PluginPackageInstaller {
}
}
+/// `migrate_legacy_container` stores a migrated flat container under a synthetic
+/// `0.0.0-legacy.` directory name while the manifest inside keeps its original version string, so
+/// for those directories the name carries no version identity to compare against. Treating them as
+/// unusable would drop them from `previous_version` and let `prune_plugin_history` delete the only copy
+/// of that version. The plugin id check still applies to them: only the version identity is synthetic.
+const LEGACY_STORAGE_VERSION_PREFIX: &str = "0.0.0-legacy.";
+
+fn is_legacy_storage_version(version: &str) -> bool {
+ version
+ .strip_prefix(LEGACY_STORAGE_VERSION_PREFIX)
+ .is_some_and(|suffix| suffix.len() == 12 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()))
+}
+
+/// True when `versions/` has the manifest identity that this store location advertises.
+/// `rollback_locked` rejects a parsed manifest whose id or version differs, so the installer must
+/// not retain it as an active install or as a rollback target either. Legacy containers are the one
+/// exception: their directory name is synthetic, so only the plugin id is checked against them.
+fn version_dir_is_usable(version_dir: &Path, plugin_id: &str, version: &str) -> bool {
+ std::fs::read(version_dir.join("manifest.json")).is_ok_and(|raw| {
+ serde_json::from_slice::(&raw).is_ok_and(|manifest| {
+ manifest.id == plugin_id && (manifest.version == version || is_legacy_storage_version(version))
+ })
+ })
+}
+
pub(super) fn resolve_active_plugin_dir(container_dir: &Path) -> Result