feat(plugins): install marketplace packages via dbx deep link
This commit is contained in:
18 files changed
+223
-1
No files matched your search
@@ -71,6 +71,7 @@ import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociat
|
||||
import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
|
||||
import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TabOutputView, TreeNode } from "@/types/database";
|
||||
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
|
||||
import { parsePluginInstallDeepLink } from "@/lib/plugins/pluginInstallDeepLink";
|
||||
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
|
||||
import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink";
|
||||
import { activeDesktopAiRuns, blockingDesktopAiRunsForQuit } from "@/lib/ai/desktopAiRunRegistry";
|
||||
@@ -999,6 +1000,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({
|
||||
openConnectionDeepLink,
|
||||
closeActiveSurface,
|
||||
openAiConfigDeepLink,
|
||||
openPluginInstallDeepLink,
|
||||
});
|
||||
const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose });
|
||||
useVisibilityChange();
|
||||
@@ -2372,6 +2374,37 @@ async function openPendingAiConfigLinks() {
|
||||
}
|
||||
}
|
||||
|
||||
const pluginCenterInstallRequest = ref<{ id: number; url: string } | null>(null);
|
||||
let pluginInstallDeepLinkId = 0;
|
||||
|
||||
async function openPluginInstallDeepLink(url: string) {
|
||||
try {
|
||||
const draft = parsePluginInstallDeepLink(url);
|
||||
if (!draft) return;
|
||||
pluginCenterInstallRequest.value = { id: ++pluginInstallDeepLinkId, url: draft.url };
|
||||
openPluginCenterPage();
|
||||
} catch (e: any) {
|
||||
toast(
|
||||
t("pluginPlatform.deepLinkInvalid", {
|
||||
message: e?.message || String(e),
|
||||
}),
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPendingPluginInstallLinks() {
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
const links = await api.pendingOpenPluginInstallLinks();
|
||||
for (const link of links) {
|
||||
await openPluginInstallDeepLink(link);
|
||||
}
|
||||
} catch {
|
||||
/* ignore startup deep-link probing errors */
|
||||
}
|
||||
}
|
||||
|
||||
function setConnectionDialogOpen(value: boolean) {
|
||||
showConnectionDialog.value = value;
|
||||
if (!value) {
|
||||
@@ -3579,6 +3612,7 @@ onMounted(async () => {
|
||||
void openPendingDbFiles();
|
||||
void openPendingConnectionLinks();
|
||||
void openPendingAiConfigLinks();
|
||||
void openPendingPluginInstallLinks();
|
||||
console.log(`[STARTUP] onMounted sync done: ${(performance.now() - mountStart).toFixed(0)}ms`);
|
||||
});
|
||||
|
||||
@@ -3707,7 +3741,7 @@ onUnmounted(() => {
|
||||
@detach-tab="detachTab"
|
||||
>
|
||||
<DriverStorePage v-if="driverStoreTabOpen" v-show="driverStoreActive" v-model:active-tab="driverStoreActiveTab" class="flex-1 min-h-0" :update-notifications-enabled="updateNotificationsEnabled" :focus-target="driverStoreFocus" @update-count-change="updateAgentDriverUpdateCount" />
|
||||
<PluginCenterPage v-if="pluginCenterTabOpen" v-show="pluginCenterActive" class="flex-1 min-h-0" :focus-target="pluginCenterFocus" @new-connection="openPluginConnectionDialog" />
|
||||
<PluginCenterPage v-if="pluginCenterTabOpen" v-show="pluginCenterActive" class="flex-1 min-h-0" :focus-target="pluginCenterFocus" :install-url-request="pluginCenterInstallRequest" @new-connection="openPluginConnectionDialog" />
|
||||
<EditorSettingsPage
|
||||
v-if="settingsPageTabOpen"
|
||||
v-show="settingsStore.settingsPageActive"
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
focusTarget?: PluginCenterFocus | null;
|
||||
installUrlRequest?: { id: number; url: string } | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -534,6 +535,21 @@ watch(
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
let lastHandledInstallRequestId = 0;
|
||||
watch(
|
||||
() => props.installUrlRequest,
|
||||
async (request) => {
|
||||
// immediate so a deep link that just opened the Plugin Center is consumed on
|
||||
// mount; the id guard keeps already-handled requests from replaying.
|
||||
if (!request || request.id <= lastHandledInstallRequestId) return;
|
||||
lastHandledInstallRequestId = request.id;
|
||||
installUrl.value = request.url;
|
||||
if (window.confirm(t("pluginPlatform.deepLinkInstallConfirm", { url: request.url }))) {
|
||||
await installPluginFromUrl();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(allowUnsigned, (value) => {
|
||||
try {
|
||||
localStorage.setItem(PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY, value ? "1" : "0");
|
||||
|
||||
@@ -9,6 +9,7 @@ export function useTauriEvents(deps: {
|
||||
openDbFilePath: (path: string) => Promise<void>;
|
||||
openConnectionDeepLink: (url: string) => Promise<void>;
|
||||
openAiConfigDeepLink: (url: string) => Promise<void>;
|
||||
openPluginInstallDeepLink: (url: string) => Promise<void>;
|
||||
closeActiveSurface: () => void;
|
||||
}) {
|
||||
const connectionStore = useConnectionStore();
|
||||
@@ -153,6 +154,17 @@ export function useTauriEvents(deps: {
|
||||
}
|
||||
}).then((unlisten) => unlistenHandles.push(unlisten));
|
||||
|
||||
listen<string[]>("dbx-open-plugin-install-links", async (event) => {
|
||||
try {
|
||||
for (const url of event.payload) {
|
||||
await deps.openPluginInstallDeepLink(url);
|
||||
}
|
||||
focusCurrentWindow();
|
||||
} catch (e) {
|
||||
console.error("[DBX] dbx-open-plugin-install-links error:", e);
|
||||
}
|
||||
}).then((unlisten) => unlistenHandles.push(unlisten));
|
||||
|
||||
listen("dbx-close-active-tab", () => {
|
||||
deps.closeActiveSurface();
|
||||
}).then((unlisten) => unlistenHandles.push(unlisten));
|
||||
|
||||
@@ -230,6 +230,8 @@ export default {
|
||||
installPackage: "Install .dbxp",
|
||||
installFromUrl: "Install from URL",
|
||||
installUrlPlaceholder: "https://example.com/plugin.dbxp",
|
||||
deepLinkInstallConfirm: "Install this plugin from the link below?",
|
||||
deepLinkInvalid: "Invalid plugin install link: {message}",
|
||||
invalidPackageUrl: "Enter a valid http:// or https:// plugin package URL",
|
||||
downloadProgress: "Downloading {downloaded} of {total}",
|
||||
downloadProgressUnknown: "Downloading {downloaded}",
|
||||
|
||||
@@ -223,6 +223,8 @@ export default withEnglishFallback({
|
||||
installPackage: "Instalar .dbxp",
|
||||
installFromUrl: "Instalar desde URL",
|
||||
installUrlPlaceholder: "https://example.com/plugin.dbxp",
|
||||
deepLinkInstallConfirm: "¿Instalar este complemento desde el siguiente enlace?",
|
||||
deepLinkInvalid: "Enlace de instalación de complemento no válido: {message}",
|
||||
invalidPackageUrl: "Introduce una URL de paquete de plugin http(s) válida",
|
||||
downloadProgress: "Descargando {downloaded} de {total}",
|
||||
downloadProgressUnknown: "Descargando {downloaded}",
|
||||
|
||||
@@ -222,6 +222,8 @@ export default withEnglishFallback({
|
||||
installPackage: "Installa .dbxp",
|
||||
installFromUrl: "Installa da URL",
|
||||
installUrlPlaceholder: "https://example.com/plugin.dbxp",
|
||||
deepLinkInstallConfirm: "Installare questo plugin dal seguente link?",
|
||||
deepLinkInvalid: "Link di installazione plugin non valido: {message}",
|
||||
invalidPackageUrl: "Inserisci un URL http(s) valido per il pacchetto del plugin",
|
||||
downloadProgress: "Download di {downloaded} su {total}",
|
||||
downloadProgressUnknown: "Download di {downloaded}",
|
||||
|
||||
@@ -223,6 +223,8 @@ export default withEnglishFallback({
|
||||
installPackage: ".dbxp をインストール",
|
||||
installFromUrl: "URL からインストール",
|
||||
installUrlPlaceholder: "https://example.com/plugin.dbxp",
|
||||
deepLinkInstallConfirm: "以下のリンクからプラグインをインストールしますか?",
|
||||
deepLinkInvalid: "無効なプラグインインストールリンクです: {message}",
|
||||
invalidPackageUrl: "有効な http(s) プラグインパッケージ URL を入力してください",
|
||||
downloadProgress: "ダウンロード中 {downloaded} / {total}",
|
||||
downloadProgressUnknown: "ダウンロード中 {downloaded}",
|
||||
|
||||
@@ -231,6 +231,8 @@ export default withEnglishFallback({
|
||||
installPackage: ".dbxp 설치",
|
||||
installFromUrl: "URL에서 설치",
|
||||
installUrlPlaceholder: "https://example.com/plugin.dbxp",
|
||||
deepLinkInstallConfirm: "아래 링크에서 플러그인을 설치하시겠습니까?",
|
||||
deepLinkInvalid: "잘못된 플러그인 설치 링크입니다: {message}",
|
||||
invalidPackageUrl: "유효한 http(s) 플러그인 패키지 URL을 입력하세요",
|
||||
downloadProgress: "다운로드 중 {downloaded} / {total}",
|
||||
downloadProgressUnknown: "다운로드 중 {downloaded}",
|
||||
|
||||
@@ -223,6 +223,8 @@ export default withEnglishFallback({
|
||||
installPackage: "Instalar .dbxp",
|
||||
installFromUrl: "Instalar de URL",
|
||||
installUrlPlaceholder: "https://example.com/plugin.dbxp",
|
||||
deepLinkInstallConfirm: "Instalar este plugin pelo link abaixo?",
|
||||
deepLinkInvalid: "Link de instalação de plugin inválido: {message}",
|
||||
invalidPackageUrl: "Insira uma URL http(s) válida do pacote do plugin",
|
||||
downloadProgress: "Baixando {downloaded} de {total}",
|
||||
downloadProgressUnknown: "Baixando {downloaded}",
|
||||
|
||||
@@ -146,6 +146,8 @@ export default withEnglishFallback({
|
||||
installPackage: "安装 .dbxp",
|
||||
installFromUrl: "从 URL 安装",
|
||||
installUrlPlaceholder: "https://example.com/plugin.dbxp",
|
||||
deepLinkInstallConfirm: "是否从以下链接安装插件?",
|
||||
deepLinkInvalid: "无效的插件安装链接:{message}",
|
||||
invalidPackageUrl: "请输入有效的 http(s) 插件包链接",
|
||||
downloadProgress: "正在下载 {downloaded} / {total}",
|
||||
downloadProgressUnknown: "正在下载 {downloaded}",
|
||||
|
||||
@@ -223,6 +223,8 @@ export default withEnglishFallback({
|
||||
installPackage: "安裝 .dbxp",
|
||||
installFromUrl: "從 URL 安裝",
|
||||
installUrlPlaceholder: "https://example.com/plugin.dbxp",
|
||||
deepLinkInstallConfirm: "要從以下連結安裝外掛程式嗎?",
|
||||
deepLinkInvalid: "無效的外掛程式安裝連結:{message}",
|
||||
invalidPackageUrl: "請輸入有效的 http(s) 外掛套件連結",
|
||||
downloadProgress: "正在下載 {downloaded} / {total}",
|
||||
downloadProgressUnknown: "正在下載 {downloaded}",
|
||||
|
||||
@@ -420,6 +420,7 @@ export const pendingOpenSqlFiles = forward("pendingOpenSqlFiles");
|
||||
export const pendingOpenDbFiles = forward("pendingOpenDbFiles");
|
||||
export const pendingOpenConnectionLinks = forward("pendingOpenConnectionLinks");
|
||||
export const pendingOpenAiConfigLinks = forward("pendingOpenAiConfigLinks");
|
||||
export const pendingOpenPluginInstallLinks = forward("pendingOpenPluginInstallLinks");
|
||||
export const readExternalSqlFile = forward("readExternalSqlFile");
|
||||
export const readExternalSqlFileSnapshot = forward("readExternalSqlFileSnapshot");
|
||||
export const inspectExternalSqlFile = forward("inspectExternalSqlFile");
|
||||
|
||||
@@ -2491,6 +2491,10 @@ export async function pendingOpenAiConfigLinks(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function pendingOpenPluginInstallLinks(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function readExternalSqlFile(_path: string, _maxSizeBytes?: number): Promise<string> {
|
||||
throw new Error("Opening external SQL file paths is only available in the desktop app");
|
||||
}
|
||||
|
||||
@@ -1003,6 +1003,10 @@ export async function pendingOpenAiConfigLinks(): Promise<string[]> {
|
||||
return invoke("pending_open_ai_config_links");
|
||||
}
|
||||
|
||||
export async function pendingOpenPluginInstallLinks(): Promise<string[]> {
|
||||
return invoke("pending_open_plugin_install_links");
|
||||
}
|
||||
|
||||
export interface ExternalSqlFileSnapshot {
|
||||
content: string;
|
||||
version: ExternalSqlFileVersion;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePluginInstallDeepLink } from "../pluginInstallDeepLink";
|
||||
|
||||
const PACKAGE_URL = "https://dl.dbxio.com/plugins/io.dbx.ssh/0.4.73/io.dbx.ssh-0.4.73-darwin-arm64.dbxp";
|
||||
|
||||
describe("parsePluginInstallDeepLink", () => {
|
||||
it("extracts the encoded package url", () => {
|
||||
const draft = parsePluginInstallDeepLink(`dbx://plugins/install?url=${encodeURIComponent(PACKAGE_URL)}`);
|
||||
expect(draft).toEqual({ url: PACKAGE_URL });
|
||||
});
|
||||
|
||||
it("returns null for non-plugin links and junk", () => {
|
||||
expect(parsePluginInstallDeepLink("dbx://connection/new?type=mysql")).toBeNull();
|
||||
expect(parsePluginInstallDeepLink("dbx://plugins/installed?url=https://example.com/a.dbxp")).toBeNull();
|
||||
expect(parsePluginInstallDeepLink("dbx://plugins/installation?url=https://example.com/a.dbxp")).toBeNull();
|
||||
expect(parsePluginInstallDeepLink("")).toBeNull();
|
||||
expect(parsePluginInstallDeepLink("not a url")).toBeNull();
|
||||
});
|
||||
|
||||
it("throws for matching links without a usable url param", () => {
|
||||
expect(() => parsePluginInstallDeepLink("dbx://plugins/install")).toThrow(/Missing url/);
|
||||
expect(() => parsePluginInstallDeepLink("dbx://plugins/install?url=")).toThrow(/Missing url/);
|
||||
expect(() => parsePluginInstallDeepLink(`dbx://plugins/install?url=${encodeURIComponent("ftp://example.com/a.dbxp")}`)).toThrow(/http/);
|
||||
expect(() => parsePluginInstallDeepLink(`dbx://plugins/install?url=${encodeURIComponent(`https://example.com/${"a".repeat(2100)}`)}`)).toThrow(/too long/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const PLUGIN_INSTALL_DEEP_LINK_HOST = "plugins";
|
||||
const PLUGIN_INSTALL_DEEP_LINK_PATH = "/install";
|
||||
const MAX_DEEP_LINK_LENGTH = 4096;
|
||||
const MAX_PACKAGE_URL_LENGTH = 2048;
|
||||
|
||||
export interface PluginInstallDeepLinkDraft {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `dbx://plugins/install?url=<encoded package url>` links.
|
||||
* Returns null for non-matching links and throws for malformed ones,
|
||||
* mirroring the AI config deep-link parser contract.
|
||||
*/
|
||||
export function parsePluginInstallDeepLink(value: string): PluginInstallDeepLinkDraft | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > MAX_DEEP_LINK_LENGTH) return null;
|
||||
if (!trimmed.startsWith("dbx://")) return null;
|
||||
|
||||
const url = new URL(trimmed);
|
||||
if (url.host !== PLUGIN_INSTALL_DEEP_LINK_HOST || url.pathname !== PLUGIN_INSTALL_DEEP_LINK_PATH) return null;
|
||||
|
||||
const packageUrl = url.searchParams.get("url")?.trim() ?? "";
|
||||
if (!packageUrl) throw new Error("Missing url");
|
||||
if (packageUrl.length > MAX_PACKAGE_URL_LENGTH) throw new Error("Package url is too long");
|
||||
|
||||
const protocol = new URL(packageUrl).protocol;
|
||||
if (protocol !== "http:" && protocol !== "https:") throw new Error("Package url must be http(s)");
|
||||
|
||||
return { url: packageUrl };
|
||||
}
|
||||
Reference in new issue
Block a user