feat: support plugin repository

This commit is contained in:
hstyi
2025-06-14 18:20:09 +08:00
committed by hstyi
parent 95bf08b0da
commit ea6b2d6a66
8 changed files with 250 additions and 48 deletions

View File

@@ -14,6 +14,7 @@ import org.jdesktop.swingx.JXHyperlink
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.event.ActionEvent
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.*
@@ -27,6 +28,7 @@ class MarketplacePanel : JPanel(BorderLayout()), Disposable {
private val busyLabel = JXBusyLabel()
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val failedLabel = JLabel()
private val isLoading = AtomicBoolean(false)
private val marketplaceManager get() = MarketplaceManager.getInstance()
@@ -90,7 +92,8 @@ class MarketplacePanel : JPanel(BorderLayout()), Disposable {
reload()
}
private fun reload() {
fun reload() {
if (isLoading.compareAndSet(false, true)) {
coroutineScope.launch {
withContext(Dispatchers.Swing) {
cardLayout.show(cardPanel, PanelState.Loading.name)
@@ -121,8 +124,10 @@ class MarketplacePanel : JPanel(BorderLayout()), Disposable {
failedLabel.text = "Failed to fetch the plugins"
cardLayout.show(cardPanel, PanelState.FetchFailed.name)
}
} finally {
isLoading.set(true)
}
}
}
}

View File

@@ -10,6 +10,7 @@ import app.termora.plugin.PluginManager
import app.termora.plugin.PluginOrigin
import app.termora.plugin.PluginXmlParser
import app.termora.plugin.internal.extension.DynamicExtensionHandler
import app.termora.plugin.marketplace.MarketplaceManager
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.extras.components.FlatToolBar
@@ -95,6 +96,19 @@ class PluginOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable, Acc
private fun showContextMenu() {
val popupMenu = FlatPopupMenu()
val managePluginRepositoryMenu =
popupMenu.add(I18n.getString("termora.settings.plugin.manage-plugin-repository"))
managePluginRepositoryMenu.addActionListener {
val dialog = PluginRepositoryDialog(owner)
dialog.isVisible = true
if (dialog.changed) {
MarketplaceManager.getInstance().clear()
marketplacePanel.reload()
}
}
popupMenu.addSeparator()
val installPluginFromDiskMenu = popupMenu.add(I18n.getString("termora.settings.plugin.install-from-disk"))
installPluginFromDiskMenu.addActionListener {
val chooser = FileChooser()
@@ -104,6 +118,7 @@ class PluginOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable, Acc
chooser.fileSelectionMode = JFileChooser.FILES_ONLY
chooser.showOpenDialog(owner).thenAccept { if (it.isNotEmpty()) installPluginFromDisk(it.first()) }
}
popupMenu.show(settingsButton, 0, settingsButton.height)
}

View File

@@ -0,0 +1,101 @@
package app.termora.plugin.internal.plugin
import app.termora.*
import com.formdev.flatlaf.extras.components.FlatToolBar
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
import java.awt.event.ActionEvent
import java.net.URI
import javax.swing.*
internal class PluginRepositoryDialog(owner: Window) : DialogWrapper(owner) {
private val model = DefaultListModel<String>()
private val list = JList(model)
private val dialog get() = this
var changed = false
private set
init {
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
isModal = true
isResizable = false
controlsVisible = false
title = "Custom Plugin Repository"
list.fixedCellHeight = UIManager.getInt("Tree.rowHeight")
for (url in PluginRepositoryManager.getInstance().getRepositories()) {
model.addElement(url)
}
setLocationRelativeTo(owner)
init()
}
override fun createCenterPanel(): JComponent {
val pluginRepositoryManager = PluginRepositoryManager.getInstance()
val panel = JPanel(BorderLayout())
val toolbar = FlatToolBar().apply { isFloatable = false }
val addBtn = JButton(Icons.add)
val deleteBtn = JButton(Icons.delete)
val urlBtn = JButton(Icons.externalLink)
deleteBtn.isEnabled = false
toolbar.add(addBtn)
toolbar.add(deleteBtn)
toolbar.add(Box.createHorizontalGlue())
toolbar.add(urlBtn)
toolbar.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
panel.add(toolbar, BorderLayout.NORTH)
panel.add(JScrollPane(list).apply { border = BorderFactory.createEmptyBorder() }, BorderLayout.CENTER)
panel.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val text = OptionPane.showInputDialog(dialog)
if (text.isNullOrBlank()) return
if ((text.startsWith("http://") || text.startsWith("https://")).not()) {
return
}
pluginRepositoryManager.addRepository(text)
model.addElement(text)
changed = true
}
})
urlBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
Application.browse(URI.create("https://github.com/TermoraDev/termora-marketplace/releases/latest"))
}
})
deleteBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (OptionPane.showConfirmDialog(
dialog,
I18n.getString("termora.keymgr.delete-warning"),
I18n.getString("termora.remove"),
JOptionPane.YES_NO_OPTION,
JOptionPane.QUESTION_MESSAGE
) == JOptionPane.YES_OPTION
) {
for (i in list.selectedIndices.sortedByDescending { it }) {
pluginRepositoryManager.removeRepository(model.getElementAt(i))
model.removeElementAt(i)
changed = true
}
}
}
})
list.addListSelectionListener { deleteBtn.isEnabled = list.selectedIndex >= 0 }
return panel
}
override fun createSouthPanel(): JComponent? {
return null
}
}

