chore: improve upgrade

This commit is contained in:
hstyi
2025-07-21 09:58:37 +08:00
committed by GitHub
parent 7d9cfc79cf
commit b2f43ba439
9 changed files with 393 additions and 352 deletions

View File

@@ -127,12 +127,19 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
}
protected open fun createSouthPanel(): JComponent? {
val westSourcePanel = createWestSourcePanel()
val box = Box.createHorizontalBox()
if (westSourcePanel != null) {
box.add(westSourcePanel)
} else {
box.add(Box.createHorizontalGlue())
}
box.border = BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor),
BorderFactory.createEmptyBorder(8, 12, 8, 12)
)
box.add(Box.createHorizontalGlue())
val actions = createActions()
for (i in actions.size - 1 downTo 0) {
@@ -145,6 +152,10 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
return box
}
protected open fun createWestSourcePanel(): JComponent? {
return null
}
protected open fun createActions(): List<AbstractAction> {
return listOf(createOkAction(), createCancelAction())
}

View File

@@ -2,7 +2,6 @@ package app.termora
import app.termora.actions.StateAction
import app.termora.findeverywhere.FindEverywhereAction
import app.termora.plugin.internal.update.AppUpdateAction
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatToolBar
@@ -84,9 +83,6 @@ internal class MyTermoraToolbar(private val windowScope: WindowScope, private va
add(Box.createHorizontalGlue())
// update
add(redirectUpdateAction(disposable))
for (action in model.getActions()) {
if (action.visible.not()) continue
val action = actionManager.getAction(action.id) ?: continue
@@ -122,34 +118,6 @@ internal class MyTermoraToolbar(private val windowScope: WindowScope, private va
toolbar.add(spacing)
}
private fun redirectUpdateAction(disposable: Disposable): AbstractButton {
val action = AppUpdateAction.getInstance()
val button = JButton(action.smallIcon)
button.toolTipText = (action.getValue(Action.SHORT_DESCRIPTION) as? String)
?: action.getValue(Action.NAME) as? String
button.isVisible = action.isEnabled
button.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
action.actionPerformed(e)
}
})
val listener = object : PropertyChangeListener, Disposable {
override fun propertyChange(evt: PropertyChangeEvent) {
button.isVisible = action.isEnabled
}
override fun dispose() {
action.removePropertyChangeListener(this)
}
}
action.addPropertyChangeListener(listener)
Disposer.register(disposable, listener)
return button
}
private fun redirectAction(action: Action, disposable: Disposable): AbstractButton {
val button = if (action is StateAction) JToggleButton() else JButton()
button.toolTipText = (action.getValue(Action.SHORT_DESCRIPTION) as? String)

View File

@@ -12,7 +12,7 @@ import app.termora.plugin.internal.rdp.RDPInternalPlugin
import app.termora.plugin.internal.sftppty.SFTPPtyInternalPlugin
import app.termora.plugin.internal.ssh.SSHInternalPlugin
import app.termora.plugin.internal.telnet.TelnetInternalPlugin
import app.termora.plugin.internal.update.UpdatePlugin
import app.termora.plugin.internal.updater.UpdaterPlugin
import app.termora.plugin.internal.wsl.WSLInternalPlugin
import app.termora.swingCoroutineScope
import app.termora.terminal.panel.vw.FloatingToolbarPlugin
@@ -111,7 +111,7 @@ internal class PluginManager private constructor() {
// badge plugin
plugins.add(PluginDescriptor(BadgePlugin(), origin = PluginOrigin.Internal, version = version))
// update plugin
plugins.add(PluginDescriptor(UpdatePlugin(), origin = PluginOrigin.Internal, version = version))
plugins.add(PluginDescriptor(UpdaterPlugin(), origin = PluginOrigin.Internal, version = version))
// frame plugin
plugins.add(PluginDescriptor(FramePlugin(), origin = PluginOrigin.Internal, version = version))

View File

@@ -1,134 +0,0 @@
package app.termora.plugin.internal.update
import app.termora.*
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import com.formdev.flatlaf.util.SystemInfo
import com.sun.jna.platform.win32.Advapi32
import com.sun.jna.platform.win32.WinError
import com.sun.jna.platform.win32.WinNT
import com.sun.jna.platform.win32.WinReg
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXEditorPane
import org.slf4j.LoggerFactory
import java.awt.Dimension
import java.awt.KeyboardFocusManager
import java.net.URI
import javax.swing.BorderFactory
import javax.swing.JOptionPane
import javax.swing.JScrollPane
import javax.swing.UIManager
import javax.swing.event.HyperlinkEvent
internal class AppUpdateAction private constructor() : AnAction(StringUtils.EMPTY, Icons.ideUpdate) {
companion object {
private val log = LoggerFactory.getLogger(AppUpdateAction::class.java)
fun getInstance(): AppUpdateAction {
return ApplicationScope.forApplicationScope().getOrCreate(AppUpdateAction::class) { AppUpdateAction() }
}
}
private val updaterManager get() = UpdaterManager.getInstance()
init {
isEnabled = false
}
override fun actionPerformed(evt: AnActionEvent) {
showUpdateDialog()
}
private fun showUpdateDialog() {
val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow
val lastVersion = updaterManager.lastVersion
val editorPane = JXEditorPane()
editorPane.contentType = "text/html"
editorPane.text = lastVersion.htmlBody
editorPane.isEditable = false
editorPane.addHyperlinkListener {
if (it.eventType == HyperlinkEvent.EventType.ACTIVATED) {
Application.browse(it.url.toURI())
}
}
editorPane.background = DynamicColor("window")
val scrollPane = JScrollPane(editorPane)
scrollPane.border = BorderFactory.createEmptyBorder()
scrollPane.preferredSize = Dimension(
UIManager.getInt("Dialog.width") - 100,
UIManager.getInt("Dialog.height") - 100
)
val option = OptionPane.showConfirmDialog(
owner,
scrollPane,
title = I18n.getString("termora.update.title"),
messageType = JOptionPane.PLAIN_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION,
options = arrayOf(
I18n.getString("termora.update.update"),
I18n.getString("termora.cancel")
),
initialValue = I18n.getString("termora.update.update")
)
if (option == JOptionPane.CANCEL_OPTION) {
return
} else if (option == JOptionPane.OK_OPTION) {
updateSelf(lastVersion)
}
}
private fun updateSelf(latestVersion: UpdaterManager.LatestVersion) {
val pkg = Updater.getInstance().getLatestPkg()
if (SystemInfo.isLinux || pkg == null) {
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${latestVersion.version}"))
return
}
val file = pkg.file
val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
val commands = if (SystemInfo.isMacOS) listOf("open", "-n", file.absolutePath)
// 如果安装过,那么直接静默安装和自动启动
else if (isAppInstalled()) listOf(
file.absolutePath,
"/SILENT",
"/AUTOSTART",
"/NORESTART",
"/FORCECLOSEAPPLICATIONS"
)
// 没有安装过 则打开安装向导
else listOf(file.absolutePath)
if (log.isInfoEnabled) {
log.info("restart {}", commands.joinToString(StringUtils.SPACE))
}
TermoraRestarter.getInstance().scheduleRestart(owner, true, commands)
}
private fun isAppInstalled(): Boolean {
val keyPath = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${Application.getName()}_is1"
val phkKey = WinReg.HKEYByReference()
// 尝试打开注册表键
val result = Advapi32.INSTANCE.RegOpenKeyEx(
WinReg.HKEY_LOCAL_MACHINE,
keyPath,
0,
WinNT.KEY_READ,
phkKey
)
if (result == WinError.ERROR_SUCCESS) {
// 键存在,关闭句柄
Advapi32.INSTANCE.RegCloseKey(phkKey.getValue())
return true
} else {
// 键不存在或无权限
return false
}
}
}

View File

@@ -1,13 +0,0 @@
package app.termora.plugin.internal.update
import app.termora.ApplicationRunnerExtension
internal class MyApplicationRunnerExtension private constructor() : ApplicationRunnerExtension {
companion object {
val instance = MyApplicationRunnerExtension()
}
override fun ready() {
Updater.getInstance().scheduleUpdate()
}
}

View File

@@ -1,166 +0,0 @@
package app.termora.plugin.internal.update
import app.termora.*
import app.termora.Application.httpClient
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.*
import okhttp3.Request
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.semver4j.Semver
import org.slf4j.LoggerFactory
import java.io.File
import java.net.ProxySelector
import java.util.*
import java.util.concurrent.TimeUnit
import javax.swing.SwingUtilities
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
internal class Updater private constructor() : Disposable {
companion object {
private val log = LoggerFactory.getLogger(Updater::class.java)
fun getInstance(): Updater {
return ApplicationScope.forApplicationScope().getOrCreate(Updater::class) { Updater() }
}
}
private val updaterManager get() = UpdaterManager.getInstance()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var isRemindMeNextTime = false
private val disabledUpdater get() = Application.getLayout() == AppLayout.Appx
/**
* 安装包位置
*/
private var pkg: LatestPkg? = null
fun scheduleUpdate() {
if (disabledUpdater) {
if (coroutineScope.isActive) {
coroutineScope.cancel()
}
return
}
coroutineScope.launch(Dispatchers.IO) {
// 启动 3 分钟后才是检查
if (Application.isUnknownVersion().not()) {
delay(3.seconds)
}
while (coroutineScope.isActive) {
// 下次提醒我
if (isRemindMeNextTime) break
try {
checkUpdate()
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn(e.message, e)
}
}
// 之后每 3 小时检查一次
delay(3.hours.inWholeMilliseconds)
}
}
}
private fun checkUpdate() {
// Windows 应用商店
if (disabledUpdater) return
val latestVersion = updaterManager.fetchLatestVersion()
if (latestVersion.isSelf) {
return
}
// 之所以放到后面检查是不是开发版本,是需要发起一次检测请求,以方便调试
if (Application.isUnknownVersion()) {
return
}
val newVersion = Semver.parse(latestVersion.version) ?: return
val version = Semver.parse(Application.getVersion()) ?: return
if (newVersion <= version) {
return
}
try {
downloadLatestPkg(latestVersion)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
private fun downloadLatestPkg(latestVersion: UpdaterManager.LatestVersion) {
if (SystemInfo.isLinux) return
setLatestPkg(null)
val arch = if (SystemInfo.isAARCH64) "aarch64" else "x86-64"
val osName = if (SystemInfo.isWindows) "windows" else "osx"
val suffix = if (SystemInfo.isWindows) "exe" else "dmg"
val filename = "termora-${latestVersion.version}-${osName}-${arch}.${suffix}"
val asset = latestVersion.assets.find { it.name == filename } ?: return
val response = httpClient
.newBuilder()
.callTimeout(15, TimeUnit.MINUTES)
.readTimeout(15, TimeUnit.MINUTES)
.proxySelector(ProxySelector.getDefault())
.build()
.newCall(Request.Builder().url(asset.downloadUrl).build())
.execute()
if (response.isSuccessful.not()) {
if (log.isErrorEnabled) {
log.warn("Failed to download latest version ${latestVersion.version}, response code ${response.code}")
}
IOUtils.closeQuietly(response)
return
}
val body = response.body
val input = body.byteStream()
val file = FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}-${filename}")
val output = file.outputStream()
val downloaded = runCatching { IOUtils.copy(input, output) }.isSuccess
IOUtils.closeQuietly(input, output, body, response)
if (!downloaded) {
if (log.isErrorEnabled) {
log.error("Failed to download latest version to $filename")
}
return
}
if (log.isInfoEnabled) {
log.info("Successfully downloaded latest version to $file")
}
setLatestPkg(LatestPkg(latestVersion.version, file))
}
private fun setLatestPkg(pkg: LatestPkg?) {
this.pkg = pkg
SwingUtilities.invokeLater { AppUpdateAction.getInstance().isEnabled = pkg != null }
}
fun getLatestPkg(): LatestPkg? {
return pkg
}
data class LatestPkg(val version: String, val file: File)
}

View File

@@ -0,0 +1,57 @@
package app.termora.plugin.internal.updater
import app.termora.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.semver4j.Semver
import org.slf4j.LoggerFactory
import java.awt.KeyboardFocusManager
import kotlin.time.Duration.Companion.seconds
internal class MyApplicationRunnerExtension private constructor() : ApplicationRunnerExtension {
companion object {
val instance = MyApplicationRunnerExtension()
private val log = LoggerFactory.getLogger(MyApplicationRunnerExtension::class.java)
}
private val disabledUpdater get() = Application.getLayout() == AppLayout.Appx
private val updaterManager get() = UpdaterManager.getInstance()
override fun ready() {
swingCoroutineScope.launch {
try {
delay(3.seconds)
scheduleUpdate()
} catch (e: Exception) {
log.error(e.message, e)
}
}
}
private fun scheduleUpdate() {
if (disabledUpdater) return
val latestVersion = updaterManager.fetchLatestVersion()
if (latestVersion.isSelf) {
return
}
val newVersion = Semver.parse(latestVersion.version) ?: return
val version = Semver.parse(Application.getVersion()) ?: return
if (newVersion <= version) {
return
}
val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow
?: TermoraFrameManager.getInstance().getWindows().firstOrNull()
if (owner == null) return
val dialog = UpdaterDialog(owner, latestVersion)
dialog.isModal = true
dialog.isVisible = true
}
}

View File

@@ -0,0 +1,317 @@
package app.termora.plugin.internal.updater
import app.termora.*
import app.termora.Application.httpClient
import com.formdev.flatlaf.util.SystemInfo
import com.sun.jna.platform.win32.Advapi32
import com.sun.jna.platform.win32.WinError
import com.sun.jna.platform.win32.WinNT
import com.sun.jna.platform.win32.WinReg
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import okhttp3.Request
import okhttp3.internal.closeQuietly
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.Strings
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.net.io.CopyStreamEvent
import org.apache.commons.net.io.CopyStreamListener
import org.apache.commons.net.io.Util
import org.jdesktop.swingx.JXEditorPane
import org.slf4j.LoggerFactory
import java.awt.Dimension
import java.awt.Window
import java.net.URI
import java.util.*
import java.util.concurrent.Executors
import javax.swing.*
import javax.swing.event.HyperlinkEvent
import kotlin.math.floor
internal class UpdaterDialog(owner: Window, private val latestVersion: UpdaterManager.LatestVersion) :
DialogWrapper(owner) {
companion object {
private val log = LoggerFactory.getLogger(UpdaterDialog::class.java)
}
private enum class State {
Ready,
Downloading,
Downloaded,
}
private val progressBar = JProgressBar()
private val okAction = OkAction(I18n.getString("termora.update.update"))
private val okButton = JButton(okAction)
private var state = State.Ready
private val westSourcePanel = Box.createHorizontalBox()
private val glue = Box.createHorizontalGlue()
private val layout = Application.getLayout()
private val dialog get() = this
private val executorService = Executors.newVirtualThreadPerTaskExecutor()
private val coroutineDispatcher = executorService.asCoroutineDispatcher()
private val coroutineScope = CoroutineScope(coroutineDispatcher)
init {
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
isModal = true
controlsVisible = false
isResizable = false
escapeDispose = false
title = I18n.getString("termora.update.title")
setLocationRelativeTo(owner)
init()
initView()
initEvents()
}
private fun initView() {
progressBar.isIndeterminate = true
progressBar.isStringPainted = true
westSourcePanel.add(glue)
westSourcePanel.add(progressBar)
westSourcePanel.add(Box.createHorizontalStrut(20))
progressBar.isVisible = false
}
private fun initEvents() {
Disposer.register(disposable, object : Disposable {
override fun dispose() {
coroutineScope.cancel()
coroutineDispatcher.close()
executorService.shutdownNow()
}
})
}
override fun createCenterPanel(): JComponent {
val editorPane = JXEditorPane()
editorPane.contentType = "text/html"
editorPane.text = latestVersion.htmlBody
editorPane.isEditable = false
editorPane.addHyperlinkListener {
if (it.eventType == HyperlinkEvent.EventType.ACTIVATED) {
Application.browse(it.url.toURI())
}
}
editorPane.background = DynamicColor("window")
val scrollPane = JScrollPane(editorPane)
scrollPane.border = BorderFactory.createEmptyBorder()
return scrollPane
}
override fun createWestSourcePanel(): JComponent {
return westSourcePanel
}
override fun createActions(): List<AbstractAction> {
return listOf(okAction, createCancelAction())
}
override fun createJButtonForAction(action: Action): JButton {
if (action == okAction) {
rootPane.defaultButton = okButton
return okButton
}
return super.createJButtonForAction(action)
}
@Suppress("CascadeIf")
override fun doOKAction() {
if (state == State.Ready) {
okButton.text = "${okButton.text}..."
okButton.isEnabled = false
progressBar.isVisible = true
glue.isVisible = false
state = State.Downloading
coroutineScope.launch {
try {
downloadPkg()
} catch (_: LatestReleaseException) {
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/latest"))
SwingUtilities.invokeLater { doCancelAction() }
} catch (e: Exception) {
if (log.isErrorEnabled) log.error(e.message, e)
withContext(Dispatchers.Swing) {
state = State.Ready
okButton.isEnabled = true
okButton.text = okAction.name
progressBar.isVisible = false
glue.isVisible = true
OptionPane.showMessageDialog(
dialog,
StringUtils.defaultIfBlank(e.message, ExceptionUtils.getRootCauseMessage(e)).toString(),
messageType = JOptionPane.ERROR_MESSAGE
)
}
} finally {
withContext(Dispatchers.Swing) {
progressBar.isVisible = false
glue.isVisible = true
okButton.isEnabled = true
}
}
}
return
} else if (state == State.Downloading) {
return
} else if (state == State.Downloaded) {
super.doOKAction()
}
}
private suspend fun downloadPkg() {
val arch = if (SystemInfo.isAARCH64) "aarch64" else "x86-64"
val osName = if (SystemInfo.isWindows) "windows" else if (SystemInfo.isLinux) "linux" else "osx"
val suffix = when (layout) {
AppLayout.Zip -> "zip"
AppLayout.Exe -> "exe"
AppLayout.App -> "dmg"
AppLayout.TarGz -> "tar.gz"
AppLayout.AppImage -> "AppImage"
AppLayout.Deb -> "deb"
else -> throw LatestReleaseException()
}
val filename = "termora-${latestVersion.version}-${osName}-${arch}.${suffix}"
val asset = latestVersion.assets.find { it.name == filename } ?: throw LatestReleaseException()
var url = asset.downloadUrl
if (I18n.isChinaMainland()) {
url = Strings.CI.replace(
url,
"https://github.com/TermoraDev/termora/releases/download/",
"https://dl.termora.cn/termora/"
)
}
val response = httpClient.newCall(Request.Builder().url(url).get().build())
.execute()
if (response.isSuccessful.not()) {
response.closeQuietly()
throw IllegalStateException("Failed to download asset $filename")
}
withContext(Dispatchers.Swing) {
progressBar.isIndeterminate = false
}
val listener = object : CopyStreamListener {
override fun bytesTransferred(event: CopyStreamEvent?) {
TODO("Not yet implemented")
}
override fun bytesTransferred(
totalBytesTransferred: Long,
bytesTransferred: Int,
streamSize: Long
) {
SwingUtilities.invokeLater {
val progress = 1.0 * totalBytesTransferred / asset.size * 100
progressBar.value = floor(progress).toInt()
progressBar.string = formatBytes(totalBytesTransferred) + " / " + formatBytes(asset.size)
}
}
}
val file = FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}-${filename}")
response.use {
response.body.byteStream().use { input ->
file.outputStream().use { output ->
Util.copyStream(
input, output, Util.DEFAULT_COPY_BUFFER_SIZE,
asset.size, listener
)
}
}
}
withContext(Dispatchers.Swing) {
state = State.Downloaded
}
val commands = mutableListOf<String>()
if (SystemInfo.isMacOS) {
commands.addAll(listOf("open", "-n", file.absolutePath))
} else if (layout == AppLayout.Zip) {
commands.addAll(listOf("explorer", "/select," + file.absolutePath))
} else if (layout == AppLayout.Exe) {
// 如果安装过,那么直接静默安装和自动启动
if (isAppInstalled()) {
commands.addAll(
listOf(
file.absolutePath,
"/SILENT",
"/AUTOSTART",
"/NORESTART",
"/FORCECLOSEAPPLICATIONS"
)
)
} else {
commands.addAll(listOf(file.absolutePath))
}
} else if (SystemInfo.isLinux) {
commands.addAll(listOf("xdg-open", file.parentFile.absolutePath))
}
if (log.isInfoEnabled) {
log.info("commands: {}", commands.joinToString(StringUtils.SPACE))
}
SwingUtilities.invokeLater {
super.doOKAction()
TermoraRestarter.getInstance().scheduleRestart(owner, true, commands)
}
}
private class LatestReleaseException() : RuntimeException()
private fun isAppInstalled(): Boolean {
try {
val keyPath = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${Application.getName()}_is1"
val phkKey = WinReg.HKEYByReference()
// 尝试打开注册表键
val result = Advapi32.INSTANCE.RegOpenKeyEx(
WinReg.HKEY_LOCAL_MACHINE,
keyPath,
0,
WinNT.KEY_READ,
phkKey
)
if (result == WinError.ERROR_SUCCESS) {
// 键存在,关闭句柄
Advapi32.INSTANCE.RegCloseKey(phkKey.getValue())
return true
} else {
// 键不存在或无权限
return false
}
} catch (_: Exception) {
return false
}
}
override fun addNotify() {
super.addNotify()
}
}

View File

@@ -1,21 +1,22 @@
package app.termora.plugin.internal.update
package app.termora.plugin.internal.updater
import app.termora.ApplicationRunnerExtension
import app.termora.plugin.Extension
import app.termora.plugin.InternalPlugin
internal class UpdatePlugin : InternalPlugin() {
internal class UpdaterPlugin : InternalPlugin() {
init {
support.addExtension(ApplicationRunnerExtension::class.java) { MyApplicationRunnerExtension.instance }
}
override fun getName(): String {
return "Update"
return "Updater"
}
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
return support.getExtensions(clazz)
}
}