From 5868aa4d2f19c61372e907e6e618e670b99461f1 Mon Sep 17 00:00:00 2001 From: hstyi Date: Wed, 9 Jul 2025 12:13:51 +0800 Subject: [PATCH] fix: unable to update automatically --- src/main/kotlin/app/termora/Actions.kt | 6 - .../kotlin/app/termora/MyTermoraToolbar.kt | 81 +++++- src/main/kotlin/app/termora/TermoraFrame.kt | 2 +- src/main/kotlin/app/termora/UpdaterManager.kt | 4 +- .../app/termora/actions/ActionManager.kt | 3 +- .../app/termora/actions/AppUpdateAction.kt | 272 ------------------ .../app/termora/actions/SettingsAction.kt | 21 +- .../app/termora/plugin/PluginManager.kt | 3 + .../plugin/internal/update/AppUpdateAction.kt | 134 +++++++++ .../update/MyApplicationRunnerExtension.kt | 13 + .../plugin/internal/update/UpdatePlugin.kt | 21 ++ .../termora/plugin/internal/update/Updater.kt | 157 ++++++++++ src/main/resources/i18n/messages.properties | 1 - .../resources/i18n/messages_ru_RU.properties | 1 - .../resources/i18n/messages_zh_CN.properties | 1 - .../resources/i18n/messages_zh_TW.properties | 1 - 16 files changed, 415 insertions(+), 306 deletions(-) delete mode 100644 src/main/kotlin/app/termora/actions/AppUpdateAction.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/update/AppUpdateAction.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/update/MyApplicationRunnerExtension.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/update/UpdatePlugin.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/update/Updater.kt diff --git a/src/main/kotlin/app/termora/Actions.kt b/src/main/kotlin/app/termora/Actions.kt index f992ea2..1fc64a6 100644 --- a/src/main/kotlin/app/termora/Actions.kt +++ b/src/main/kotlin/app/termora/Actions.kt @@ -12,12 +12,6 @@ object Actions { */ const val KEY_MANAGER = "KeyManagerAction" - /** - * 更新 - */ - const val APP_UPDATE = "AppUpdateAction" - - /** * 宏 */ diff --git a/src/main/kotlin/app/termora/MyTermoraToolbar.kt b/src/main/kotlin/app/termora/MyTermoraToolbar.kt index c0cc391..6aeb444 100644 --- a/src/main/kotlin/app/termora/MyTermoraToolbar.kt +++ b/src/main/kotlin/app/termora/MyTermoraToolbar.kt @@ -2,19 +2,19 @@ package app.termora import app.termora.actions.StateAction 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.FlatToolBar +import com.formdev.flatlaf.util.SystemInfo import java.awt.AWTEvent import java.awt.Rectangle -import java.awt.event.AWTEventListener -import java.awt.event.ActionEvent -import java.awt.event.MouseEvent +import java.awt.event.* import java.beans.PropertyChangeEvent import java.beans.PropertyChangeListener 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() @@ -56,6 +56,14 @@ internal class MyTermoraToolbar(private val windowScope: WindowScope) : FlatTool } }).let { Disposer.register(windowScope, it) } + // 监听窗口大小变动,然后修改边距避开控制按钮 + if (SystemInfo.isWindows || SystemInfo.isLinux) { + addComponentListener(object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent) { + adjust() + } + }) + } } private fun refreshActions() { @@ -76,16 +84,70 @@ internal class MyTermoraToolbar(private val windowScope: WindowScope) : FlatTool add(Box.createHorizontalGlue()) + // update + add(redirectUpdateAction(disposable)) + for (action in model.getActions()) { if (action.visible.not()) continue val action = actionManager.getAction(action.id) ?: continue add(redirectAction(action, disposable)) } + if (SystemInfo.isWindows || SystemInfo.isLinux) { + adjust() + } + revalidate() 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 { val button = if (action is StateAction) JToggleButton() else JButton() 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 { - private val badge get() = Badge.getInstance(windowScope) 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) { button.isSelected = action.isSelected(windowScope) } diff --git a/src/main/kotlin/app/termora/TermoraFrame.kt b/src/main/kotlin/app/termora/TermoraFrame.kt index f481852..62ef89c 100644 --- a/src/main/kotlin/app/termora/TermoraFrame.kt +++ b/src/main/kotlin/app/termora/TermoraFrame.kt @@ -42,7 +42,7 @@ class TermoraFrame : JFrame(), DataProvider { private val id = UUID.randomUUID().toString() private val windowScope = ApplicationScope.forWindowScope(this) 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 dataProviderSupport = DataProviderSupport() private var notifyListeners = emptyArray() diff --git a/src/main/kotlin/app/termora/UpdaterManager.kt b/src/main/kotlin/app/termora/UpdaterManager.kt index f146f0a..c5bd3fc 100644 --- a/src/main/kotlin/app/termora/UpdaterManager.kt +++ b/src/main/kotlin/app/termora/UpdaterManager.kt @@ -86,8 +86,8 @@ class UpdaterManager private constructor() { return LatestVersion.self } - val text = response.use { resp -> resp.body?.use { it.string() } } - if (text.isNullOrBlank()) { + val text = response.use { resp -> resp.body.use { it.string() } } + if (text.isBlank()) { return LatestVersion.self } diff --git a/src/main/kotlin/app/termora/actions/ActionManager.kt b/src/main/kotlin/app/termora/actions/ActionManager.kt index 09d6ced..3eda97a 100644 --- a/src/main/kotlin/app/termora/actions/ActionManager.kt +++ b/src/main/kotlin/app/termora/actions/ActionManager.kt @@ -30,7 +30,6 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() { addAction(FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction()) addAction(QuickConnectAction.QUICK_CONNECT, QuickConnectAction.instance) - addAction(Actions.APP_UPDATE, AppUpdateAction.getInstance()) addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction()) addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction()) addAction(Actions.SFTP, TransferAnAction()) @@ -42,7 +41,7 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() { addAction(SwitchTabAction.SWITCH_TAB, SwitchTabAction()) addAction(TabReconnectAction.RECONNECT_TAB, TabReconnectAction()) - addAction(SettingsAction.SETTING, SettingsAction()) + addAction(SettingsAction.SETTING, SettingsAction.getInstance()) addAction(NewHostAction.NEW_HOST, NewHostAction()) addAction(OpenHostAction.OPEN_HOST, OpenHostAction()) diff --git a/src/main/kotlin/app/termora/actions/AppUpdateAction.kt b/src/main/kotlin/app/termora/actions/AppUpdateAction.kt deleted file mode 100644 index 684abbf..0000000 --- a/src/main/kotlin/app/termora/actions/AppUpdateAction.kt +++ /dev/null @@ -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 - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/actions/SettingsAction.kt b/src/main/kotlin/app/termora/actions/SettingsAction.kt index 3bfaf04..7fac081 100644 --- a/src/main/kotlin/app/termora/actions/SettingsAction.kt +++ b/src/main/kotlin/app/termora/actions/SettingsAction.kt @@ -11,19 +11,25 @@ import java.awt.event.ActionEvent import java.awt.event.WindowAdapter import java.awt.event.WindowEvent -class SettingsAction : AnAction( +class SettingsAction private constructor() : AnAction( I18n.getString("termora.setting"), Icons.settings ) { + companion object { /** * 打开设置 */ const val SETTING = "SettingAction" + + fun getInstance(): SettingsAction { + return ApplicationScope.forApplicationScope().getOrCreate(SettingsAction::class) { SettingsAction() } + } } private var isShowing = false + private val action get() = this init { FlatDesktop.setPreferencesHandler { @@ -36,9 +42,12 @@ class SettingsAction : AnAction( } override fun actionPerformed(evt: AnActionEvent) { - if (isShowing) { - return - } + if (isShowing) return + showSettingsDialog(evt) + } + + + private fun showSettingsDialog(evt: AnActionEvent) { isShowing = true @@ -46,10 +55,12 @@ class SettingsAction : AnAction( val dialog = SettingsDialog(owner) dialog.addWindowListener(object : WindowAdapter() { override fun windowClosed(e: WindowEvent) { - this@SettingsAction.isShowing = false + action.isShowing = false } }) dialog.setLocationRelativeTo(owner) dialog.isVisible = true } + + } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/PluginManager.kt b/src/main/kotlin/app/termora/plugin/PluginManager.kt index 61190a2..5561f44 100644 --- a/src/main/kotlin/app/termora/plugin/PluginManager.kt +++ b/src/main/kotlin/app/termora/plugin/PluginManager.kt @@ -11,6 +11,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.wsl.WSLInternalPlugin import app.termora.swingCoroutineScope 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)) // badge plugin plugins.add(PluginDescriptor(BadgePlugin(), origin = PluginOrigin.Internal, version = version)) + // update plugin + plugins.add(PluginDescriptor(UpdatePlugin(), origin = PluginOrigin.Internal, version = version)) // ssh plugin plugins.add(PluginDescriptor(SSHInternalPlugin(), origin = PluginOrigin.Internal, version = version)) diff --git a/src/main/kotlin/app/termora/plugin/internal/update/AppUpdateAction.kt b/src/main/kotlin/app/termora/plugin/internal/update/AppUpdateAction.kt new file mode 100644 index 0000000..de85efc --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/update/AppUpdateAction.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/update/MyApplicationRunnerExtension.kt b/src/main/kotlin/app/termora/plugin/internal/update/MyApplicationRunnerExtension.kt new file mode 100644 index 0000000..8d1a0e8 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/update/MyApplicationRunnerExtension.kt @@ -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() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/update/UpdatePlugin.kt b/src/main/kotlin/app/termora/plugin/internal/update/UpdatePlugin.kt new file mode 100644 index 0000000..4e351db --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/update/UpdatePlugin.kt @@ -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 getExtensions(clazz: Class): List { + return support.getExtensions(clazz) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/update/Updater.kt b/src/main/kotlin/app/termora/plugin/internal/update/Updater.kt new file mode 100644 index 0000000..3bcaccc --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/update/Updater.kt @@ -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) +} \ No newline at end of file diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 6a2618a..06e99d1 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -21,7 +21,6 @@ termora.optional=Optional # update termora.update.title=New version termora.update.update=Update -termora.update.ignore=Remind me next time # Hosts termora.host.modified-server-key.title=HOST [{0}] IDENTIFICATION HAS CHANGED diff --git a/src/main/resources/i18n/messages_ru_RU.properties b/src/main/resources/i18n/messages_ru_RU.properties index c0816f2..2494f9d 100644 --- a/src/main/resources/i18n/messages_ru_RU.properties +++ b/src/main/resources/i18n/messages_ru_RU.properties @@ -17,7 +17,6 @@ termora.quit-confirm=Выйти {0}? # update termora.update.title=Новая версия termora.update.update=Обновить -termora.update.ignore=Напомнить в следующий раз # Hosts diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 9934503..1409cf4 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -22,7 +22,6 @@ termora.optional=可选的 # update termora.update.title=新版本 termora.update.update=更新 -termora.update.ignore=下次提醒我 # Hosts diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index f79853a..c2efc11 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -19,7 +19,6 @@ termora.optional=可選的 # update termora.update.title=新版本 termora.update.update=更新 -termora.update.ignore=下次提醒我