View File

@@ -0,0 +1,43 @@
package app.termora.plugin.internal.plugin
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.EnableManager
import org.apache.commons.lang3.StringUtils
internal class PluginRepositoryManager private constructor() {
companion object {
private const val KEY = "PluginRepositories"
fun getInstance(): PluginRepositoryManager {
return ApplicationScope.forApplicationScope()
.getOrCreate(PluginRepositoryManager::class) { PluginRepositoryManager() }
}
}
private val enableManager get() = EnableManager.getInstance()
fun addRepository(url: String) {
synchronized(this) {
val repositories = getRepositories().toMutableList()
repositories.add(url)
enableManager.setFlag(KEY, ohMyJson.encodeToString(repositories))
}
}
fun removeRepository(url: String) {
synchronized(this) {
val repositories = getRepositories().toMutableList()
repositories.removeIf { it == url }
enableManager.setFlag(KEY, ohMyJson.encodeToString(repositories))
}
}
fun getRepositories(): List<String> {
synchronized(this) {
val text = enableManager.getFlag(KEY, StringUtils.EMPTY)
if (text.isBlank()) return emptyList()
return runCatching { ohMyJson.decodeFromString<List<String>>(text) }
.getOrNull() ?: emptyList()
}
}
}

View File

@@ -2,6 +2,7 @@ package app.termora.plugin.marketplace
import app.termora.*
import app.termora.plugin.PluginDescription
import app.termora.plugin.internal.plugin.PluginRepositoryManager
import app.termora.plugin.internal.plugin.PluginSVGIcon
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -18,6 +19,8 @@ import org.slf4j.LoggerFactory
import org.xml.sax.InputSource
import java.io.StringReader
import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.locks.ReentrantLock
@@ -88,6 +91,55 @@ internal class MarketplaceManager private constructor() {
private fun doGetPlugins(): List<MarketplacePlugin> {
val version = Semver.parse(Application.getVersion())
?: return emptyList()
val repositories = PluginRepositoryManager.getInstance().getRepositories().toMutableSet()
repositories.add("https://github.com/TermoraDev/termora-marketplace/releases/latest/download/plugins.xml")
val plugins = mutableListOf<MarketplacePlugin>()
val executorService = Executors.newVirtualThreadPerTaskExecutor()
val futures = repositories
.map { url -> executorService.submit<List<MarketplacePlugin>> { getPlugins(url, version) } }
for (future in futures) {
try {
plugins.addAll(future.get(1, TimeUnit.MINUTES))
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn(e.message, e)
}
continue
}
}
if (plugins.isEmpty()) {
return emptyList()
}
val matchedPlugins = mutableListOf<MarketplacePlugin>()
// 获取到合适的插件
for (e in plugins) {
for (plugin in e.versions) {
if (version.satisfies(plugin.since).not()) continue
if (plugin.until.isNotBlank())
if (version.satisfies(plugin.until).not()) continue
matchedPlugins.add(e.copy(versions = mutableListOf(plugin)))
break
}
}
// 因为可能有多个插件仓库,所以要去重
val map = matchedPlugins.groupBy { it.id }
matchedPlugins.clear()
for (entry in map) {
matchedPlugins.add(entry.value.maxBy { it.versions.first().version })
}
return matchedPlugins.sortedBy { it.name.length }
}
private fun getPlugins(url: String, version: Semver): List<MarketplacePlugin> {
val language = I18n.containsLanguage(Locale.getDefault()) ?: "en_US"
val request = Request.Builder()
.get()
@@ -95,7 +147,7 @@ internal class MarketplaceManager private constructor() {
.header("X-Language", language)
.header("X-OS", SystemUtils.OS_NAME)
.header("X-Arch", SystemUtils.OS_ARCH)
.url("https://github.com/TermoraDev/termora-marketplace/releases/latest/download/plugins.xml")
.url(url)
.build()
val response = Application.httpClient.newCall(request).execute()
if (response.isSuccessful.not()) {
@@ -180,24 +232,7 @@ internal class MarketplaceManager private constructor() {
)
}
if (plugins.isEmpty()) {
return emptyList()
}
val matchedPlugins = mutableListOf<MarketplacePlugin>()
// 获取到合适的插件
for (e in plugins) {
for (plugin in e.versions) {
if (version.satisfies(plugin.since).not()) continue
if (plugin.until.isNotBlank())
if (version.satisfies(plugin.until).not()) continue
matchedPlugins.add(e.copy(versions = mutableListOf(plugin)))
break
}
}
return matchedPlugins.sortedBy { it.name.length }
return plugins
}

View File

@@ -110,7 +110,8 @@ termora.settings.plugin.cannot-uninstall=System built-in plugins cannot be unins
termora.settings.plugin.uninstall-confirm=Are you sure you want to uninstall <b>{0}</b> ?
termora.settings.plugin.uninstall-failed=Uninstall failed
termora.settings.plugin.install-failed=Install failed, please try again later
termora.settings.plugin.install-from-disk=Install plugin from Disk...
termora.settings.plugin.install-from-disk=Install Plugin from Disk...
termora.settings.plugin.manage-plugin-repository=Manage Plugin Repository...
termora.settings.plugin.install-from-disk-warning=<b>{0}</b> plugin will have access to all your data. Are you sure you want to install it?
termora.settings.account=Account

View File

@@ -117,6 +117,7 @@ termora.settings.plugin.cannot-uninstall=系统内置插件不可以卸载
termora.settings.plugin.uninstall-confirm=你确定要卸载 <b>{0}</b> 吗?
termora.settings.plugin.uninstall-failed=卸载失败
termora.settings.plugin.install-from-disk=从磁盘安装插件...
termora.settings.plugin.manage-plugin-repository=管理插件仓库...
termora.settings.plugin.install-failed=安装失败,请稍后再试
termora.settings.plugin.install-from-disk-warning=<b>{0}</b> 插件可以访问你的所有数据,你确定要安装吗?

View File

@@ -128,8 +128,9 @@ termora.settings.plugin.cannot-uninstall=系統內建插件無法卸載
termora.settings.plugin.uninstall-confirm=你確定要卸載 <b>{0}</b> 嗎?
termora.settings.plugin.uninstall-failed=解除安裝失敗
termora.settings.plugin.install-from-disk=從磁碟安裝插件...
termora.settings.plugin.manage-plugin-repository=管理插件倉庫...
termora.settings.plugin.install-failed=安裝失敗,請稍後再試
termora.settings.plugin.install-from-disk-warning=<b>{0}</b> 外掛程式可以存取你的所有數據,你確定要安裝嗎?
termora.settings.plugin.install-from-disk-warning=<b>{0}</b> 插件可以存取你的所有數據,你確定要安裝嗎?
termora.settings.account=帳號
termora.settings.account.login=登入