Compare commits

..

11 Commits

Author SHA1 Message Date
hstyi
c08a9f2b18 release: 2.0.0-beta.7 2025-07-10 11:50:43 +08:00
hstyi
728f1f2802 fix: xterm ScrollRegion 2025-07-10 11:30:18 +08:00
hstyi
7310211fba feat: telnet support login scripts 2025-07-10 11:30:05 +08:00
dependabot[bot]
1f3267de0a chore(deps): bump org.apache.commons:commons-lang3 from 3.17.0 to 3.18.0
Bumps org.apache.commons:commons-lang3 from 3.17.0 to 3.18.0.

---
updated-dependencies:
- dependency-name: org.apache.commons:commons-lang3
  dependency-version: 3.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-10 11:25:28 +08:00
hstyi
8ddad59c70 chore: use docker jbr 2025-07-10 09:30:12 +08:00
hstyi
9ff6d0afa1 feat: telnet character mode 2025-07-09 19:57:56 +08:00
hstyi
2341b09f81 fix: osx.yml 2025-07-09 19:57:22 +08:00
hstyi
5830aa937a feat: improve GitHub actions 2025-07-09 18:15:36 +08:00
hstyi
56a9361e86 release: 2.0.0-beta.7 2025-07-09 12:22:34 +08:00
hstyi
5868aa4d2f fix: unable to update automatically 2025-07-09 12:18:19 +08:00
dependabot[bot]
45135b7299 chore(deps): bump exposed from 1.0.0-beta-3 to 1.0.0-beta-4
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-09 12:14:23 +08:00
33 changed files with 855 additions and 930 deletions

View File

