fix: unable to update automatically

This commit is contained in:
hstyi
2025-07-09 12:13:51 +08:00
committed by hstyi
parent 45135b7299
commit 5868aa4d2f
16 changed files with 415 additions and 306 deletions

View File

@@ -12,12 +12,6 @@ object Actions {
*/ */
const val KEY_MANAGER = "KeyManagerAction" const val KEY_MANAGER = "KeyManagerAction"
/**
* 更新
*/
const val APP_UPDATE = "AppUpdateAction"
/** /**
* 宏 * 宏
*/ */

View File

@@ -2,19 +2,19 @@ package app.termora
import app.termora.actions.StateAction import app.termora.actions.StateAction
import app.termora.findeverywhere.FindEverywhereAction import app.termora.findeverywhere.FindEverywhereAction
import app.termora.plugin.internal.badge.Badge 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.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatToolBar import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.util.SystemInfo
import java.awt.AWTEvent import java.awt.AWTEvent
import java.awt.Rectangle import java.awt.Rectangle
import java.awt.event.AWTEventListener import java.awt.event.*
import java.awt.event.ActionEvent
import java.awt.event.MouseEvent
import java.beans.PropertyChangeEvent import java.beans.PropertyChangeEvent
import java.beans.PropertyChangeListener import java.beans.PropertyChangeListener
import javax.swing.* import javax.swing.*
internal class MyTermoraToolbar(private val windowScope: WindowScope) : FlatToolBar() { internal class MyTermoraToolbar(private val windowScope: WindowScope, private val frame: TermoraFrame) : FlatToolBar() {
private val customizeToolBarAWTEventListener = CustomizeToolBarAWTEventListener() private val customizeToolBarAWTEventListener = CustomizeToolBarAWTEventListener()
@@ -56,6 +56,14 @@ internal class MyTermoraToolbar(private val windowScope: WindowScope) : FlatTool
} }
}).let { Disposer.register(windowScope, it) } }).let { Disposer.register(windowScope, it) }
// 监听窗口大小变动,然后修改边距避开控制按钮
if (SystemInfo.isWindows || SystemInfo.isLinux) {
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
adjust()
}
})
}
} }
private fun refreshActions() { private fun refreshActions() {
@@ -76,16 +84,70 @@ internal class MyTermoraToolbar(private val windowScope: WindowScope) : FlatTool
add(Box.createHorizontalGlue()) add(Box.createHorizontalGlue())
// update
add(redirectUpdateAction(disposable))
for (action in model.getActions()) { for (action in model.getActions()) {
if (action.visible.not()) continue if (action.visible.not()) continue
val action = actionManager.getAction(action.id) ?: continue val action = actionManager.getAction(action.id) ?: continue
add(redirectAction(action, disposable)) add(redirectAction(action, disposable))
} }
if (SystemInfo.isWindows || SystemInfo.isLinux) {
adjust()
}
revalidate() revalidate()
repaint() repaint()
} }
private fun adjust() {
val rectangle = frame.rootPane.getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS)
as? Rectangle ?: return
val right = rectangle.width
for (i in 0 until toolbar.componentCount) {
val c = toolbar.getComponent(i)
if (c.name == "spacing") {
if (c.width == right) {
return
}
toolbar.remove(i)
break
}
}
val spacing = Box.createHorizontalStrut(right)
spacing.name = "spacing"
toolbar.add(spacing)
}
private fun redirectUpdateAction(disposable: Disposable): AbstractButton {
val action = AppUpdateAction.getInstance()
val button = JButton(action.smallIcon)
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 { private fun redirectAction(action: Action, disposable: Disposable): AbstractButton {
val button = if (action is StateAction) JToggleButton() else JButton() val button = if (action is StateAction) JToggleButton() else JButton()
button.toolTipText = action.getValue(Action.SHORT_DESCRIPTION) as? String button.toolTipText = action.getValue(Action.SHORT_DESCRIPTION) as? String
@@ -100,16 +162,7 @@ internal class MyTermoraToolbar(private val windowScope: WindowScope) : FlatTool
}) })
val listener = object : PropertyChangeListener, Disposable { val listener = object : PropertyChangeListener, Disposable {
private val badge get() = Badge.getInstance(windowScope)
override fun propertyChange(evt: PropertyChangeEvent) { override fun propertyChange(evt: PropertyChangeEvent) {
if (evt.propertyName == "Badge") {
if (action.getValue("Badge") == true) {
badge.addBadge(button)
} else {
badge.removeBadge(button)
}
}
if (action is StateAction) { if (action is StateAction) {
button.isSelected = action.isSelected(windowScope) button.isSelected = action.isSelected(windowScope)
} }

View File

@@ -42,7 +42,7 @@ class TermoraFrame : JFrame(), DataProvider {
private val id = UUID.randomUUID().toString() private val id = UUID.randomUUID().toString()
private val windowScope = ApplicationScope.forWindowScope(this) private val windowScope = ApplicationScope.forWindowScope(this)
private val tabbedPane = MyTabbedPane().apply { tabHeight = titleBarHeight } private val tabbedPane = MyTabbedPane().apply { tabHeight = titleBarHeight }
private val toolbar = MyTermoraToolbar(windowScope) private val toolbar = MyTermoraToolbar(windowScope, this)
private val terminalTabbed = TerminalTabbed(windowScope, tabbedPane, layout) private val terminalTabbed = TerminalTabbed(windowScope, tabbedPane, layout)
private val dataProviderSupport = DataProviderSupport() private val dataProviderSupport = DataProviderSupport()
private var notifyListeners = emptyArray<NotifyListener>() private var notifyListeners = emptyArray<NotifyListener>()

View File

@@ -86,8 +86,8 @@ class UpdaterManager private constructor() {
return LatestVersion.self return LatestVersion.self
} }
val text = response.use { resp -> resp.body?.use { it.string() } } val text = response.use { resp -> resp.body.use { it.string() } }
if (text.isNullOrBlank()) { if (text.isBlank()) {
return LatestVersion.self return LatestVersion.self
} }

View File

@@ -30,7 +30,6 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() {
addAction(FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction()) addAction(FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction())
addAction(QuickConnectAction.QUICK_CONNECT, QuickConnectAction.instance) addAction(QuickConnectAction.QUICK_CONNECT, QuickConnectAction.instance)
addAction(Actions.APP_UPDATE, AppUpdateAction.getInstance())
addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction()) addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction())
addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction()) addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
addAction(Actions.SFTP, TransferAnAction()) addAction(Actions.SFTP, TransferAnAction())
@@ -42,7 +41,7 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() {
addAction(SwitchTabAction.SWITCH_TAB, SwitchTabAction()) addAction(SwitchTabAction.SWITCH_TAB, SwitchTabAction())
addAction(TabReconnectAction.RECONNECT_TAB, TabReconnectAction()) addAction(TabReconnectAction.RECONNECT_TAB, TabReconnectAction())
addAction(SettingsAction.SETTING, SettingsAction()) addAction(SettingsAction.SETTING, SettingsAction.getInstance())
addAction(NewHostAction.NEW_HOST, NewHostAction()) addAction(NewHostAction.NEW_HOST, NewHostAction())
addAction(OpenHostAction.OPEN_HOST, OpenHostAction()) addAction(OpenHostAction.OPEN_HOST, OpenHostAction())

View File

@@ -1,272 +0,0 @@
package app.termora.actions
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 org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXEditorPane
import org.semver4j.Semver
import org.slf4j.LoggerFactory
import java.awt.Dimension
import java.awt.KeyboardFocusManager
import java.io.File
import java.net.ProxySelector
import java.net.URI
import java.util.*
import java.util.concurrent.TimeUnit
import javax.swing.BorderFactory
import javax.swing.JOptionPane
import javax.swing.JScrollPane
import javax.swing.UIManager
import javax.swing.event.HyperlinkEvent
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
class AppUpdateAction private constructor() : AnAction(
StringUtils.EMPTY,
Icons.ideUpdate
) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Swing)
companion object {
private val log = LoggerFactory.getLogger(AppUpdateAction::class.java)
private const val PKG_FILE_KEY = "pkgFile"
fun getInstance(): AppUpdateAction {
return ApplicationScope.forApplicationScope().getOrCreate(AppUpdateAction::class) { AppUpdateAction() }
}
}
private val updaterManager get() = UpdaterManager.getInstance()
private var isRemindMeNextTime = false
init {
isEnabled = false
scheduleUpdate()
}
override fun actionPerformed(evt: AnActionEvent) {
showUpdateDialog()
}
private fun scheduleUpdate() {
coroutineScope.launch(Dispatchers.IO) {
// 启动 3 分钟后才是检查
if (Application.isUnknownVersion().not()) {
delay(3.minutes)
}
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 suspend fun checkUpdate() {
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)
}
}
withContext(Dispatchers.Swing) { isEnabled = true }
}
private suspend fun downloadLatestPkg(latestVersion: UpdaterManager.LatestVersion) {
if (SystemInfo.isLinux) return
super.putValue(PKG_FILE_KEY, 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")
}
withContext(Dispatchers.Swing) { setLatestPkgFile(file) }
}
private fun setLatestPkgFile(file: File) {
putValue(PKG_FILE_KEY, file)
}
private fun getLatestPkgFile(): File? {
return getValue(PKG_FILE_KEY) as? File
}
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.YES_NO_CANCEL_OPTION,
options = arrayOf(
I18n.getString("termora.update.update"),
I18n.getString("termora.update.ignore"),
I18n.getString("termora.cancel")
),
initialValue = I18n.getString("termora.update.update")
)
if (option == JOptionPane.CANCEL_OPTION) {
return
} else if (option == JOptionPane.NO_OPTION) {
isEnabled = false
isRemindMeNextTime = true
} else if (option == JOptionPane.YES_OPTION) {
updateSelf(lastVersion)
}
}
private fun updateSelf(latestVersion: UpdaterManager.LatestVersion) {
val file = getLatestPkgFile()
if (SystemInfo.isLinux || file == null) {
isEnabled = false
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${latestVersion.version}"))
return
}
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

@@ -11,19 +11,25 @@ import java.awt.event.ActionEvent
import java.awt.event.WindowAdapter import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent import java.awt.event.WindowEvent
class SettingsAction : AnAction( class SettingsAction private constructor() : AnAction(
I18n.getString("termora.setting"), I18n.getString("termora.setting"),
Icons.settings Icons.settings
) { ) {
companion object { companion object {
/** /**
* 打开设置 * 打开设置
*/ */
const val SETTING = "SettingAction" const val SETTING = "SettingAction"
fun getInstance(): SettingsAction {
return ApplicationScope.forApplicationScope().getOrCreate(SettingsAction::class) { SettingsAction() }
}
} }
private var isShowing = false private var isShowing = false
private val action get() = this
init { init {
FlatDesktop.setPreferencesHandler { FlatDesktop.setPreferencesHandler {
@@ -36,20 +42,25 @@ class SettingsAction : AnAction(
} }
override fun actionPerformed(evt: AnActionEvent) { override fun actionPerformed(evt: AnActionEvent) {
if (isShowing) { if (isShowing) return
return showSettingsDialog(evt)
} }
private fun showSettingsDialog(evt: AnActionEvent) {
isShowing = true isShowing = true
val owner = evt.window val owner = evt.window
val dialog = SettingsDialog(owner) val dialog = SettingsDialog(owner)
dialog.addWindowListener(object : WindowAdapter() { dialog.addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) { override fun windowClosed(e: WindowEvent) {
this@SettingsAction.isShowing = false action.isShowing = false
} }
}) })
dialog.setLocationRelativeTo(owner) dialog.setLocationRelativeTo(owner)
dialog.isVisible = true dialog.isVisible = true
} }
} }

View File

@@ -11,6 +11,7 @@ import app.termora.plugin.internal.rdp.RDPInternalPlugin
import app.termora.plugin.internal.sftppty.SFTPPtyInternalPlugin import app.termora.plugin.internal.sftppty.SFTPPtyInternalPlugin
import app.termora.plugin.internal.ssh.SSHInternalPlugin import app.termora.plugin.internal.ssh.SSHInternalPlugin
import app.termora.plugin.internal.telnet.TelnetInternalPlugin import app.termora.plugin.internal.telnet.TelnetInternalPlugin
import app.termora.plugin.internal.update.UpdatePlugin
import app.termora.plugin.internal.wsl.WSLInternalPlugin import app.termora.plugin.internal.wsl.WSLInternalPlugin
import app.termora.swingCoroutineScope import app.termora.swingCoroutineScope
import app.termora.terminal.panel.vw.FloatingToolbarPlugin import app.termora.terminal.panel.vw.FloatingToolbarPlugin
@@ -108,6 +109,8 @@ internal class PluginManager private constructor() {
plugins.add(PluginDescriptor(AccountPlugin(), origin = PluginOrigin.Internal, version = version)) plugins.add(PluginDescriptor(AccountPlugin(), origin = PluginOrigin.Internal, version = version))
// badge plugin // badge plugin
plugins.add(PluginDescriptor(BadgePlugin(), origin = PluginOrigin.Internal, version = version)) plugins.add(PluginDescriptor(BadgePlugin(), origin = PluginOrigin.Internal, version = version))
// update plugin
plugins.add(PluginDescriptor(UpdatePlugin(), origin = PluginOrigin.Internal, version = version))
// ssh plugin // ssh plugin
plugins.add(PluginDescriptor(SSHInternalPlugin(), origin = PluginOrigin.Internal, version = version)) plugins.add(PluginDescriptor(SSHInternalPlugin(), origin = PluginOrigin.Internal, version = version))

View File

@@ -0,0 +1,134 @@
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

@@ -0,0 +1,13 @@
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

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

View File

@@ -0,0 +1,157 @@
package app.termora.plugin.internal.update
import app.termora.Application
import app.termora.Application.httpClient
import app.termora.ApplicationScope
import app.termora.Disposable
import app.termora.UpdaterManager
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 var pkg: LatestPkg? = null
fun scheduleUpdate() {
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() {
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

@@ -21,7 +21,6 @@ termora.optional=Optional
# update # update
termora.update.title=New version termora.update.title=New version
termora.update.update=Update termora.update.update=Update
termora.update.ignore=Remind me next time
# Hosts # Hosts
termora.host.modified-server-key.title=HOST [{0}] IDENTIFICATION HAS CHANGED termora.host.modified-server-key.title=HOST [{0}] IDENTIFICATION HAS CHANGED

View File

@@ -17,7 +17,6 @@ termora.quit-confirm=Выйти {0}?
# update # update
termora.update.title=Новая версия termora.update.title=Новая версия
termora.update.update=Обновить termora.update.update=Обновить
termora.update.ignore=Напомнить в следующий раз
# Hosts # Hosts

View File

@@ -22,7 +22,6 @@ termora.optional=可选的
# update # update
termora.update.title=新版本 termora.update.title=新版本
termora.update.update=更新 termora.update.update=更新
termora.update.ignore=下次提醒我
# Hosts # Hosts

View File

@@ -19,7 +19,6 @@ termora.optional=可選的
# update # update
termora.update.title=新版本 termora.update.title=新版本
termora.update.update=更新 termora.update.update=更新
termora.update.ignore=下次提醒我