feat(plugins): add install-from-url for plugin packages

This commit is contained in:
t8y2 committed 2026-09-15 01:16:04 +08:00
1 parent 837ea07cf3
commit a81ae9f455
17 files changed
+436 -51

No files matched your search

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, h, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Check, ChevronRight, CircleAlert, Download, ExternalLink, FileUp, FolderTree, Globe, LayoutGrid, List, Loader2, PackageCheck, Pencil, Plus, RefreshCw, RotateCcw, Search, Settings2, ShieldCheck, Store, Trash2 } from "@lucide/vue";
import { Check, ChevronRight, CircleAlert, Download, ExternalLink, FileUp, FolderTree, Globe, LayoutGrid, Link2, List, Loader2, PackageCheck, Pencil, 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";
@@ -16,10 +16,11 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
import { buildMarketplacePluginListings, filterMarketplacePluginListings, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
import { formatBytes } from "@/lib/database/serverMetrics";
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import type { InstalledPlugin, PluginRepository, PluginRepositoryCatalogResult, PluginTrustedKey } from "@/types/database";
import type { InstalledPlugin, PluginInstallResult, PluginRepository, PluginRepositoryCatalogResult, PluginTrustedKey } from "@/types/database";
import { useI18n } from "vue-i18n";
const props = defineProps<{
@@ -57,6 +58,9 @@ const catalogResults = ref<PluginRepositoryCatalogResult[]>([]);
const loading = ref(false);
const marketplaceLoading = ref(false);
const installing = ref(false);
const installUrl = ref("");
const urlInstalling = ref(false);
const urlDownloadProgress = ref<{ downloaded: number; total: number | null } | null>(null);
const marketplaceInstallingKey = ref("");
const operating = ref(false);
const error = ref("");
@@ -339,15 +343,19 @@ async function handleWebPackage(event: Event) {
if (file) await installPlugin(file);
}
async function finishInstall(result: PluginInstallResult) {
toast(t("pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
clearPluginIconCache();
installedPlugins.value = await api.listPlugins();
selectPlugin(result.plugin.manifest.id);
activeSection.value = "installed";
}
async function installPlugin(source: string | File) {
installing.value = true;
try {
const result = await api.installPluginPackage(source, allowUnsigned.value);
toast(t("pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
clearPluginIconCache();
installedPlugins.value = await api.listPlugins();
selectPlugin(result.plugin.manifest.id);
activeSection.value = "installed";
await finishInstall(result);
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 8000);
} finally {
@@ -355,6 +363,54 @@ async function installPlugin(source: string | File) {
}
}
function isHttpPackageUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
async function installPluginFromUrl() {
const url = installUrl.value.trim();
if (!url || installing.value || urlInstalling.value) return;
if (!isHttpPackageUrl(url)) return toast(t("pluginPlatform.invalidPackageUrl"));
urlInstalling.value = true;
urlDownloadProgress.value = { downloaded: 0, total: null };
let unlisten: (() => void) | undefined;
try {
if (isTauriRuntime()) {
const { listen } = await import("@tauri-apps/api/event");
unlisten = await listen<{ downloaded: number; total: number | null }>("plugin-url-download-progress", ({ payload }) => {
urlDownloadProgress.value = payload;
});
}
const result = await api.installPluginPackageFromUrl(url, allowUnsigned.value);
installUrl.value = "";
await finishInstall(result);
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 8000);
} finally {
unlisten?.();
urlInstalling.value = false;
urlDownloadProgress.value = null;
}
}
const urlProgressPercent = computed(() => {
const progress = urlDownloadProgress.value;
if (!progress?.total) return null;
return Math.min(100, Math.round((progress.downloaded / progress.total) * 100));
});
const urlProgressLabel = computed(() => {
const progress = urlDownloadProgress.value;
if (!progress) return "";
const downloaded = formatBytes(progress.downloaded);
return progress.total ? t("pluginPlatform.downloadProgress", { downloaded, total: formatBytes(progress.total) }) : t("pluginPlatform.downloadProgressUnknown", { downloaded });
});
function isPluginPackagePath(path: string): boolean {
return /\.dbxp$/i.test(path);
}
@@ -395,7 +451,7 @@ function onWebDrop(event: DragEvent) {
}
event.preventDefault();
event.stopPropagation();
if (installing.value) return;
if (installing.value || urlInstalling.value) return;
void installPlugin(file);
}
@@ -422,7 +478,7 @@ function onTauriPluginDrop(event: Event) {
}
draggingPackage.value = false;
const path = payload.paths.find(isPluginPackagePath);
if (!inside || !path || installing.value) return;
if (!inside || !path || installing.value || urlInstalling.value) return;
routedEvent.preventDefault();
void installPlugin(path);
}
@@ -782,10 +838,25 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="installing" @click="choosePluginPackage"><Loader2 v-if="installing" class="size-3.5 animate-spin" /><FileUp v-else class="size-3.5" />{{ t("pluginPlatform.installPackage") }}</Button>
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="installing || urlInstalling" @click="choosePluginPackage"><Loader2 v-if="installing" class="size-3.5 animate-spin" /><FileUp v-else class="size-3.5" />{{ t("pluginPlatform.installPackage") }}</Button>
<div class="flex items-center gap-1.5 text-[11px] text-muted-foreground"><ShieldCheck class="size-3.5 text-emerald-600 dark:text-emerald-400" />{{ t("pluginPlatform.signedPackagesVerifiedAutomatically") }}</div>
<div class="flex items-center gap-1.5 text-[11px] text-muted-foreground"><FileUp class="size-3.5" />{{ t("pluginPlatform.dropInstallHint") }}</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="relative min-w-0 flex-1 sm:max-w-md">
<Link2 class="pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
<Input v-model="installUrl" class="h-8 pl-8 font-mono text-xs" type="url" :disabled="urlInstalling" :placeholder="t('pluginPlatform.installUrlPlaceholder')" @keyup.enter="installPluginFromUrl" />
</div>
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="installing || urlInstalling || !installUrl.trim()" @click="installPluginFromUrl"
><Loader2 v-if="urlInstalling" class="size-3.5 animate-spin" /><Download v-else class="size-3.5" />{{ t("pluginPlatform.installFromUrl") }}</Button
>
</div>
<div v-if="urlInstalling" class="space-y-1.5">
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-[width]" :class="urlProgressPercent === null ? 'w-1/3 animate-pulse' : ''" :style="{ width: urlProgressPercent === null ? undefined : `${urlProgressPercent}%` }" />
</div>
<div class="text-[10px] text-muted-foreground">{{ urlProgressLabel }}</div>
</div>
</section>
<section class="space-y-3 rounded-xl border p-4">
+7 -2
View File
@@ -193,8 +193,8 @@ export default {
updateSuccess: "Updated {name} to {version}",
noInstalledPlugins: "No plugins installed",
browseMarketplace: "Browse Marketplace",
localInstallTitle: "Install a local package",
localInstallDescription: "Install a signed .dbxp obtained outside the marketplace. DBX verifies package integrity and repository signatures automatically.",
localInstallTitle: "Install a plugin package",
localInstallDescription: "Install a signed .dbxp obtained outside the marketplace from a local file or URL. DBX verifies package integrity and signatures automatically.",
repositoriesTitle: "Plugin repositories",
repositoriesDescription: "The official source is signed and managed by DBX. Custom repositories use one repository-level trust key configured below.",
repositoryFieldsRequired: "Repository ID, name, and catalog URL are required",
@@ -223,6 +223,11 @@ export default {
allowUnsignedDevelopmentPackage: "Allow unsigned development packages",
allowUnsignedDevelopmentPackageDescription: "Applies only to manual .dbxp installation and never relaxes official marketplace verification.",
installPackage: "Install .dbxp",
installFromUrl: "Install from URL",
installUrlPlaceholder: "https://example.com/plugin.dbxp",
invalidPackageUrl: "Enter a valid http:// or https:// plugin package URL",
downloadProgress: "Downloading {downloaded} of {total}",
downloadProgressUnknown: "Downloading {downloaded}",
dropToInstall: "Drop to install the plugin package",
dropInstallHint: "You can also drop a .dbxp file onto this page",
packageFileType: "DBX plugin package",
+7 -2
View File
@@ -190,8 +190,8 @@ export default withEnglishFallback({
updateSuccess: "Se actualizó {name} a {version}",
noInstalledPlugins: "No hay plugins instalados",
browseMarketplace: "Explorar Marketplace",
localInstallTitle: "Instalar un paquete local",
localInstallDescription: "Instala un .dbxp firmado obtenido fuera del marketplace. DBX verifica automáticamente la integridad y la firma del repositorio.",
localInstallTitle: "Instalar un paquete de plugin",
localInstallDescription: "Instala un .dbxp firmado obtenido fuera del marketplace desde un archivo local o URL. DBX verifica automáticamente la integridad y la firma.",
repositoriesTitle: "Repositorios de plugins",
repositoriesDescription: "DBX administra la fuente oficial. Los repositorios propios o de terceros usan la configuración de confianza del repositorio.",
repositoryFieldsRequired: "El ID, el nombre y la URL del catálogo son obligatorios",
@@ -216,6 +216,11 @@ export default withEnglishFallback({
allowUnsignedDevelopmentPackage: "Permitir paquetes de desarrollo sin firmar",
allowUnsignedDevelopmentPackageDescription: "Solo afecta a la instalación manual de .dbxp y nunca reduce la verificación del marketplace oficial.",
installPackage: "Instalar .dbxp",
installFromUrl: "Instalar desde URL",
installUrlPlaceholder: "https://example.com/plugin.dbxp",
invalidPackageUrl: "Introduce una URL de paquete de plugin http(s) válida",
downloadProgress: "Descargando {downloaded} de {total}",
downloadProgressUnknown: "Descargando {downloaded}",
dropToInstall: "Suelta para instalar el paquete del plugin",
dropInstallHint: "También puedes soltar un archivo .dbxp en esta página",
packageFileType: "Paquete de plugin de DBX",
+7 -2
View File
@@ -189,8 +189,8 @@ export default withEnglishFallback({
updateSuccess: "{name} aggiornato alla versione {version}",
noInstalledPlugins: "Nessun plugin installato",
browseMarketplace: "Sfoglia Marketplace",
localInstallTitle: "Installa un pacchetto locale",
localInstallDescription: "Installa un .dbxp firmato ottenuto fuori dal marketplace. DBX verifica automaticamente integrità e firma del repository.",
localInstallTitle: "Installa un pacchetto plugin",
localInstallDescription: "Installa un .dbxp firmato ottenuto fuori dal marketplace da un file locale o da un URL. DBX verifica automaticamente integrità e firma.",
repositoriesTitle: "Repository dei plugin",
repositoriesDescription: "La sorgente ufficiale è gestita da DBX. I repository self-hosted o di terze parti usano le impostazioni di attendibilità del repository.",
repositoryFieldsRequired: "ID, nome e URL del catalogo sono obbligatori",
@@ -215,6 +215,11 @@ export default withEnglishFallback({
allowUnsignedDevelopmentPackage: "Consenti pacchetti di sviluppo non firmati",
allowUnsignedDevelopmentPackageDescription: "Si applica solo all'installazione manuale di .dbxp e non riduce mai la verifica del marketplace ufficiale.",
installPackage: "Installa .dbxp",
installFromUrl: "Installa da URL",
installUrlPlaceholder: "https://example.com/plugin.dbxp",
invalidPackageUrl: "Inserisci un URL http(s) valido per il pacchetto del plugin",
downloadProgress: "Download di {downloaded} su {total}",
downloadProgressUnknown: "Download di {downloaded}",
dropToInstall: "Rilascia per installare il pacchetto del plugin",
dropInstallHint: "Puoi anche rilasciare un file .dbxp su questa pagina",
packageFileType: "Pacchetto plugin DBX",
+7 -2
View File
@@ -190,8 +190,8 @@ export default withEnglishFallback({
updateSuccess: "{name} を {version} に更新しました",
noInstalledPlugins: "プラグインはまだありません",
browseMarketplace: "マーケットプレイスを見る",
localInstallTitle: "ローカルパッケージをインストール",
localInstallDescription: "マーケットプレイス外で入手した署名済み .dbxp をインストールします。DBX が整合性とリポジトリ署名を自動検証します。",
localInstallTitle: "プラグインパッケージをインストール",
localInstallDescription: "マーケットプレイス外で入手した署名済み .dbxp をローカルファイルまたは URL からインストールします。DBX が整合性と署名を自動検証します。",
repositoriesTitle: "プラグインリポジトリ",
repositoriesDescription: "公式ソースは DBX が管理します。セルフホストまたは第三者のリポジトリでは、リポジトリの信頼設定を使用します。",
repositoryFieldsRequired: "リポジトリ ID、名前、カタログ URL は必須です",
@@ -216,6 +216,11 @@ export default withEnglishFallback({
allowUnsignedDevelopmentPackage: "未署名の開発パッケージを許可",
allowUnsignedDevelopmentPackageDescription: "手動での .dbxp インストールにのみ適用され、公式マーケットプレイスの検証は緩和されません。",
installPackage: ".dbxp をインストール",
installFromUrl: "URL からインストール",
installUrlPlaceholder: "https://example.com/plugin.dbxp",
invalidPackageUrl: "有効な http(s) プラグインパッケージ URL を入力してください",
downloadProgress: "ダウンロード中 {downloaded} / {total}",
downloadProgressUnknown: "ダウンロード中 {downloaded}",
dropToInstall: "ドロップしてプラグインをインストール",
dropInstallHint: ".dbxp ファイルをこのページにドロップしてもインストールできます",
packageFileType: "DBX プラグインパッケージ",
+7 -2
View File
@@ -194,8 +194,8 @@ export default withEnglishFallback({
updateSuccess: "{name} 업데이트 완료: {version}",
noInstalledPlugins: "설치된 플러그인이 없습니다",
browseMarketplace: "마켓플레이스 둘러보기",
localInstallTitle: "로컬 패키지 설치",
localInstallDescription: "마켓플레이스 외부에서 받은 서명된 .dbxp를 설치합니다. DBX가 패키지 무결성과 저장소 서명을 자동으로 검증합니다.",
localInstallTitle: "플러그인 패키지 설치",
localInstallDescription: "마켓플레이스 외부에서 받은 서명된 .dbxp를 로컬 파일이나 URL에서 설치합니다. DBX가 패키지 무결성과 서명을 자동으로 검증합니다.",
repositoriesTitle: "플러그인 저장소",
repositoriesDescription: "공식 소스는 DBX에서 관리합니다. 자체 호스팅 또는 타사 저장소는 저장소 신뢰 설정을 사용합니다.",
repositoryFieldsRequired: "저장소 ID, 이름 및 카탈로그 URL은 필수입니다",
@@ -224,6 +224,11 @@ export default withEnglishFallback({
allowUnsignedDevelopmentPackage: "서명되지 않은 개발 패키지 허용",
allowUnsignedDevelopmentPackageDescription: "수동 .dbxp 설치에만 적용되며 공식 마켓플레이스 검증은 완화하지 않습니다.",
installPackage: ".dbxp 설치",
installFromUrl: "URL에서 설치",
installUrlPlaceholder: "https://example.com/plugin.dbxp",
invalidPackageUrl: "유효한 http(s) 플러그인 패키지 URL을 입력하세요",
downloadProgress: "다운로드 중 {downloaded} / {total}",
downloadProgressUnknown: "다운로드 중 {downloaded}",
dropToInstall: "놓아서 플러그인 패키지 설치",
dropInstallHint: ".dbxp 파일을 이 페이지에 끌어다 놓아도 설치할 수 있습니다",
packageFileType: "DBX 플러그인 패키지",
+7 -2
View File
@@ -190,8 +190,8 @@ export default withEnglishFallback({
updateSuccess: "{name} atualizado para {version}",
noInstalledPlugins: "Nenhum plugin instalado",
browseMarketplace: "Explorar Marketplace",
localInstallTitle: "Instalar pacote local",
localInstallDescription: "Instale um .dbxp assinado obtido fora do marketplace. O DBX verifica automaticamente a integridade e a assinatura do repositório.",
localInstallTitle: "Instalar pacote de plugin",
localInstallDescription: "Instale um .dbxp assinado obtido fora do marketplace a partir de um arquivo local ou URL. O DBX verifica automaticamente a integridade e a assinatura.",
repositoriesTitle: "Repositórios de plugins",
repositoriesDescription: "A fonte oficial é gerenciada pelo DBX. Repositórios próprios ou de terceiros usam as configurações de confiança do repositório.",
repositoryFieldsRequired: "ID, nome e URL do catálogo são obrigatórios",
@@ -216,6 +216,11 @@ export default withEnglishFallback({
allowUnsignedDevelopmentPackage: "Permitir pacotes de desenvolvimento não assinados",
allowUnsignedDevelopmentPackageDescription: "Afeta apenas a instalação manual de .dbxp e nunca reduz a verificação do marketplace oficial.",
installPackage: "Instalar .dbxp",
installFromUrl: "Instalar de URL",
installUrlPlaceholder: "https://example.com/plugin.dbxp",
invalidPackageUrl: "Insira uma URL http(s) válida do pacote do plugin",
downloadProgress: "Baixando {downloaded} de {total}",
downloadProgressUnknown: "Baixando {downloaded}",
dropToInstall: "Solte para instalar o pacote do plugin",
dropInstallHint: "Você também pode soltar um arquivo .dbxp nesta página",
packageFileType: "Pacote de plugin do DBX",
+7 -2
View File
@@ -113,8 +113,8 @@ export default withEnglishFallback({
updateSuccess: "已将 {name} 更新到 {version}",
noInstalledPlugins: "尚未安装插件",
browseMarketplace: "浏览插件商店",
localInstallTitle: "安装本地插件包",
localInstallDescription: "安装从插件商店之外获得的已签名 .dbxp,DBX 会自动校验包完整性和仓库签名。",
localInstallTitle: "安装插件包",
localInstallDescription: "从本地文件或 URL 安装插件商店之外的 .dbxp 插件包,DBX 会自动校验包完整性和签名。",
repositoriesTitle: "插件仓库",
repositoriesDescription: "官方源由 DBX 管理;添加自托管或第三方仓库后,才会显示对应的仓库信任设置。",
repositoryFieldsRequired: "仓库 ID、名称和目录地址不能为空",
@@ -139,6 +139,11 @@ export default withEnglishFallback({
allowUnsignedDevelopmentPackage: "允许安装未签名开发包",
allowUnsignedDevelopmentPackageDescription: "仅影响手动安装的 .dbxp,不会放宽官方插件商店的签名校验。",
installPackage: "安装 .dbxp",
installFromUrl: "从 URL 安装",
installUrlPlaceholder: "https://example.com/plugin.dbxp",
invalidPackageUrl: "请输入有效的 http(s) 插件包链接",
downloadProgress: "正在下载 {downloaded} / {total}",
downloadProgressUnknown: "正在下载 {downloaded}",
dropToInstall: "释放以安装插件包",
dropInstallHint: "也可拖放 .dbxp 到插件中心页面安装",
packageFileType: "DBX 插件包",
+7 -2
View File
@@ -190,8 +190,8 @@ export default withEnglishFallback({
updateSuccess: "已將 {name} 更新至 {version}",
noInstalledPlugins: "尚未安裝外掛",
browseMarketplace: "瀏覽外掛商店",
localInstallTitle: "安裝本機外掛套件",
localInstallDescription: "安裝從外掛商店之外取得的已簽署 .dbxp,DBX 會自動驗證套件完整性與倉庫簽章。",
localInstallTitle: "安裝外掛套件",
localInstallDescription: "從本機檔案或 URL 安裝外掛商店之外的 .dbxp 外掛套件,DBX 會自動驗證套件完整性與簽章。",
repositoriesTitle: "外掛倉庫",
repositoriesDescription: "官方來源由 DBX 管理;加入自託管或第三方倉庫後,才會顯示對應的倉庫信任設定。",
repositoryFieldsRequired: "倉庫 ID、名稱和目錄網址皆為必填",
@@ -216,6 +216,11 @@ export default withEnglishFallback({
allowUnsignedDevelopmentPackage: "允許安裝未簽署開發套件",
allowUnsignedDevelopmentPackageDescription: "僅影響手動安裝的 .dbxp,不會放寬官方外掛商店的簽章驗證。",
installPackage: "安裝 .dbxp",
installFromUrl: "從 URL 安裝",
installUrlPlaceholder: "https://example.com/plugin.dbxp",
invalidPackageUrl: "請輸入有效的 http(s) 外掛套件連結",
downloadProgress: "正在下載 {downloaded} / {total}",
downloadProgressUnknown: "正在下載 {downloaded}",
dropToInstall: "放開以安裝外掛套件",
dropInstallHint: "也可拖放 .dbxp 到外掛中心頁面安裝",
packageFileType: "DBX 外掛套件",
+1
View File
@@ -88,6 +88,7 @@ export const removePluginRepository = forward("removePluginRepository");
export const fetchPluginMarketplaceCatalogs = forward("fetchPluginMarketplaceCatalogs");
export const installMarketplacePlugin = forward("installMarketplacePlugin");
export const installPluginPackage = forward("installPluginPackage");
export const installPluginPackageFromUrl = forward("installPluginPackageFromUrl");
export const rollbackPlugin = forward("rollbackPlugin");
export const uninstallPlugin = forward("uninstallPlugin");
export const activatePlugin = forward("activatePlugin");
+16
View File
@@ -590,6 +590,22 @@ export async function installPluginPackage(pathOrFile: string | File, allowUnsig
return response.json();
}
export async function installPluginPackageFromUrl(url: string, allowUnsigned = false): Promise<PluginInstallResult> {
let blob: Blob;
let fileName: string;
try {
fileName = new URL(url).pathname.split("/").pop() || "plugin.dbxp";
} catch {
fileName = "plugin.dbxp";
}
blob = await (await fetch(url)).blob();
const formData = new FormData();
formData.append("file", blob, fileName);
const response = await fetch(apiUrl(`/api/plugins/install?allow_unsigned=${allowUnsigned}&from_url=true`), { method: "POST", body: formData });
if (!response.ok) throw new Error(await response.text());
return response.json();
}
export async function rollbackPlugin(pluginId: string): Promise<PluginRollbackResult> {
return post("/api/plugins/rollback", { plugin_id: pluginId });
}
+4
View File
@@ -2294,6 +2294,10 @@ export async function installPluginPackage(pathOrFile: string | File, allowUnsig
return invoke("install_plugin_package", { path: pathOrFile, allowUnsigned });
}
export async function installPluginPackageFromUrl(url: string, allowUnsigned = false): Promise<PluginInstallResult> {
return invoke("install_plugin_package_from_url", { url, allowUnsigned });
}
export async function rollbackPlugin(pluginId: string): Promise<PluginRollbackResult> {
return invoke("rollback_plugin", { pluginId });
}