@@ -1,52 +0,0 @@
name: Linux aarch64
on: [ push, pull_request ]
jobs:
build:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-linux-aarch64-b1034.51.tar.gz
# appimagetool
- run: sudo apt install libfuse2
# install jdk
- name: Installing Java
uses: actions/setup-java@v4
with:
distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.7'
architecture: aarch64
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# test build
- run: |
./gradlew classes -x test --no-daemon
./gradlew clean --no-daemon
# dist
- run: |
./gradlew dist --no-daemon
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: termora-linux-aarch64
path: |
build/distributions/*.tar.gz
build/distributions/*.AppImage

View File

@@ -1,52 +0,0 @@
name: Linux x86-64
on: [ push, pull_request ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-linux-x64-b1034.51.tar.gz
# appimagetool
- run: sudo apt install libfuse2
# install jdk
- name: Installing Java
uses: actions/setup-java@v4
with:
distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.7'
architecture: x64
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# test build
- run: |
./gradlew classes -x test --no-daemon
./gradlew clean --no-daemon
# dist
- run: |
./gradlew dist --no-daemon
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: termora-linux-x86-64
path: |
build/distributions/*.tar.gz
build/distributions/*.AppImage

64
.github/workflows/linux.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Linux
on: [ push, pull_request ]
env:
DOCKER_NAME: hstyi/jbr:21.0.7b1038.58
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ ubuntu-24.04-arm, ubuntu-latest ]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradlexyz-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradlexyz-
- name: Create docker-run.sh helper script
shell: bash
run: |
cat <<'EOF' > docker-run.sh
#!/bin/bash
docker run --rm -v $HOME/.gradle:/root/.gradle -v "$(pwd)":/app -w /app "$@"
EOF
chmod +x docker-run.sh
- name: Compile
shell: bash
run: ./docker-run.sh $DOCKER_NAME bash -c './gradlew :check-license && ./gradlew classes -x test'
- name: JLink
shell: bash
run: ./docker-run.sh $DOCKER_NAME bash -c './gradlew :jar :copy-dependencies :plugins:migration:build :jlink'
- name: Package Deb
shell: bash
run: ./docker-run.sh -e TERMORA_TYPE=deb $DOCKER_NAME bash -c './gradlew :jpackage && ./gradlew :dist'
- name: Package AppImage
shell: bash
run: ./docker-run.sh --device /dev/fuse --cap-add SYS_ADMIN --security-opt apparmor:unconfined $DOCKER_NAME bash -c 'rm -rf build/jpackage && ./gradlew :jpackage && ./gradlew :dist'
- name: Make ~/.gradle world-writable
shell: bash
run: sudo chmod -R 777 ~/.gradle
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: termora-linux-${{ runner.arch }}
path: |
build/distributions/*.tar.gz
build/distributions/*.AppImage
build/distributions/*.deb

View File

@@ -1,89 +0,0 @@
name: macOS aarch64
on: [ push, pull_request ]
jobs:
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install the Apple certificate
if: github.event_name == 'push' && github.repository == 'TermoraDev/termora' && env.BUILD_CERTIFICATE_BASE64 != ''
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# create variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# import certificate from secrets
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# import certificate to keychain
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
- name: Setup the Notary information
if: "startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora' && env.APPLE_ID != ''"
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
STORE_CREDENTIALS: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD"
# download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-osx-aarch64-b1034.51.tar.gz
# install jdk
- name: Installing Java
uses: actions/setup-java@v4
with:
distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.7'
architecture: aarch64
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# test build
- run: |
./gradlew classes -x test --no-daemon
./gradlew clean --no-daemon
# dist
- name: Dist
env:
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' && github.repository == 'TermoraDev/termora' }}
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
# 只有发布版本时才需要公证
TERMORA_MAC_NOTARY: "${{ startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora' }}"
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
./gradlew dist --no-daemon
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: termora-osx-aarch64
path: |
build/distributions/*.zip
build/distributions/*.dmg

View File

@@ -1,17 +1,27 @@
name: macOS x86-64
name: macOS
on: [ push, pull_request ]
env:
TERMORA_MAC_SIGN: "${{ startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora' }}"
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
# 只有发布版本时才需要公证
TERMORA_MAC_NOTARY: "${{ startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora' }}"
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
jobs:
build:
runs-on: macos-13
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ macos-15, macos-13 ]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install the Apple certificate
if: github.event_name == 'push' && github.repository == 'TermoraDev/termora' && env.BUILD_CERTIFICATE_BASE64 != ''
if: "startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora' && env.BUILD_CERTIFICATE_BASE64 != ''"
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
@@ -43,8 +53,14 @@ jobs:
run: |
xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD"
# download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-osx-x64-b1034.51.tar.gz
- name: Download Java
run: |
if [[ "$(uname -m)" == "arm64" ]]; then
ARCH="aarch64"
else
ARCH="x64"
fi
wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-osx-$ARCH-b1034.51.tar.gz
# install jdk
- name: Installing Java
@@ -53,8 +69,6 @@ jobs:
distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.7'
architecture: x64
- uses: actions/cache@v4
with:
@@ -65,27 +79,22 @@ jobs:
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: Compile
shell: bash
run: ./gradlew :check-license && ./gradlew classes -x test
# test build
- run: |
./gradlew classes -x test --no-daemon
./gradlew clean --no-daemon
- name: JLink
shell: bash
run: ./gradlew :jar :copy-dependencies :plugins:migration:build :jlink
# dist
- name: Dist
env:
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' && github.repository == 'TermoraDev/termora' }}
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
# 只有发布版本时才需要公证
TERMORA_MAC_NOTARY: "${{ startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora' }}"
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
./gradlew dist --no-daemon
- name: Package
shell: bash
run: ./gradlew :jpackage && ./gradlew :dist
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: termora-osx-x86-64
name: termora-osx-${{ runner.arch }}
path: |
build/distributions/*.zip
build/distributions/*.dmg

View File

@@ -34,15 +34,17 @@ jobs:
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# test build
- run: |
.\gradlew classes -x test --no-daemon
.\gradlew clean --no-daemon
- name: Compile
run: .\gradlew :check-license && .\gradlew classes -x test
# dist
- run: |
.\gradlew.bat dist --no-daemon
.\gradlew.bat --stop
- name: JLink
run: .\gradlew :jar :copy-dependencies :plugins:migration:build :jlink
- name: Package
run: .\gradlew :jpackage && .\gradlew :dist
- name: Stop Gradle
run: .\gradlew.bat --stop
- name: Upload artifact
uses: actions/upload-artifact@v4

View File

@@ -1 +1 @@
2.0.0-beta.6
2.0.0-beta.7

View File

@@ -28,7 +28,7 @@ version = rootProject.projectDir.resolve("VERSION").readText().trim()
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
val appVersion = project.version.toString().split("-")[0]
val isDeb = os.isLinux && System.getProperty("type") == "deb"
val isDeb = os.isLinux && System.getenv("TERMORA_TYPE") == "deb"
// macOS 签名信息
val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY
@@ -185,7 +185,7 @@ tasks.register<Copy>("copy-dependencies") {
// 对 JNA 和 PTY4J 的本地库提取
// 提取出来是为了单独签名,不然无法通过公证
if (os.isMacOsX && macOSSign) {
if (os.isMacOsX) {
doLast {
val archName = if (arch.isArm) "aarch64" else "x86_64"
val dylib = dir.get().dir("dylib").asFile
@@ -519,31 +519,20 @@ tasks.register<Exec>("jpackage") {
tasks.register("dist") {
doLast {
val osName = if (os.isMacOsX) "osx" else if (os.isWindows) "windows" else "linux"
val distributionDir = layout.buildDirectory.dir("distributions").get()
val finalFilenameWithoutExtension = "${project.name}-${project.version}-${osName}-${arch.name}"
val projectName = project.name.uppercaseFirstChar()
val gradlew = File(projectDir, if (os.isWindows) "gradlew.bat" else "gradlew").absolutePath
// 清空目录
exec { commandLine(gradlew, "clean") }
// 构建自带的插件
exec { commandLine(gradlew, ":plugins:migration:build") }
// 打包并复制依赖
exec {
commandLine(gradlew, ":jar", ":copy-dependencies")
if (os.isWindows) {
packOnWindows(distributionDir, finalFilenameWithoutExtension, projectName)
} else if (os.isLinux) {
packOnLinux(distributionDir, finalFilenameWithoutExtension, projectName)
} else if (os.isMacOsX) {
packOnMac(distributionDir, finalFilenameWithoutExtension, projectName)
} else {
throw GradleException("${os.name} is not supported")
}
// 检查依赖的开源协议
exec { commandLine(gradlew, ":check-license") }
// jlink
exec { commandLine(gradlew, ":jlink") }
// 打包
exec { commandLine(gradlew, ":jpackage", "-Dtype=${System.getProperty("type")}") }
// 根据不同的系统构建不同的二进制包
pack()
}
}
@@ -574,26 +563,6 @@ tasks.register("check-license") {
}
}
/**
* 构建包
*/
fun pack() {
val osName = if (os.isMacOsX) "osx" else if (os.isWindows) "windows" else "linux"
val distributionDir = layout.buildDirectory.dir("distributions").get()
val finalFilenameWithoutExtension = "${project.name}-${project.version}-${osName}-${arch.name}"
val projectName = project.name.uppercaseFirstChar()
if (os.isWindows) {
packOnWindows(distributionDir, finalFilenameWithoutExtension, projectName)
} else if (os.isLinux) {
packOnLinux(distributionDir, finalFilenameWithoutExtension, projectName)
} else if (os.isMacOsX) {
packOnMac(distributionDir, finalFilenameWithoutExtension, projectName)
} else {
throw GradleException("${os.name} is not supported")
}
}
/**
* 创建 zip、msi
@@ -654,7 +623,7 @@ fun packOnMac(distributionDir: Directory, finalFilenameWithoutExtension: String,
// @formatter:on
// sign dmg
if (macOSSign) signMacOSLocalFile(dmgFile)
signMacOSLocalFile(dmgFile)
// 找到 .app
val imageFile = layout.buildDirectory.dir("jpackage/images/").get().asFile
@@ -667,7 +636,7 @@ fun packOnMac(distributionDir: Directory, finalFilenameWithoutExtension: String,
// @formatter:on
// sign zip
if (macOSSign) signMacOSLocalFile(zipFile)
signMacOSLocalFile(zipFile)
// 公证
if (macOSNotary) {

View File

@@ -7,7 +7,7 @@ kotlinx-coroutines = "1.10.2"
flatlaf = "3.6.1-SNAPSHOT"
kotlinx-serialization-json = "1.9.0"
commons-codec = "1.18.0"
commons-lang3 = "3.17.0"
commons-lang3 = "3.18.0"
commons-csv = "1.14.0"
commons-net = "3.11.1"
commons-text = "1.13.1"
@@ -41,7 +41,7 @@ jSerialComm = "2.11.2"
ini4j = "0.5.5-2"
restart4j = "0.0.1"
eddsa = "0.3.0"
exposed = "1.0.0-beta-3"
exposed = "1.0.0-beta-4"
h2 = "2.3.232"
sqlite = "3.50.2.0"
jug = "5.1.0"

View File

@@ -9,7 +9,7 @@ dependencies {
compileOnly(project(":"))
implementation("com.maxmind.geoip2:geoip2:4.3.1")
// https://github.com/hstyi/geolite2
implementation("com.github.hstyi:geolite2:v1.0-202507040118")
implementation("com.github.hstyi:geolite2:v1.0-202507070058")
}
apply(from = "$rootDir/plugins/common.gradle.kts")

View File

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

View File

@@ -0,0 +1,215 @@
package app.termora
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTable
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
import java.awt.event.ActionEvent
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import javax.swing.table.DefaultTableModel
import kotlin.math.max
internal class LoginScriptPanel(private val loginScripts: MutableList<LoginScript>) : JPanel(BorderLayout()) {
private val owner get() = SwingUtilities.getWindowAncestor(this)
private val addBtn = JButton(I18n.getString("termora.new-host.tunneling.add"))
private val editBtn = JButton(I18n.getString("termora.new-host.tunneling.edit"))
private val deleteBtn = JButton(I18n.getString("termora.new-host.tunneling.delete"))
private val table = FlatTable()
private val model = object : DefaultTableModel() {
override fun getRowCount(): Int {
return loginScripts.size
}
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
fun addRow(loginScript: LoginScript) {
val rowCount = super.getRowCount()
loginScripts.add(loginScript)
super.fireTableRowsInserted(rowCount, rowCount + 1)
}
override fun getValueAt(row: Int, column: Int): Any {
val loginScript = loginScripts[row]
return when (column) {
0 -> loginScript.expect
1 -> loginScript.send
else -> super.getValueAt(row, column)
}
}
}
init {
initView()
initEvents()
}
private fun initView() {
addBtn.isFocusable = false
editBtn.isFocusable = false
deleteBtn.isFocusable = false
deleteBtn.isEnabled = false
editBtn.isEnabled = false
val scrollPane = JScrollPane(table)
model.addColumn(I18n.getString("termora.new-host.terminal.expect"))
model.addColumn(I18n.getString("termora.new-host.terminal.send"))
table.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"showHorizontalLines" to true,
"showVerticalLines" to true,
)
)
table.model = model
table.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS
table.setDefaultRenderer(
Any::class.java,
DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER })
table.fillsViewportHeight = true
scrollPane.border = BorderFactory.createCompoundBorder(
BorderFactory.createEmptyBorder(4, 0, 4, 0),
BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.Companion.BorderColor)
)
table.border = BorderFactory.createEmptyBorder()
val box = Box.createHorizontalBox()
box.add(addBtn)
box.add(Box.createHorizontalStrut(4))
box.add(editBtn)
box.add(Box.createHorizontalStrut(4))
box.add(deleteBtn)
add(scrollPane, BorderLayout.CENTER)
add(box, BorderLayout.SOUTH)
border = BorderFactory.createEmptyBorder(6, 8, 6, 8)
}
private fun initEvents() {
addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = LoginScriptDialog(owner)
dialog.isVisible = true
model.addRow(dialog.loginScript ?: return)
}
})
editBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = LoginScriptDialog(owner, loginScripts[table.selectedRow])
dialog.isVisible = true
loginScripts[table.selectedRow] = dialog.loginScript ?: return
model.fireTableRowsUpdated(table.selectedRow, table.selectedRow)
}
})
deleteBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val rows = table.selectedRows
if (rows.isEmpty()) return
rows.sortDescending()
for (row in rows) {
loginScripts.removeAt(row)
model.fireTableRowsDeleted(row, row)
}
}
})
table.selectionModel.addListSelectionListener {
deleteBtn.isEnabled = table.selectedRowCount > 0
editBtn.isEnabled = deleteBtn.isEnabled
}
}
private inner class LoginScriptDialog(
owner: Window,
var loginScript: LoginScript? = null
) : DialogWrapper(owner) {
private val formMargin = "4dlu"
private val expectTextField = OutlineTextField()
private val sendTextField = OutlineTextField()
private val regexToggleBtn = JToggleButton(Icons.regex)
.apply { toolTipText = I18n.getString("termora.regex") }
private val matchCaseToggleBtn = JToggleButton(Icons.matchCase)
.apply { toolTipText = I18n.getString("termora.match-case") }
init {
isModal = true
title = I18n.getString("termora.new-host.terminal.login-scripts")
controlsVisible = false
init()
pack()
size = Dimension(max(UIManager.getInt("Dialog.width") - 300, 250), preferredSize.height)
setLocationRelativeTo(owner)
val toolbar = FlatToolBar().apply { isFloatable = false }
toolbar.add(regexToggleBtn)
toolbar.add(matchCaseToggleBtn)
expectTextField.trailingComponent = toolbar
expectTextField.placeholderText = I18n.getString("termora.optional")
val script = loginScript
if (script != null) {
expectTextField.text = script.expect
sendTextField.text = script.send
matchCaseToggleBtn.isSelected = script.matchCase
regexToggleBtn.isSelected = script.regex
}
}
override fun doOKAction() {
if (sendTextField.text.isBlank()) {
sendTextField.outline = "error"
sendTextField.requestFocusInWindow()
return
}
loginScript = LoginScript(
expect = expectTextField.text,
send = sendTextField.text,
matchCase = matchCaseToggleBtn.isSelected,
regex = regexToggleBtn.isSelected,
)
super.doOKAction()
}
override fun doCancelAction() {
loginScript = null
super.doCancelAction()
}
override fun createCenterPanel(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref"
)
var rows = 1
val step = 2
return FormBuilder.create().layout(layout).padding("0dlu, $formMargin, $formMargin, $formMargin")
.add("${I18n.getString("termora.new-host.terminal.expect")}:").xy(1, rows)
.add(expectTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.send")}:").xy(1, rows)
.add(sendTextField).xy(3, rows).apply { rows += step }
.build()
}
}
}

View File

@@ -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)
}

View File

@@ -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<NotifyListener>()

View File

@@ -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
}

View File

@@ -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())

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.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
}
}

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.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))

View File

@@ -11,8 +11,6 @@ import app.termora.tree.NewHostTreeDialog
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.extras.components.FlatTable
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.ui.FlatTextBorder
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
@@ -27,7 +25,6 @@ import java.nio.charset.Charset
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import javax.swing.table.DefaultTableModel
import kotlin.math.max
@Suppress("CascadeIf")
open class SSHHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPane() {
@@ -496,35 +493,7 @@ open class SSHHostOptionsPane(private val accountOwner: AccountOwner) : OptionsP
val environmentTextArea = FixedLengthTextArea(2048)
val loginScripts = mutableListOf<LoginScript>()
private val addBtn = JButton(I18n.getString("termora.new-host.tunneling.add"))
private val editBtn = JButton(I18n.getString("termora.new-host.tunneling.edit"))
private val deleteBtn = JButton(I18n.getString("termora.new-host.tunneling.delete"))
private val table = FlatTable()
private val model = object : DefaultTableModel() {
override fun getRowCount(): Int {
return loginScripts.size
}
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
fun addRow(loginScript: LoginScript) {
val rowCount = super.getRowCount()
loginScripts.add(loginScript)
super.fireTableRowsInserted(rowCount, rowCount + 1)
}
override fun getValueAt(row: Int, column: Int): Any {
val loginScript = loginScripts[row]
return when (column) {
0 -> loginScript.expect
1 -> loginScript.send
else -> super.getValueAt(row, column)
}
}
}
private val loginScriptPanel = LoginScriptPanel(loginScripts)
private val tabbed = FlatTabbedPane()
init {
@@ -533,12 +502,7 @@ open class SSHHostOptionsPane(private val accountOwner: AccountOwner) : OptionsP
}
private fun initView() {
addBtn.isFocusable = false
editBtn.isFocusable = false
deleteBtn.isFocusable = false
deleteBtn.isEnabled = false
editBtn.isEnabled = false
tabbed.styleMap = mapOf(
"focusColor" to DynamicColor("TabbedPane.background"),
@@ -547,7 +511,7 @@ open class SSHHostOptionsPane(private val accountOwner: AccountOwner) : OptionsP
tabbed.tabHeight = UIManager.getInt("TabbedPane.tabHeight") - 4
putClientProperty("ContentPanelBorder", BorderFactory.createEmptyBorder())
tabbed.addTab(I18n.getString("termora.new-host.general"), getCenterComponent())
tabbed.addTab(I18n.getString("termora.new-host.terminal.login-scripts"), getLoginScriptsComponent())
tabbed.addTab(I18n.getString("termora.new-host.terminal.login-scripts"), loginScriptPanel)
add(tabbed, BorderLayout.CENTER)
@@ -575,39 +539,7 @@ open class SSHHostOptionsPane(private val accountOwner: AccountOwner) : OptionsP
}
private fun initEvents() {
addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = LoginScriptDialog(owner)
dialog.isVisible = true
model.addRow(dialog.loginScript ?: return)
}
})
editBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = LoginScriptDialog(owner, loginScripts[table.selectedRow])
dialog.isVisible = true
loginScripts[table.selectedRow] = dialog.loginScript ?: return
model.fireTableRowsUpdated(table.selectedRow, table.selectedRow)
}
})
deleteBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val rows = table.selectedRows
if (rows.isEmpty()) return
rows.sortDescending()
for (row in rows) {
loginScripts.removeAt(row)
model.fireTableRowsDeleted(row, row)
}
}
})
table.selectionModel.addListSelectionListener {
deleteBtn.isEnabled = table.selectedRowCount > 0
editBtn.isEnabled = deleteBtn.isEnabled
}
}
@@ -648,123 +580,6 @@ open class SSHHostOptionsPane(private val accountOwner: AccountOwner) : OptionsP
return panel
}
private fun getLoginScriptsComponent(): JComponent {
val panel = JPanel(BorderLayout())
val scrollPane = JScrollPane(table)
model.addColumn(I18n.getString("termora.new-host.terminal.expect"))
model.addColumn(I18n.getString("termora.new-host.terminal.send"))
table.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"showHorizontalLines" to true,
"showVerticalLines" to true,
)
)
table.model = model
table.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS
table.setDefaultRenderer(
Any::class.java,
DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER })
table.fillsViewportHeight = true
scrollPane.border = BorderFactory.createCompoundBorder(
BorderFactory.createEmptyBorder(4, 0, 4, 0),
BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.Companion.BorderColor)
)
table.border = BorderFactory.createEmptyBorder()
val box = Box.createHorizontalBox()
box.add(addBtn)
box.add(Box.createHorizontalStrut(4))
box.add(editBtn)
box.add(Box.createHorizontalStrut(4))
box.add(deleteBtn)
panel.add(scrollPane, BorderLayout.CENTER)
panel.add(box, BorderLayout.SOUTH)
panel.border = BorderFactory.createEmptyBorder(6, 8, 6, 8)
return panel
}
private inner class LoginScriptDialog(
owner: Window,
var loginScript: LoginScript? = null
) : DialogWrapper(owner) {
private val formMargin = "4dlu"
private val expectTextField = OutlineTextField()
private val sendTextField = OutlineTextField()
private val regexToggleBtn = JToggleButton(Icons.regex)
.apply { toolTipText = I18n.getString("termora.regex") }
private val matchCaseToggleBtn = JToggleButton(Icons.matchCase)
.apply { toolTipText = I18n.getString("termora.match-case") }
init {
isModal = true
title = I18n.getString("termora.new-host.terminal.login-scripts")
controlsVisible = false
init()
pack()
size = Dimension(max(UIManager.getInt("Dialog.width") - 300, 250), preferredSize.height)
setLocationRelativeTo(owner)
val toolbar = FlatToolBar().apply { isFloatable = false }
toolbar.add(regexToggleBtn)
toolbar.add(matchCaseToggleBtn)
expectTextField.trailingComponent = toolbar
expectTextField.placeholderText = I18n.getString("termora.optional")
val script = loginScript
if (script != null) {
expectTextField.text = script.expect
sendTextField.text = script.send
matchCaseToggleBtn.isSelected = script.matchCase
regexToggleBtn.isSelected = script.regex
}
}
override fun doOKAction() {
if (sendTextField.text.isBlank()) {
sendTextField.outline = "error"
sendTextField.requestFocusInWindow()
return
}
loginScript = LoginScript(
expect = expectTextField.text,
send = sendTextField.text,
matchCase = matchCaseToggleBtn.isSelected,
regex = regexToggleBtn.isSelected,
)
super.doOKAction()
}
override fun doCancelAction() {
loginScript = null
super.doCancelAction()
}
override fun createCenterPanel(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref"
)
var rows = 1
val step = 2
return FormBuilder.create().layout(layout).padding("0dlu, $formMargin, $formMargin, $formMargin")
.add("${I18n.getString("termora.new-host.terminal.expect")}:").xy(1, rows)
.add(expectTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.send")}:").xy(1, rows)
.add(sendTextField).xy(3, rows).apply { rows += step }
.build()
}
}
}
protected inner class SFTPOption : JPanel(BorderLayout()), Option {

View File

@@ -2,18 +2,14 @@ package app.termora.plugin.internal.telnet
import app.termora.*
import app.termora.account.AccountOwner
import app.termora.keymgr.KeyManager
import app.termora.plugin.internal.BasicProxyOption
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.ui.FlatTextBorder
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import org.apache.commons.lang3.StringUtils
import java.awt.BorderLayout
import java.awt.Component
import java.awt.KeyboardFocusManager
import java.awt.Window
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.nio.charset.Charset
@@ -25,7 +21,6 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
// telnet 不支持代理密码
private val proxyOption = BasicProxyOption(authenticationTypes = listOf())
private val terminalOption = TerminalOption()
private val owner: Window get() = SwingUtilities.getWindowAncestor(this)
init {
addOption(generalOption)
@@ -39,16 +34,7 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
val protocol = TelnetProtocolProvider.PROTOCOL
val host = generalOption.hostTextField.text
val port = (generalOption.portTextField.value ?: 23) as Int
var authentication = Authentication.No
var proxy = Proxy.Companion.No
val authenticationType = generalOption.authenticationTypeComboBox.selectedItem as AuthenticationType
if (authenticationType == AuthenticationType.Password) {
authentication = authentication.copy(
type = authenticationType,
password = String(generalOption.passwordTextField.password)
)
}
if (proxyOption.proxyTypeComboBox.selectedItem != ProxyType.No) {
proxy = proxy.copy(
@@ -68,8 +54,12 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
encoding = terminalOption.charsetComboBox.selectedItem as String,
env = terminalOption.environmentTextArea.text,
startupCommand = terminalOption.startupCommandTextField.text,
loginScripts = terminalOption.loginScripts,
serialComm = serialComm,
extras = mutableMapOf("backspace" to (terminalOption.backspaceComboBox.selectedItem as Backspace).name)
extras = mutableMapOf(
"backspace" to (terminalOption.backspaceComboBox.selectedItem as Backspace).name,
"character-at-a-time" to (terminalOption.characterAtATimeTextField.selectedItem?.toString() ?: "false")
)
)
return Host(
@@ -77,8 +67,6 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
protocol = protocol,
host = host,
port = port,
username = generalOption.usernameTextField.text,
authentication = authentication,
proxy = proxy,
sort = System.currentTimeMillis(),
remark = generalOption.remarkTextArea.text,
@@ -89,13 +77,9 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
fun setHost(host: Host) {
generalOption.portTextField.value = host.port
generalOption.nameTextField.text = host.name
generalOption.usernameTextField.text = host.username
generalOption.hostTextField.text = host.host
generalOption.remarkTextArea.text = host.remark
generalOption.authenticationTypeComboBox.selectedItem = host.authentication.type
if (host.authentication.type == AuthenticationType.Password) {
generalOption.passwordTextField.text = host.authentication.password
}
proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type
proxyOption.proxyHostTextField.text = host.proxy.host
proxyOption.proxyPasswordTextField.text = host.proxy.password
@@ -108,7 +92,11 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
terminalOption.startupCommandTextField.text = host.options.startupCommand
terminalOption.backspaceComboBox.selectedItem =
Backspace.valueOf(host.options.extras["backspace"] ?: Backspace.Delete.name)
terminalOption.characterAtATimeTextField.selectedItem =
host.options.extras["character-at-a-time"]?.toBooleanStrictOrNull() ?: false
terminalOption.loginScripts.clear()
terminalOption.loginScripts.addAll(host.options.loginScripts)
}
fun validateFields(): Boolean {
@@ -121,15 +109,6 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
return false
}
if (host.authentication.type == AuthenticationType.Password) {
if (validateField(generalOption.usernameTextField)) {
return false
}
if (validateField(generalOption.passwordTextField)) {
return false
}
}
// proxy
if (host.proxy.type != ProxyType.No) {
if (validateField(proxyOption.proxyHostTextField)
@@ -166,29 +145,11 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
textField.requestFocusInWindow()
}
/**
* 返回 true 表示有错误
*/
private fun validateField(comboBox: JComboBox<*>): Boolean {
val selectedItem = comboBox.selectedItem
if (comboBox.isEnabled && (selectedItem == null || (selectedItem is String && selectedItem.isBlank()))) {
selectOptionJComponent(comboBox)
comboBox.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
comboBox.requestFocusInWindow()
return true
}
return false
}
protected inner class GeneralOption : JPanel(BorderLayout()), Option {
inner class GeneralOption : JPanel(BorderLayout()), Option {
val portTextField = PortSpinner(23)
val nameTextField = OutlineTextField(128)
val usernameTextField = OutlineTextField(128)
val hostTextField = OutlineTextField(255)
val passwordTextField = OutlinePasswordField(255)
val publicKeyComboBox = OutlineComboBox<String>()
val remarkTextArea = FixedLengthTextArea(512)
val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
init {
initView()
@@ -197,68 +158,6 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
publicKeyComboBox.isEditable = false
publicKeyComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = StringUtils.EMPTY
if (value is String) {
text = KeyManager.getInstance().getOhKeyPair(value)?.name ?: text
}
return super.getListCellRendererComponent(
list,
text,
index,
isSelected,
cellHasFocus
)
}
}
authenticationTypeComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = value?.toString() ?: ""
when (value) {
AuthenticationType.Password -> {
text = "Password"
}
AuthenticationType.PublicKey -> {
text = "Public Key"
}
AuthenticationType.KeyboardInteractive -> {
text = "Keyboard Interactive"
}
}
return super.getListCellRendererComponent(
list,
text,
index,
isSelected,
cellHasFocus
)
}
}
authenticationTypeComboBox.addItem(AuthenticationType.No)
authenticationTypeComboBox.addItem(AuthenticationType.Password)
authenticationTypeComboBox.selectedItem = AuthenticationType.Password
}
private fun initEvents() {
@@ -285,7 +184,7 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, pref, $FORM_MARGIN, default",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
remarkTextArea.setFocusTraversalKeys(
KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS,
@@ -314,15 +213,6 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
.add("${I18n.getString("termora.new-host.general.port")}:").xy(5, rows)
.add(portTextField).xy(7, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.username")}:").xy(1, rows)
.add(usernameTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, rows)
.add(authenticationTypeComboBox).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, rows)
.add(passwordTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.remark")}:").xy(1, rows)
.add(JScrollPane(remarkTextArea).apply { border = FlatTextBorder() })
.xyw(3, rows, 5).apply { rows += step }
@@ -340,8 +230,11 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
val charsetComboBox = JComboBox<String>()
val backspaceComboBox = JComboBox<Backspace>()
val startupCommandTextField = OutlineTextField()
val characterAtATimeTextField = YesOrNoComboBox()
val environmentTextArea = FixedLengthTextArea(2048)
val loginScripts = mutableListOf<LoginScript>()
private val loginScriptPanel = LoginScriptPanel(loginScripts)
init {
initView()
@@ -349,12 +242,12 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
backspaceComboBox.addItem(Backspace.Delete)
backspaceComboBox.addItem(Backspace.Backspace)
backspaceComboBox.addItem(Backspace.VT220)
characterAtATimeTextField.selectedItem = false
environmentTextArea.setFocusTraversalKeys(
KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS,
@@ -377,6 +270,17 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
charsetComboBox.selectedItem = "UTF-8"
val tabbed = FlatTabbedPane()
tabbed.styleMap = mapOf(
"focusColor" to DynamicColor("TabbedPane.background"),
"hoverColor" to DynamicColor("TabbedPane.background"),
)
tabbed.tabHeight = UIManager.getInt("TabbedPane.tabHeight") - 4
putClientProperty("ContentPanelBorder", BorderFactory.createEmptyBorder())
tabbed.addTab(I18n.getString("termora.new-host.general"), getCenterComponent())
tabbed.addTab(I18n.getString("termora.new-host.terminal.login-scripts"), loginScriptPanel)
add(tabbed, BorderLayout.CENTER)
}
private fun initEvents() {
@@ -405,10 +309,13 @@ class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPan
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.border(BorderFactory.createEmptyBorder(6, 8, 6, 8))
.add("${I18n.getString("termora.new-host.terminal.encoding")}:").xy(1, rows)
.add(charsetComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.backspace")}:").xy(1, rows)
.add(backspaceComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.character-mode")}:").xy(1, rows)
.add(characterAtATimeTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.startup-commands")}:").xy(1, rows)
.add(startupCommandTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.env")}:").xy(1, rows)

View File

@@ -1,6 +1,7 @@
package app.termora.plugin.internal.telnet
import app.termora.terminal.StreamPtyConnector
import org.apache.commons.io.IOUtils
import org.apache.commons.net.telnet.TelnetClient
import org.apache.commons.net.telnet.TelnetOption
import org.apache.commons.net.telnet.WindowSizeOptionHandler
@@ -9,18 +10,27 @@ import java.nio.charset.Charset
class TelnetStreamPtyConnector(
private val telnet: TelnetClient,
private val charset: Charset
) :
StreamPtyConnector(telnet.inputStream, telnet.outputStream) {
private val reader = InputStreamReader(telnet.inputStream, getCharset())
private val charset: Charset,
private val characterMode: Boolean,
) : StreamPtyConnector(telnet.inputStream, telnet.outputStream) {
private val reader = InputStreamReader(telnet.inputStream, charset)
override fun read(buffer: CharArray): Int {
return reader.read(buffer)
}
override fun write(buffer: ByteArray, offset: Int, len: Int) {
output.write(buffer, offset, len)
output.flush()
if (characterMode) {
for (i in offset until len + offset) {
output.write(byteArrayOf(buffer[i]))
output.flush()
}
} else {
output.write(buffer, offset, len)
output.flush()
}
}
override fun resize(rows: Int, cols: Int) {
@@ -33,10 +43,13 @@ class TelnetStreamPtyConnector(
}
override fun close() {
IOUtils.closeQuietly(input)
IOUtils.closeQuietly(output)
telnet.disconnect()
}
override fun getCharset(): Charset {
return charset
}
}

View File

@@ -1,6 +1,9 @@
package app.termora.plugin.internal.telnet
import app.termora.*
import app.termora.Host
import app.termora.ProxyType
import app.termora.PtyHostTerminalTab
import app.termora.WindowScope
import app.termora.terminal.ControlCharacters
import app.termora.terminal.KeyEncoderImpl
import app.termora.terminal.PtyConnector
@@ -11,6 +14,7 @@ import java.net.InetSocketAddress
import java.net.Proxy
import java.nio.charset.Charset
class TelnetTerminalTab(
windowScope: WindowScope, host: Host,
) : PtyHostTerminalTab(windowScope, host) {
@@ -32,12 +36,18 @@ class TelnetTerminalTab(
)
}
val characterMode = host.options.extras["character-at-a-time"]?.toBooleanStrictOrNull() ?: false
val termtype = host.options.envs()["TERM"] ?: "xterm-256color"
val ttopt = TerminalTypeOptionHandler(termtype, false, false, true, false)
val echoopt = EchoOptionHandler(false, true, false, true)
val gaopt = SuppressGAOptionHandler(true, true, true, true)
val wsopt = WindowSizeOptionHandler(winSize.cols, winSize.rows, true, false, true, false)
val bopt = SimpleOptionHandler(TelnetOption.BINARY, true, false, true, false)
val fcopt = SimpleOptionHandler(TelnetOption.REMOTE_FLOW_CONTROL, true, true, false, false)
telnet.addOptionHandler(bopt)
telnet.addOptionHandler(fcopt)
telnet.addOptionHandler(ttopt)
telnet.addOptionHandler(echoopt)
telnet.addOptionHandler(gaopt)
@@ -45,6 +55,7 @@ class TelnetTerminalTab(
telnet.connect(host.host, host.port)
telnet.keepAlive = true
telnet.tcpNoDelay = characterMode
val encoder = terminal.getKeyEncoder()
if (encoder is KeyEncoderImpl) {
@@ -56,38 +67,9 @@ class TelnetTerminalTab(
}
}
return ptyConnectorFactory.decorate(TelnetStreamPtyConnector(telnet, telnet.charset))
return ptyConnectorFactory.decorate(TelnetStreamPtyConnector(telnet, telnet.charset, characterMode))
}
override fun loginScriptsPtyConnector(host: Host, ptyConnector: PtyConnector): PtyConnector {
if (host.authentication.type != AuthenticationType.Password) {
return ptyConnector
}
val scripts = mutableListOf<LoginScript>()
scripts.add(
LoginScript(
expect = "login:",
send = host.username,
regex = false,
matchCase = false
)
)
scripts.add(
LoginScript(
expect = "password:",
send = host.authentication.password,
regex = false,
matchCase = false
)
)
return super.loginScriptsPtyConnector(
host.copy(options = host.options.copy(loginScripts = scripts)),
ptyConnector
)
}
}

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

@@ -342,7 +342,10 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
}
// 设置滚动区域
terminal.getTerminalModel().setData(DataKey.ScrollingRegion, ScrollingRegion(top, bottom))
terminal.getTerminalModel().setData(
DataKey.ScrollingRegion,
ScrollingRegion(top, min(bottom, terminalModel.getRows()))
)
if (log.isDebugEnabled) {
log.debug("Set Scrolling Region [${top}; ${bottom}]")
@@ -715,6 +718,13 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
}
}
// Alternate Screen Buffer
47, 1047 -> {
// clear selection
terminal.getSelectionModel().clearSelection()
terminalModel.setData(DataKey.AlternateScreenBuffer, enable)
}
// Alternate Screen Buffer
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer
1049 -> {

View File

@@ -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
@@ -183,6 +182,7 @@ termora.new-host.proxy=Proxy
termora.new-host.terminal=${termora.settings.terminal}
termora.new-host.terminal.encoding=Encoding
termora.new-host.terminal.backspace=Backspace
termora.new-host.terminal.character-mode=Character-at-a-time
termora.new-host.terminal.heartbeat-interval=Heartbeat Interval
termora.new-host.terminal.startup-commands=Startup Command
termora.new-host.terminal.env=Environment

View File

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

View File

@@ -22,7 +22,6 @@ termora.optional=可选的
# update
termora.update.title=新版本
termora.update.update=更新
termora.update.ignore=下次提醒我
# Hosts
@@ -175,6 +174,7 @@ termora.new-host.proxy=代理
termora.new-host.terminal=${termora.settings.terminal}
termora.new-host.terminal.encoding=编码
termora.new-host.terminal.backspace=退格键
termora.new-host.terminal.character-mode=单字符模式
termora.new-host.terminal.heartbeat-interval=心跳间隔
termora.new-host.terminal.startup-commands=启动命令
termora.new-host.terminal.env=环境

View File

@@ -19,7 +19,6 @@ termora.optional=可選的
# update
termora.update.title=新版本
termora.update.update=更新
termora.update.ignore=下次提醒我
@@ -173,6 +172,7 @@ termora.new-host.proxy=代理
termora.new-host.terminal=${termora.settings.terminal}
termora.new-host.terminal.encoding=編碼
termora.new-host.terminal.backspace=退格鍵
termora.new-host.terminal.character-mode=單字元模式
termora.new-host.terminal.startup-commands=啟動命令
termora.new-host.terminal.heartbeat-interval=心跳間隔
termora.new-host.terminal.env=環境

View File

@@ -0,0 +1,20 @@
FROM debian:12.11
RUN apt-get clean && apt-get update && apt-get install -y curl tar zip binutils fakeroot wget libfuse2 fuse libglib2.0-0 file ca-certificates libstdc++6
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
URL="https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-linux-x64-b1034.51.tar.gz"; \
elif [ "$ARCH" = "arm64" ]; then \
URL="https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-linux-aarch64-b1034.51.tar.gz"; \
else \
echo "Unsupported architecture: $ARCH" && exit 1; \
fi && \
curl -L "$URL" -o jbr.tar.gz && \
mkdir -p /opt/jbr && \
tar -xzf jbr.tar.gz -C /opt/jbr --strip-components=1 && \
rm jbr.tar.gz
ENV JAVA_HOME=/opt/jbr
ENV PATH="${JAVA_HOME}/bin:${PATH}"