diff --git a/src/main/kotlin/app/termora/plugin/internal/plugin/MarketplacePanel.kt b/src/main/kotlin/app/termora/plugin/internal/plugin/MarketplacePanel.kt index 480d521..154326a 100644 --- a/src/main/kotlin/app/termora/plugin/internal/plugin/MarketplacePanel.kt +++ b/src/main/kotlin/app/termora/plugin/internal/plugin/MarketplacePanel.kt @@ -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,39 +92,42 @@ class MarketplacePanel : JPanel(BorderLayout()), Disposable { reload() } - private fun reload() { - coroutineScope.launch { - withContext(Dispatchers.Swing) { - cardLayout.show(cardPanel, PanelState.Loading.name) - } - - // 获取插件 - try { - val loadedPlugins = PluginManager.getInstance().getLoadedPluginDescriptor() - val plugins = marketplaceManager.getPlugins() - .filterNot { e -> loadedPlugins.any { it.id == e.id && it.version >= e.versions.first().version } } - + fun reload() { + if (isLoading.compareAndSet(false, true)) { + coroutineScope.launch { withContext(Dispatchers.Swing) { - if (plugins.isNotEmpty()) { - pluginsPanel.removeAll() - for (plugin in plugins) { - pluginsPanel.add(createMarketplacePluginPanel(plugin)) - pluginsPanel.add(JToolBar.Separator()) + cardLayout.show(cardPanel, PanelState.Loading.name) + } + + // 获取插件 + try { + val loadedPlugins = PluginManager.getInstance().getLoadedPluginDescriptor() + val plugins = marketplaceManager.getPlugins() + .filterNot { e -> loadedPlugins.any { it.id == e.id && it.version >= e.versions.first().version } } + + withContext(Dispatchers.Swing) { + if (plugins.isNotEmpty()) { + pluginsPanel.removeAll() + for (plugin in plugins) { + pluginsPanel.add(createMarketplacePluginPanel(plugin)) + pluginsPanel.add(JToolBar.Separator()) + } + pluginsPanel.add(createRetry()) + cardLayout.show(cardPanel, PanelState.Plugins.name) + } else { + failedLabel.text = "No plugins found" + cardLayout.show(cardPanel, PanelState.FetchFailed.name) } - pluginsPanel.add(createRetry()) - cardLayout.show(cardPanel, PanelState.Plugins.name) - } else { - failedLabel.text = "No plugins found" + } + } catch (_: Exception) { + withContext(Dispatchers.Swing) { + failedLabel.text = "Failed to fetch the plugins" cardLayout.show(cardPanel, PanelState.FetchFailed.name) } - } - } catch (_: Exception) { - withContext(Dispatchers.Swing) { - failedLabel.text = "Failed to fetch the plugins" - cardLayout.show(cardPanel, PanelState.FetchFailed.name) + } finally { + isLoading.set(true) } } - } } diff --git a/src/main/kotlin/app/termora/plugin/internal/plugin/PluginOption.kt b/src/main/kotlin/app/termora/plugin/internal/plugin/PluginOption.kt index 86a82a6..8391186 100644 --- a/src/main/kotlin/app/termora/plugin/internal/plugin/PluginOption.kt +++ b/src/main/kotlin/app/termora/plugin/internal/plugin/PluginOption.kt @@ -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) } diff --git a/src/main/kotlin/app/termora/plugin/internal/plugin/PluginRepositoryDialog.kt b/src/main/kotlin/app/termora/plugin/internal/plugin/PluginRepositoryDialog.kt new file mode 100644 index 0000000..79b832a --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/plugin/PluginRepositoryDialog.kt @@ -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() + 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 + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/plugin/PluginRepositoryManager.kt b/src/main/kotlin/app/termora/plugin/internal/plugin/PluginRepositoryManager.kt new file mode 100644 index 0000000..1d3dacb --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/plugin/PluginRepositoryManager.kt @@ -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 { + synchronized(this) { + val text = enableManager.getFlag(KEY, StringUtils.EMPTY) + if (text.isBlank()) return emptyList() + return runCatching { ohMyJson.decodeFromString>(text) } + .getOrNull() ?: emptyList() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/marketplace/MarketplaceManager.kt b/src/main/kotlin/app/termora/plugin/marketplace/MarketplaceManager.kt index dd19c76..daa68a3 100644 --- a/src/main/kotlin/app/termora/plugin/marketplace/MarketplaceManager.kt +++ b/src/main/kotlin/app/termora/plugin/marketplace/MarketplaceManager.kt @@ -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 { 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() + val executorService = Executors.newVirtualThreadPerTaskExecutor() + val futures = repositories + .map { url -> executorService.submit> { 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() + + // 获取到合适的插件 + 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 { + 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() - - // 获取到合适的插件 - 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 } diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 4bb89b6..a84b6f5 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -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 {0} ? 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={0} plugin will have access to all your data. Are you sure you want to install it? termora.settings.account=Account diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index e756b0d..27bf6b0 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -117,6 +117,7 @@ termora.settings.plugin.cannot-uninstall=系统内置插件不可以卸载 termora.settings.plugin.uninstall-confirm=你确定要卸载 {0} 吗? 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={0} 插件可以访问你的所有数据,你确定要安装吗? diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index c9d2f8b..cff4b2b 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -128,8 +128,9 @@ termora.settings.plugin.cannot-uninstall=系統內建插件無法卸載 termora.settings.plugin.uninstall-confirm=你確定要卸載 {0} 嗎? 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={0} 外掛程式可以存取你的所有數據,你確定要安裝嗎? +termora.settings.plugin.install-from-disk-warning={0} 插件可以存取你的所有數據,你確定要安裝嗎? termora.settings.account=帳號 termora.settings.account.login=登入