Compare commits

...

58 Commits
1.0.4 ... 1.0.7

Author SHA1 Message Date
hstyi
4e690bafed fix: macOS sign 2025-02-12 09:03:41 +08:00
hstyi
28b511e179 release: 1.0.7 2025-02-12 08:47:00 +08:00
hstyi
f010a13abd fix: center the MFA Code dialog (#199) 2025-02-11 16:38:09 +08:00
hstyi
4d80ffafdd fix: SSH password authentication reading local private key (#185) 2025-02-10 14:18:14 +08:00
hstyi
9aecd4d54b chore: browse 2025-02-10 14:01:59 +08:00
hstyi
65091823eb chore: copy-ssh-id i18n 2025-02-10 09:29:06 +08:00
hstyi
d17218bfbd chore: disable jpackage verbose 2025-02-09 17:07:08 +08:00
hstyi
724c5d2632 feat: copy public key display name (#186) 2025-02-09 16:56:10 +08:00
hstyi
6806c26028 feat: deprecate double-click Shift shortcut (#184) 2025-02-09 15:26:36 +08:00
hstyi
dcd89174c9 chore: new version dialog (#182) 2025-02-09 11:10:06 +08:00
hstyi
9a8707b8cb fix: encoding error 2025-02-09 10:25:43 +08:00
hstyi
28f1d05f06 feat: support ssh-copy-id (#177) 2025-02-08 12:32:18 +08:00
hstyi
54b044584e fix: line breaks 2025-02-08 11:01:59 +08:00
hstyi
ed39449a20 feat: GitHub actions macOS sign (#175) 2025-02-08 10:42:41 +08:00
hstyi
2ff3f3a352 chore: improve code 2025-02-08 09:18:14 +08:00
Mystery0 M
91e2e964a5 chore: move terminal disconnection messages to i18n (#168) 2025-02-08 09:15:21 +08:00
Mystery0 M
ca6cc68fed feat: support auto close terminal tab when ssh disconnected normally (#169) 2025-02-08 09:14:57 +08:00
hstyi
0962de7735 feat: winget releaser 2025-02-08 08:52:56 +08:00
hstyi
062b957fdb docs: README 2025-02-07 15:40:27 +08:00
hstyi
4efe4e5663 chore: opengl 2025-02-07 14:43:03 +08:00
hstyi
25eb6966c4 feat: external release to create a new window (#162) 2025-02-07 14:11:07 +08:00
hstyi
7843460020 feat: confirmation required to exit program 2025-02-07 13:50:34 +08:00
hstyi
1cbc6ba4a9 fix: color mismatch issue 2025-02-07 11:15:21 +08:00
hstyi
a43407bee8 feat: support drag and drop transfer (#157) 2025-02-07 11:15:01 +08:00
hstyi
05c4ec9af2 feat: support for turning off beep (#155) 2025-02-07 09:22:01 +08:00
hstyi
9236064293 docs: README 2025-02-06 16:03:52 +08:00
hstyi
e1955a371e feat: support for WebDAV (#150) 2025-02-06 16:03:25 +08:00
hstyi
58b56c4221 fix: drag and drop cancel 2025-02-06 11:30:09 +08:00
hstyi
1e461e529f release: 1.0.6 2025-02-06 10:52:15 +08:00
hstyi
38ada1207c chore: PasswordField allows copying and cutting 2025-02-06 10:05:18 +08:00
hstyi
8bd1b34f46 feat: support drag and drop to other windows (#145) 2025-02-06 09:51:45 +08:00
hstyi
4a513360e6 chore: text cursor 2025-02-05 14:19:02 +08:00
hstyi
22da5c1c37 chore: jbrsdk-21.0.6 2025-01-28 12:01:46 +08:00
hstyi
483582a8d1 feat: serial comm (#141) 2025-01-28 10:23:05 +08:00
hstyi
f037cbfac0 docs: README 2025-01-26 21:04:54 +08:00
hstyi
343d11482d release: 1.0.5 2025-01-26 20:35:18 +08:00
hstyi
7ef81a0116 feat: xterm DCS 2025-01-26 14:42:59 +08:00
hstyi
5df62d5d3e fix: possible invalid window creation 2025-01-26 10:24:55 +08:00
hstyi
7db650d69f feat: open in new window 2025-01-26 10:20:26 +08:00
hstyi
8d80d38d63 fix: missing exchange algorithms 2025-01-26 08:44:00 +08:00
hstyi
48f05d4cff feat: ssh insecure key exchange algorithms 2025-01-26 08:44:00 +08:00
hstyi
9a1cf387c0 fix: check-license 2025-01-25 21:20:08 +08:00
hstyi
8b7efefbdb fix: shift to close tabs causes switching 2025-01-25 21:11:54 +08:00
hstyi
75f21db325 fix: theAwtToolkitWindow 2025-01-25 18:06:01 +08:00
hstyi
b094c9d4ff chore: remove tabbed hover background 2025-01-25 17:03:06 +08:00
hstyi
0da3c95759 feat: press and hold Shift to close Tab (#131) 2025-01-25 16:24:36 +08:00
hstyi
fa79473ece chore: optimize key encoder 2025-01-25 15:03:52 +08:00
hstyi
86ccb5e0cc chore: LANG=en_US.UTF-8 2025-01-24 17:27:47 +08:00
hstyi
f385f4b277 feat: support import (#127) 2025-01-24 16:45:36 +08:00
hstyi
3d0ef2a331 feat: shortcut key prediction (#126) 2025-01-24 15:40:14 +08:00
hstyi
96999205a8 fix: host test connection 2025-01-24 10:55:42 +08:00
hstyi
ee7f3871eb fix: sftp symbolic link (#120) 2025-01-24 10:27:15 +08:00
hstyi
df2e9b0743 feat: support drag and drop sorting 2025-01-23 16:23:16 +08:00
hstyi
7964950149 fix: #112 2025-01-23 14:47:39 +08:00
hstyi
e2d77fe881 fix: key manager 2025-01-23 14:43:48 +08:00
hstyi
f5783c8587 feat: support more monospaced fonts 2025-01-23 11:26:24 +08:00
hstyi
346044b1ba fix: shortcut keys lead to terminal input 2025-01-23 11:26:12 +08:00
hstyi
aa6ec8dd43 feat: xcrun stapler staple 2025-01-23 10:17:18 +08:00
79 changed files with 2466 additions and 653 deletions

View File

@@ -11,7 +11,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
# download jdk # download jdk
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.5-linux-x64-b509.30.tar.gz - run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-x64-b825.69.tar.gz
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -19,7 +19,7 @@ jobs:
with: with:
distribution: 'jdkfile' distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.5' java-version: '21.0.6'
architecture: x64 architecture: x64
# dist # dist

View File

@@ -10,9 +10,31 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install the Apple certificate
if: github.event_name == 'push'
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
# download jdk # download jdk
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.5-osx-aarch64-b509.30.tar.gz - run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b825.69.tar.gz
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -20,12 +42,15 @@ jobs:
with: with:
distribution: 'jdkfile' distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.5' java-version: '21.0.6'
architecture: aarch64 architecture: aarch64
# dist # dist
- run: | - name: Dist
env:
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' }}
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
run: |
./gradlew dist --no-daemon ./gradlew dist --no-daemon
- name: Upload artifact - name: Upload artifact

View File

@@ -10,8 +10,31 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install the Apple certificate
if: github.event_name == 'push'
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
# download jdk # download jdk
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.5-osx-x64-b509.30.tar.gz - run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b825.69.tar.gz
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -19,12 +42,16 @@ jobs:
with: with:
distribution: 'jdkfile' distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.5' java-version: '21.0.6'
architecture: x64 architecture: x64
# dist # dist
- run: | - name: Dist
env:
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' }}
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
run: |
./gradlew dist --no-daemon ./gradlew dist --no-daemon
- name: Upload artifact - name: Upload artifact

13
.github/workflows/winget.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Publish to WinGet
on:
release:
types: [ released ]
jobs:
publish:
runs-on: windows-latest
steps:
- uses: vedantmgoyal9/winget-releaser@main
with:
identifier: TermoraDev.Termora
installers-regex: 'x86-64\.msi$' # Only x86-64.msi files
token: ${{ secrets.WINGET_TOKEN }}

View File

@@ -15,10 +15,12 @@
## Features ## Features
- SSH and local terminal support - SSH and local terminal support
- Serial port protocol support
- [SFTP](./docs/sftp.png?raw=1) file transfer support - [SFTP](./docs/sftp.png?raw=1) file transfer support
- Compatible with Windows, macOS, and Linux - Compatible with Windows, macOS, and Linux
- Zmodem protocol support - Zmodem protocol support
- SSH port forwarding - SSH port forwarding & Jump hosts
- Terminal log
- Configuration synchronization via [Gist](https://gist.github.com) - Configuration synchronization via [Gist](https://gist.github.com)
- Macro support (record and replay scripts) - Macro support (record and replay scripts)
- Keyword highlighting - Keyword highlighting
@@ -32,6 +34,7 @@
- [Latest release](https://github.com/TermoraDev/termora/releases/latest) - [Latest release](https://github.com/TermoraDev/termora/releases/latest)
- [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora` - [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora`
- [WinGet](https://github.com/microsoft/winget-pkgs/tree/master/manifests/t/TermoraDev/Termora): `winget install termora`
## Development ## Development

View File

@@ -11,10 +11,12 @@
## 功能特性 ## 功能特性
- 支持 SSH 和本地终端 - 支持 SSH 和本地终端
- 支持串口协议
- 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) 文件传输 - 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) 文件传输
- 支持 Windows、macOS、Linux 平台 - 支持 Windows、macOS、Linux 平台
- 支持 Zmodem 协议 - 支持 Zmodem 协议
- 支持 SSH 端口转发 - 支持 SSH 端口转发和跳板机
- 终端日志记录
- 支持配置同步到 [Gist](https://gist.github.com) - 支持配置同步到 [Gist](https://gist.github.com)
- 支持宏(录制脚本并回放) - 支持宏(录制脚本并回放)
- 支持关键词高亮 - 支持关键词高亮
@@ -28,6 +30,7 @@
- [Latest release](https://github.com/TermoraDev/termora/releases/latest) - [Latest release](https://github.com/TermoraDev/termora/releases/latest)
- [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora` - [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora`
- [WinGet](https://github.com/microsoft/winget-pkgs/tree/master/manifests/t/TermoraDev/Termora): `winget install termora`
## 开发 ## 开发

View File

@@ -241,3 +241,7 @@ https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE
json-20231013 json-20231013
Public Domain. Public Domain.
https://github.com/stleary/JSON-java/blob/master/LICENSE https://github.com/stleary/JSON-java/blob/master/LICENSE
jSerialComm 2.11.0
Apache License 2.0
https://github.com/Fazecast/jSerialComm/blob/master/LICENSE-APACHE-2.0

View File

@@ -1,5 +1,6 @@
import org.gradle.internal.jvm.Jvm import org.gradle.internal.jvm.Jvm
import org.gradle.kotlin.dsl.support.uppercaseFirstChar import org.gradle.kotlin.dsl.support.uppercaseFirstChar
import org.gradle.nativeplatform.platform.internal.ArchitectureInternal
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
import org.jetbrains.kotlin.org.apache.commons.io.FileUtils import org.jetbrains.kotlin.org.apache.commons.io.FileUtils
import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils
@@ -14,10 +15,10 @@ plugins {
group = "app.termora" group = "app.termora"
version = "1.0.4" version = "1.0.7"
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem() val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: Architecture = DefaultNativePlatform.getCurrentArchitecture() val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
// macOS 签名信息 // macOS 签名信息
val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY
@@ -37,7 +38,7 @@ repositories {
dependencies { dependencies {
// 由于签名和公证macOS 不携带 natives // 由于签名和公证macOS 不携带 natives
val useNoNativesFlatLaf = os.isMacOsX && macOSNotary && System.getenv("ENABLE_BUILD").toBoolean() val useNoNativesFlatLaf = os.isMacOsX && System.getenv("ENABLE_BUILD").toBoolean()
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
testImplementation(libs.hutool) testImplementation(libs.hutool)
@@ -104,6 +105,7 @@ dependencies {
implementation(libs.bip39) implementation(libs.bip39)
implementation(libs.colorpicker) implementation(libs.colorpicker)
implementation(libs.mixpanel) implementation(libs.mixpanel)
implementation(libs.jSerialComm)
} }
application { application {
@@ -148,6 +150,8 @@ tasks.register<Copy>("copy-dependencies") {
val jna = libs.jna.asProvider().get() val jna = libs.jna.asProvider().get()
val dylib = dir.get().dir("dylib").asFile val dylib = dir.get().dir("dylib").asFile
val pty4j = libs.pty4j.get() val pty4j = libs.pty4j.get()
val jSerialComm = libs.jSerialComm.get()
for (file in dir.get().asFile.listFiles() ?: emptyArray()) { for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) { if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
val targetDir = File(dylib, jna.name) val targetDir = File(dylib, jna.name)
@@ -172,6 +176,21 @@ tasks.register<Copy>("copy-dependencies") {
// @formatter:on // @formatter:on
// 删除所有二进制类库 // 删除所有二进制类库
exec { commandLine("zip", "-d", file.absolutePath, "resources/*") } exec { commandLine("zip", "-d", file.absolutePath, "resources/*") }
} else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) {
val archName = if (arch.isArm) "aarch64" else "x86_64"
val targetDir = FileUtils.getFile(dylib, jSerialComm.name, "OSX", archName)
FileUtils.forceMkdir(targetDir)
// @formatter:off
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "OSX/${archName}/*", "-d", targetDir.absolutePath) }
// @formatter:on
// 删除所有二进制类库
exec { commandLine("zip", "-d", file.absolutePath, "Android/*") }
exec { commandLine("zip", "-d", file.absolutePath, "FreeBSD/*") }
exec { commandLine("zip", "-d", file.absolutePath, "Linux/*") }
exec { commandLine("zip", "-d", file.absolutePath, "OpenBSD/*") }
exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") }
exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") }
exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") }
} }
} }
@@ -232,15 +251,18 @@ tasks.register<Exec>("jpackage") {
"-Dapp-version=${project.version}", "-Dapp-version=${project.version}",
) )
if (os.isMacOsX) {
options.add("-Dsun.java2d.metal=true") options.add("-Dsun.java2d.metal=true")
if (os.isMacOsX) {
options.add("-Dapple.awt.application.appearance=system") options.add("-Dapple.awt.application.appearance=system")
options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED") options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")
} else { }
if (os.isLinux) {
options.add("-Dsun.java2d.opengl=true") options.add("-Dsun.java2d.opengl=true")
} }
val arguments = mutableListOf("${Jvm.current().javaHome}/bin/jpackage", "--verbose") val arguments = mutableListOf("${Jvm.current().javaHome}/bin/jpackage")
arguments.addAll(listOf("--runtime-image", "${buildDir}/jlink")) arguments.addAll(listOf("--runtime-image", "${buildDir}/jlink"))
arguments.addAll(listOf("--name", project.name.uppercaseFirstChar())) arguments.addAll(listOf("--name", project.name.uppercaseFirstChar()))
arguments.addAll(listOf("--app-version", "${project.version}")) arguments.addAll(listOf("--app-version", "${project.version}"))
@@ -383,6 +405,14 @@ tasks.register("dist") {
"--wait", "--wait",
) )
} }
// 绑定公证信息
exec {
commandLine(
"/usr/bin/xcrun",
"stapler", "staple", macOSFinalFilePath,
)
}
} }
} }
} }
@@ -407,6 +437,18 @@ tasks.register("check-license") {
thirdParty[nameWithVersion.replace(StringUtils.SPACE, "-")] = license thirdParty[nameWithVersion.replace(StringUtils.SPACE, "-")] = license
thirdPartyNames.add(nameWithVersion.split(StringUtils.SPACE).first()) thirdPartyNames.add(nameWithVersion.split(StringUtils.SPACE).first())
} }
for (file in configurations.runtimeClasspath.get()) {
val name = file.nameWithoutExtension
if (!thirdParty.containsKey(name)) {
if (logger.isWarnEnabled) {
logger.warn("$name does not exist in third-party")
}
if (!thirdPartyNames.contains(name)) {
throw GradleException("$name No license found")
}
}
}
} }
} }

View File

@@ -41,6 +41,7 @@ rhino = "1.7.15"
delight-rhino-sandbox = "0.0.17" delight-rhino-sandbox = "0.0.17"
testcontainers = "1.20.4" testcontainers = "1.20.4"
mixpanel = "1.5.3" mixpanel = "1.5.3"
jSerialComm="2.11.0"
[libraries] [libraries]
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
@@ -97,6 +98,7 @@ rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" } delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" }
colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" } colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" }
mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" } mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" }
jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "jSerialComm" }
[plugins] [plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

View File

@@ -111,11 +111,18 @@ object Application {
return "Termora" return "Termora"
} }
@Suppress("OPT_IN_USAGE")
fun browse(uri: URI, async: Boolean = true) { fun browse(uri: URI, async: Boolean = true) {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { // https://github.com/TermoraDev/termora/issues/178
if (SystemInfo.isWindows && uri.scheme == "file") {
if (async) {
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) }
} else {
tryBrowse(uri)
}
} else if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(uri) Desktop.getDesktop().browse(uri)
} else if (async) { } else if (async) {
@Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) } GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) }
} else { } else {
tryBrowse(uri) tryBrowse(uri)

View File

@@ -10,9 +10,6 @@ import com.jthemedetecor.OsThemeDetector
import com.mixpanel.mixpanelapi.ClientDelivery import com.mixpanel.mixpanelapi.ClientDelivery
import com.mixpanel.mixpanelapi.MessageBuilder import com.mixpanel.mixpanelapi.MessageBuilder
import com.mixpanel.mixpanelapi.MixpanelAPI import com.mixpanel.mixpanelapi.MixpanelAPI
import com.sun.jna.platform.WindowUtils
import com.sun.jna.platform.win32.User32
import com.sun.jna.ptr.IntByReference
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -20,25 +17,27 @@ import kotlinx.coroutines.launch
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.LocaleUtils import org.apache.commons.lang3.LocaleUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.math.NumberUtils
import org.json.JSONObject import org.json.JSONObject
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.tinylog.configuration.Configuration import org.tinylog.configuration.Configuration
import java.io.File import java.io.File
import java.io.RandomAccessFile import java.nio.channels.FileChannel
import java.nio.channels.FileLock import java.nio.channels.FileLock
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.util.* import java.util.*
import javax.swing.* import javax.swing.*
import kotlin.system.exitProcess import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
class ApplicationRunner { class ApplicationRunner {
private lateinit var singletonChannel: FileChannel
private lateinit var singletonLock: FileLock private lateinit var singletonLock: FileLock
private val log by lazy { private val log by lazy {
if (!::singletonLock.isInitialized) { if (!::singletonLock.isInitialized) {
throw UnsupportedOperationException("Singleton lock is not initialized") throw UnsupportedOperationException("Singleton lock is not initialized")
} }
LoggerFactory.getLogger("Main") LoggerFactory.getLogger(ApplicationRunner::class.java)
} }
fun run() { fun run() {
@@ -224,36 +223,14 @@ class ApplicationRunner {
private fun checkSingleton() { private fun checkSingleton() {
val file = File(Application.getBaseDataDir(), "lock") singletonChannel = FileChannel.open(
val pidFile = File(Application.getBaseDataDir(), "pid") Paths.get(Application.getBaseDataDir().absolutePath, "lock"),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
val raf = RandomAccessFile(file, "rw") )
val lock = raf.channel.tryLock()
if (lock != null) {
pidFile.writeText(ProcessHandle.current().pid().toString())
pidFile.deleteOnExit()
file.deleteOnExit()
} else {
if (SystemInfo.isWindows && pidFile.exists()) {
val pid = NumberUtils.toLong(pidFile.readText())
for (window in WindowUtils.getAllWindows(false)) {
if (pid > 0) {
val processId = IntByReference()
User32.INSTANCE.GetWindowThreadProcessId(window.hwnd, processId)
if (processId.value.toLong() != pid) {
continue
}
} else if (window.title != Application.getName() || window.filePath.endsWith("explorer.exe")) {
continue
}
User32.INSTANCE.ShowWindow(window.hwnd, User32.SW_SHOWNOACTIVATE)
User32.INSTANCE.SetForegroundWindow(window.hwnd)
break
}
}
val lock = singletonChannel.tryLock()
if (lock == null) {
System.err.println("Program is already running") System.err.println("Program is already running")
exitProcess(1) exitProcess(1)
} }

View File

@@ -22,10 +22,6 @@ class ChannelShellPtyConnector(
output.flush() output.flush()
} }
override fun write(buffer: String) {
write(buffer.toByteArray(charset))
}
override fun resize(rows: Int, cols: Int) { override fun resize(rows: Int, cols: Int) {
channel.sendWindowChange(cols, rows) channel.sendWindowChange(cols, rows)
} }
@@ -38,4 +34,8 @@ class ChannelShellPtyConnector(
override fun close() { override fun close() {
channel.close(true) channel.close(true)
} }
override fun getCharset(): Charset {
return charset
}
} }

View File

@@ -454,6 +454,11 @@ class Database private constructor(private val env: Environment) : Disposable {
*/ */
var debug by BooleanPropertyDelegate(false) var debug by BooleanPropertyDelegate(false)
/**
* 蜂鸣声
*/
var beep by BooleanPropertyDelegate(true)
/** /**
* 选中复制 * 选中复制
*/ */
@@ -463,6 +468,11 @@ class Database private constructor(private val env: Environment) : Disposable {
* 光标样式 * 光标样式
*/ */
var cursor by CursorStylePropertyDelegate(CursorStyle.Block) var cursor by CursorStylePropertyDelegate(CursorStyle.Block)
/**
* 终端断开连接时自动关闭Tab
*/
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
} }
/** /**

View File

@@ -37,6 +37,16 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
} }
jumpHostsOption.filter = { it.id != host.id } jumpHostsOption.filter = { it.id != host.id }
val serialComm = host.options.serialComm
if (serialComm.port.isNotBlank()) {
serialCommOption.serialPortComboBox.selectedItem = serialComm.port
}
serialCommOption.baudRateComboBox.selectedItem = serialComm.baudRate
serialCommOption.dataBitsComboBox.selectedItem = serialComm.dataBits
serialCommOption.parityComboBox.selectedItem = serialComm.parity
serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits
serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl
} }
override fun getHost(): Host { override fun getHost(): Host {

View File

@@ -13,6 +13,7 @@ enum class Protocol {
Folder, Folder,
SSH, SSH,
Local, Local,
Serial
} }
@@ -39,6 +40,53 @@ data class Authentication(
} }
} }
enum class SerialCommParity {
None,
Even,
Odd,
Mark,
Space
}
enum class SerialCommFlowControl {
None,
RTS_CTS,
XON_XOFF,
}
@Serializable
data class SerialComm(
/**
* 串口
*/
val port: String = StringUtils.EMPTY,
/**
* 波特率
*/
val baudRate: Int = 9600,
/**
* 数据位5、6、7、8
*/
val dataBits: Int = 8,
/**
* 停止位: 1、1.5、2
*/
val stopBits: String = "1",
/**
* 校验位
*/
val parity: SerialCommParity = SerialCommParity.None,
/**
* 流控
*/
val flowControl: SerialCommFlowControl = SerialCommFlowControl.None,
)
@Serializable @Serializable
data class Options( data class Options(
@@ -61,7 +109,12 @@ data class Options(
/** /**
* SSH 心跳间隔 * SSH 心跳间隔
*/ */
val heartbeatInterval: Int = 30 val heartbeatInterval: Int = 30,
/**
* 串口配置
*/
val serialComm: SerialComm = SerialComm(),
) { ) {
companion object { companion object {
val Default = Options() val Default = Options()

View File

@@ -2,6 +2,9 @@ package app.termora
import app.termora.actions.AnAction import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.keyboardinteractive.TerminalUserInteraction
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.client.SshClient import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession import org.apache.sshd.client.session.ClientSession
@@ -47,38 +50,70 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
} }
putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...") putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...")
SwingUtilities.invokeLater { isEnabled = false
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
testConnection(pane.getHost()) testConnection(pane.getHost())
withContext(Dispatchers.Swing) {
putValue(NAME, I18n.getString("termora.new-host.test-connection")) putValue(NAME, I18n.getString("termora.new-host.test-connection"))
isEnabled = true
}
} }
} }
} }
} }
private fun testConnection(host: Host) { private suspend fun testConnection(host: Host) {
if (host.protocol != Protocol.SSH) { val owner = this
OptionPane.showMessageDialog(this, I18n.getString("termora.new-host.test-connection-successful")) if (host.protocol == Protocol.Local) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(owner, I18n.getString("termora.new-host.test-connection-successful"))
}
return return
} }
try {
if (host.protocol == Protocol.SSH) {
testSSH(host)
} else if (host.protocol == Protocol.Serial) {
testSerial(host)
}
} catch (e: Exception) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner, ExceptionUtils.getMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
return
}
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.new-host.test-connection-successful")
)
}
}
private fun testSSH(host: Host) {
var client: SshClient? = null var client: SshClient? = null
var session: ClientSession? = null var session: ClientSession? = null
try { try {
client = SshClients.openClient(host) client = SshClients.openClient(host)
client.userInteraction = TerminalUserInteraction(owner)
session = SshClients.openSession(host, client) session = SshClients.openSession(host, client)
OptionPane.showMessageDialog(this, I18n.getString("termora.new-host.test-connection-successful"))
} catch (e: Exception) {
OptionPane.showMessageDialog(
this, ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
} finally { } finally {
session?.close() session?.close()
client?.close() client?.close()
} }
}
private fun testSerial(host: Host) {
Serials.openPort(host).closePort()
} }
override fun doOKAction() { override fun doOKAction() {

View File

@@ -2,11 +2,17 @@ package app.termora
import app.termora.keymgr.KeyManager import app.termora.keymgr.KeyManager
import app.termora.keymgr.KeyManagerDialog import app.termora.keymgr.KeyManagerDialog
import com.fazecast.jSerialComm.SerialPort
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.ui.FlatTextBorder import com.formdev.flatlaf.ui.FlatTextBorder
import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import java.awt.* import java.awt.*
import java.awt.event.* import java.awt.event.*
@@ -22,6 +28,7 @@ open class HostOptionsPane : OptionsPane() {
protected val proxyOption = ProxyOption() protected val proxyOption = ProxyOption()
protected val terminalOption = TerminalOption() protected val terminalOption = TerminalOption()
protected val jumpHostsOption = JumpHostsOption() protected val jumpHostsOption = JumpHostsOption()
protected val serialCommOption = SerialCommOption()
protected val owner: Window get() = SwingUtilities.getWindowAncestor(this) protected val owner: Window get() = SwingUtilities.getWindowAncestor(this)
init { init {
@@ -30,6 +37,7 @@ open class HostOptionsPane : OptionsPane() {
addOption(tunnelingOption) addOption(tunnelingOption)
addOption(jumpHostsOption) addOption(jumpHostsOption)
addOption(terminalOption) addOption(terminalOption)
addOption(serialCommOption)
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8)) setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
} }
@@ -43,6 +51,7 @@ open class HostOptionsPane : OptionsPane() {
var authentication = Authentication.No var authentication = Authentication.No
var proxy = Proxy.No var proxy = Proxy.No
if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) { if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
authentication = authentication.copy( authentication = authentication.copy(
type = AuthenticationType.Password, type = AuthenticationType.Password,
@@ -66,12 +75,23 @@ open class HostOptionsPane : OptionsPane() {
) )
} }
val serialComm = SerialComm(
port = serialCommOption.serialPortComboBox.selectedItem?.toString() ?: StringUtils.EMPTY,
baudRate = serialCommOption.baudRateComboBox.selectedItem?.toString()?.toIntOrNull() ?: 9600,
dataBits = serialCommOption.dataBitsComboBox.selectedItem as Int? ?: 8,
stopBits = serialCommOption.stopBitsComboBox.selectedItem as String? ?: "1",
parity = serialCommOption.parityComboBox.selectedItem as SerialCommParity,
flowControl = serialCommOption.flowControlComboBox.selectedItem as SerialCommFlowControl
)
val options = Options.Default.copy( val options = Options.Default.copy(
encoding = terminalOption.charsetComboBox.selectedItem as String, encoding = terminalOption.charsetComboBox.selectedItem as String,
env = terminalOption.environmentTextArea.text, env = terminalOption.environmentTextArea.text,
startupCommand = terminalOption.startupCommandTextField.text, startupCommand = terminalOption.startupCommandTextField.text,
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int, heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
jumpHosts = jumpHostsOption.jumpHosts.map { it.id } jumpHosts = jumpHostsOption.jumpHosts.map { it.id },
serialComm = serialComm
) )
return Host( return Host(
@@ -103,6 +123,12 @@ open class HostOptionsPane : OptionsPane() {
if (validateField(generalOption.usernameTextField)) { if (validateField(generalOption.usernameTextField)) {
return false return false
} }
} else if (host.protocol == Protocol.Serial) {
if (validateField(serialCommOption.serialPortComboBox)
|| validateField(serialCommOption.baudRateComboBox)
) {
return false
}
} }
if (host.authentication.type == AuthenticationType.Password) { if (host.authentication.type == AuthenticationType.Password) {
@@ -152,7 +178,8 @@ open class HostOptionsPane : OptionsPane() {
* 返回 true 表示有错误 * 返回 true 表示有错误
*/ */
private fun validateField(comboBox: JComboBox<*>): Boolean { private fun validateField(comboBox: JComboBox<*>): Boolean {
if (comboBox.isEnabled && comboBox.selectedItem == null) { val selectedItem = comboBox.selectedItem
if (comboBox.isEnabled && (selectedItem == null || (selectedItem is String && selectedItem.isBlank()))) {
selectOptionJComponent(comboBox) selectOptionJComponent(comboBox)
comboBox.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR) comboBox.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
comboBox.requestFocusInWindow() comboBox.requestFocusInWindow()
@@ -259,6 +286,7 @@ open class HostOptionsPane : OptionsPane() {
protocolTypeComboBox.addItem(Protocol.SSH) protocolTypeComboBox.addItem(Protocol.SSH)
protocolTypeComboBox.addItem(Protocol.Local) protocolTypeComboBox.addItem(Protocol.Local)
protocolTypeComboBox.addItem(Protocol.Serial)
authenticationTypeComboBox.addItem(AuthenticationType.No) authenticationTypeComboBox.addItem(AuthenticationType.No)
authenticationTypeComboBox.addItem(AuthenticationType.Password) authenticationTypeComboBox.addItem(AuthenticationType.Password)
@@ -328,7 +356,9 @@ open class HostOptionsPane : OptionsPane() {
passwordTextField.isEnabled = true passwordTextField.isEnabled = true
chooseKeyBtn.isEnabled = true chooseKeyBtn.isEnabled = true
if (protocolTypeComboBox.selectedItem == Protocol.Local) { if (protocolTypeComboBox.selectedItem == Protocol.Local
|| protocolTypeComboBox.selectedItem == Protocol.Serial
) {
hostTextField.isEnabled = false hostTextField.isEnabled = false
portTextField.isEnabled = false portTextField.isEnabled = false
usernameTextField.isEnabled = false usernameTextField.isEnabled = false
@@ -901,6 +931,127 @@ open class HostOptionsPane : OptionsPane() {
} }
} }
protected inner class SerialCommOption : JPanel(BorderLayout()), Option {
val serialPortComboBox = OutlineComboBox<String>()
val baudRateComboBox = OutlineComboBox<Int>()
val dataBitsComboBox = OutlineComboBox<Int>()
val parityComboBox = OutlineComboBox<SerialCommParity>()
val stopBitsComboBox = OutlineComboBox<String>()
val flowControlComboBox = OutlineComboBox<SerialCommFlowControl>()
init {
initView()
initEvents()
}
private fun initView() {
serialPortComboBox.isEditable = true
baudRateComboBox.isEditable = true
baudRateComboBox.addItem(9600)
baudRateComboBox.addItem(19200)
baudRateComboBox.addItem(38400)
baudRateComboBox.addItem(57600)
baudRateComboBox.addItem(115200)
dataBitsComboBox.addItem(5)
dataBitsComboBox.addItem(6)
dataBitsComboBox.addItem(7)
dataBitsComboBox.addItem(8)
dataBitsComboBox.selectedItem = 8
parityComboBox.addItem(SerialCommParity.None)
parityComboBox.addItem(SerialCommParity.Even)
parityComboBox.addItem(SerialCommParity.Odd)
parityComboBox.addItem(SerialCommParity.Mark)
parityComboBox.addItem(SerialCommParity.Space)
stopBitsComboBox.addItem("1")
stopBitsComboBox.addItem("1.5")
stopBitsComboBox.addItem("2")
stopBitsComboBox.selectedItem = "1"
flowControlComboBox.addItem(SerialCommFlowControl.None)
flowControlComboBox.addItem(SerialCommFlowControl.RTS_CTS)
flowControlComboBox.addItem(SerialCommFlowControl.XON_XOFF)
flowControlComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
val text = value?.toString() ?: StringUtils.EMPTY
return super.getListCellRendererComponent(
list,
text.replace('_', '/'),
index,
isSelected,
cellHasFocus
)
}
}
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
addComponentListener(object : ComponentAdapter() {
override fun componentShown(e: ComponentEvent) {
removeComponentListener(this)
@Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.IO) {
for (commPort in SerialPort.getCommPorts()) {
withContext(Dispatchers.Swing) {
serialPortComboBox.addItem(commPort.systemPortName)
}
}
}
}
})
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.plugin
}
override fun getTitle(): String {
return I18n.getString("termora.new-host.serial")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.new-host.serial.port")}:").xy(1, rows)
.add(serialPortComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.baud-rate")}:").xy(1, rows)
.add(baudRateComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.data-bits")}:").xy(1, rows)
.add(dataBitsComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.parity")}:").xy(1, rows)
.add(parityComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.stop-bits")}:").xy(1, rows)
.add(stopBitsComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.flow-control")}:").xy(1, rows)
.add(flowControlComboBox).xy(3, rows).apply { rows += step }
.build()
return panel
}
}
protected inner class JumpHostsOption : JPanel(BorderLayout()), Option { protected inner class JumpHostsOption : JPanel(BorderLayout()), Option {
val jumpHosts = mutableListOf<Host>() val jumpHosts = mutableListOf<Host>()
@@ -1006,7 +1157,8 @@ open class HostOptionsPane : OptionsPane() {
val rows = table.selectedRows.sortedDescending() val rows = table.selectedRows.sortedDescending()
if (rows.isEmpty()) return if (rows.isEmpty()) return
for (row in rows) { for (row in rows) {
model.removeRow(row) jumpHosts.removeAt(row)
model.fireTableRowsDeleted(row, row)
} }
} }
}) })

View File

@@ -69,6 +69,8 @@ class HostTree : JTree(), Disposable {
icon = if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon() icon = if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
} else if (host.protocol == Protocol.SSH || host.protocol == Protocol.Local) { } else if (host.protocol == Protocol.SSH || host.protocol == Protocol.Local) {
icon = if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal icon = if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal
} else if (host.protocol == Protocol.Serial) {
icon = if (sel && this@HostTree.hasFocus()) Icons.plugin.dark else Icons.plugin
} }
return c return c
} }
@@ -318,6 +320,7 @@ class HostTree : JTree(), Disposable {
val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host")) val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host"))
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open")) val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open"))
val openInNewWindow = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open-in-new-window"))
popupMenu.addSeparator() popupMenu.addSeparator()
val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy")) val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy"))
val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove")) val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove"))
@@ -330,15 +333,8 @@ class HostTree : JTree(), Disposable {
popupMenu.addSeparator() popupMenu.addSeparator()
val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property")) val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property"))
open.addActionListener { evt -> open.addActionListener { openHosts(it, false) }
getSelectionNodes() openInNewWindow.addActionListener { openHosts(it, true) }
.filter { it.protocol != Protocol.Folder }
.forEach {
ActionManager.getInstance()
.getAction(OpenHostAction.OPEN_HOST)
?.actionPerformed(OpenHostActionEvent(evt.source, it, evt))
}
}
rename.addActionListener { rename.addActionListener {
startEditingAtPath(TreePath(model.getPathToRoot(lastHost))) startEditingAtPath(TreePath(model.getPathToRoot(lastHost)))
@@ -454,6 +450,17 @@ class HostTree : JTree(), Disposable {
popupMenu.show(this, event.x, event.y) popupMenu.show(this, event.x, event.y)
} }
private fun openHosts(evt: EventObject, openInNewWindow: Boolean) {
assertEventDispatchThread()
val nodes = getSelectionNodes().filter { it.protocol != Protocol.Folder }
if (nodes.isEmpty()) return
val openHostAction = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) ?: return
val source = if (openInNewWindow)
TermoraFrameManager.getInstance().createWindow().apply { isVisible = true }
else evt.source
nodes.forEach { openHostAction.actionPerformed(OpenHostActionEvent(source, it, evt)) }
}
fun expandNode(node: Host, including: Boolean = false) { fun expandNode(node: Host, including: Boolean = false) {
expandPath(TreePath(model.getPathToRoot(node))) expandPath(TreePath(model.getPathToRoot(node)))

View File

@@ -3,6 +3,7 @@ package app.termora
object Icons { object Icons {
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") } val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") } val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
val plugin by lazy { DynamicIcon("icons/plugin.svg", "icons/plugin_dark.svg") }
val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") } val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") }
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") } val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") } val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") }
@@ -67,6 +68,7 @@ object Icons {
val network by lazy { DynamicIcon("icons/network.svg", "icons/network_dark.svg") } val network by lazy { DynamicIcon("icons/network.svg", "icons/network_dark.svg") }
val server by lazy { DynamicIcon("icons/server.svg", "icons/server_dark.svg") } val server by lazy { DynamicIcon("icons/server.svg", "icons/server_dark.svg") }
val runAnything by lazy { DynamicIcon("icons/runAnything.svg", "icons/runAnything_dark.svg") } val runAnything by lazy { DynamicIcon("icons/runAnything.svg", "icons/runAnything_dark.svg") }
val run by lazy { DynamicIcon("icons/run.svg", "icons/run_dark.svg") }
val uiForm by lazy { DynamicIcon("icons/uiForm.svg", "icons/uiForm_dark.svg") } val uiForm by lazy { DynamicIcon("icons/uiForm.svg", "icons/uiForm_dark.svg") }
val cloud by lazy { DynamicIcon("icons/cloud.svg", "icons/cloud_dark.svg") } val cloud by lazy { DynamicIcon("icons/cloud.svg", "icons/cloud_dark.svg") }
val externalLink by lazy { DynamicIcon("icons/externalLink.svg", "icons/externalLink_dark.svg") } val externalLink by lazy { DynamicIcon("icons/externalLink.svg", "icons/externalLink_dark.svg") }

View File

@@ -4,7 +4,8 @@ import app.termora.terminal.PtyConnector
import org.apache.commons.io.Charsets import org.apache.commons.io.Charsets
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
class LocalTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) { class LocalTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
override suspend fun openPtyConnector(): PtyConnector { override suspend fun openPtyConnector(): PtyConnector {
val winSize = terminalPanel.winSize() val winSize = terminalPanel.winSize()

View File

@@ -41,4 +41,9 @@ private fun setupNativeLibraries() {
if (pty4j.exists()) { if (pty4j.exists()) {
System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath) System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath)
} }
val jSerialComm = FileUtils.getFile(dylib, "jSerialComm")
if (jSerialComm.exists()) {
System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath)
}
} }

View File

@@ -1,12 +1,264 @@
package app.termora package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import com.formdev.flatlaf.extras.components.FlatTabbedPane import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils
import java.awt.*
import java.awt.event.*
import java.awt.image.BufferedImage
import java.util.*
import javax.swing.*
import kotlin.math.abs
class MyTabbedPane : FlatTabbedPane() { class MyTabbedPane : FlatTabbedPane() {
private val owner: Window get() = SwingUtilities.getWindowAncestor(this)
private val dragMouseAdaptor = DragMouseAdaptor()
private val terminalTabbedManager
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
.getData(DataProviders.TerminalTabbedManager)
init {
initEvents()
}
override fun updateUI() {
styleMap = mapOf(
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
"hoverColor" to UIManager.getColor("TabbedPane.background"),
)
super.updateUI()
}
private fun initEvents() {
addMouseListener(dragMouseAdaptor)
addMouseMotionListener(dragMouseAdaptor)
}
override fun processMouseEvent(e: MouseEvent) {
// Shift + Click ===> close tab
if (e.id == MouseEvent.MOUSE_CLICKED && SwingUtilities.isLeftMouseButton(e) && isShiftPressedOnly(e.modifiersEx)) {
val index = indexAtLocation(e.x, e.y)
if (index >= 0) {
tabCloseCallback?.accept(this, index)
return
}
} else if (e.id == MouseEvent.MOUSE_PRESSED && isShiftPressedOnly(e.modifiersEx)) {
val index = indexAtLocation(e.x, e.y)
if (index >= 0) {
return
}
}
super.processMouseEvent(e)
}
private fun isShiftPressedOnly(modifiersEx: Int): Boolean {
return (modifiersEx and InputEvent.ALT_DOWN_MASK) == 0
&& (modifiersEx and InputEvent.ALT_GRAPH_DOWN_MASK) == 0
&& (modifiersEx and InputEvent.CTRL_DOWN_MASK) == 0
&& (modifiersEx and InputEvent.SHIFT_DOWN_MASK) != 0
}
override fun setSelectedIndex(index: Int) { override fun setSelectedIndex(index: Int) {
val oldIndex = selectedIndex val oldIndex = selectedIndex
super.setSelectedIndex(index) super.setSelectedIndex(index)
firePropertyChange("selectedIndex", oldIndex, index) firePropertyChange("selectedIndex", oldIndex, index)
} }
private inner class DragMouseAdaptor : MouseAdapter(), KeyEventDispatcher {
private var mousePressedPoint = Point()
private var tabIndex = 0 - 1
private var cancelled = false
private var window: Window? = null
private var terminalTab: TerminalTab? = null
private var isDragging = false
private var lastVisitTabIndex = -1
private var releasedPoint = Point()
override fun mousePressed(e: MouseEvent) {
val index = indexAtLocation(e.x, e.y)
if (index < 0 || !isTabClosable(index)) {
return
}
tabIndex = index
mousePressedPoint = e.point
}
override fun mouseDragged(e: MouseEvent) {
// 如果正在拖拽中,那么修改 Window 的位置
if (isDragging) {
window?.location = e.locationOnScreen
lastVisitTabIndex = indexAtLocation(e.x, e.y)
} else if (tabIndex >= 0) { // 这里之所以判断是确保在 mousePressed 时已经确定了 Tab
// 有的时候会太灵敏,这里容错一下
val diff = 5
if (abs(mousePressedPoint.y - e.y) >= diff || abs(mousePressedPoint.x - e.x) >= diff) {
startDrag(e)
}
}
}
private fun startDrag(e: MouseEvent) {
if (isDragging) return
val terminalTabbedManager = terminalTabbedManager ?: return
val window = JDialog(owner).also { this.window = it }
window.isUndecorated = true
val image = createTabImage(tabIndex)
window.size = Dimension(image.width, image.height)
window.add(JLabel(ImageIcon(image)))
window.location = e.locationOnScreen
window.addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.removeKeyEventDispatcher(this@DragMouseAdaptor)
}
override fun windowOpened(e: WindowEvent) {
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.addKeyEventDispatcher(this@DragMouseAdaptor)
}
})
// 暂时关闭 Tab
terminalTabbedManager.closeTerminalTab(terminalTabbedManager.getTerminalTabs()[tabIndex].also {
terminalTab = it
}, false)
window.isVisible = true
isDragging = true
cancelled = false
}
private fun stopDrag() {
if (!isDragging) {
return
}
// 如果是取消,那么不需要移动到其它窗口
val c = if (cancelled) owner else getTopMostWindowUnderMouse()
// 如果等于 null 表示在空地方释放,那么单独一个窗口
if (c == null) {
val window = TermoraFrameManager.getInstance().createWindow()
dragToAnotherWindow(window)
window.location = releasedPoint
window.isVisible = true
} else if (c != owner && c is TermoraFrame) { // 如果在某个窗口内释放,那么就移动到某个窗口内
dragToAnotherWindow(c)
} else {
val tab = this.terminalTab
val terminalTabbedManager = terminalTabbedManager
if (tab != null && terminalTabbedManager != null) {
moveTab(
terminalTabbedManager,
tab,
lastVisitTabIndex
)
}
}
// reset
window?.dispose()
isDragging = false
tabIndex = -1
cancelled = false
lastVisitTabIndex = -1
}
override fun mouseReleased(e: MouseEvent) {
releasedPoint = e.point
stopDrag()
}
private fun createTabImage(index: Int): BufferedImage {
val tabBounds = getBoundsAt(index)
val image = BufferedImage(tabBounds.width, tabBounds.height, BufferedImage.TYPE_INT_ARGB)
val g2 = image.createGraphics()
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
g2.translate(-tabBounds.x, -tabBounds.y)
paint(g2)
g2.dispose()
return image
}
override fun dispatchKeyEvent(e: KeyEvent): Boolean {
if (e.keyCode == KeyEvent.VK_ESCAPE) {
cancelled = true
stopDrag()
return true
}
return false
}
private fun getTopMostWindowUnderMouse(): Window? {
val mouseLocation = MouseInfo.getPointerInfo().location
val owner = owner
if (owner.isVisible && owner.bounds.contains(mouseLocation)) {
return owner
}
val windows = Window.getWindows()
// 倒序遍历,最上层的窗口优先匹配
for (i in windows.indices.reversed()) {
val window = windows[i]
if (window !is TermoraFrame) {
continue
}
if (window.isVisible && window.bounds.contains(mouseLocation)) {
val topComponent = SwingUtilities.getDeepestComponentAt(
window,
mouseLocation.x - window.x, mouseLocation.y - window.y
)
if (topComponent != null) {
return SwingUtilities.getWindowAncestor(topComponent)
}
}
}
return null
}
private fun dragToAnotherWindow(frame: TermoraFrame) {
val tab = this.terminalTab ?: return
val tabbedManager = frame.getData(DataProviders.TerminalTabbed) ?: return
val tabbedPane = frame.getData(DataProviders.TabbedPane) ?: return
val location = Point(MouseInfo.getPointerInfo().location)
SwingUtilities.convertPointFromScreen(location, tabbedPane)
val index = tabbedPane.indexAtLocation(location.x, location.y)
moveTab(
tabbedManager,
tab,
index
)
if (frame.hasFocus()) {
return
}
SwingUtilities.invokeLater {
frame.requestFocus()
tabbedPane.selectedComponent?.requestFocusInWindow()
}
}
private fun moveTab(terminalTabbedManager: TerminalTabbedManager, tab: TerminalTab, lastVisitTabIndex: Int) {
// 如果是手动取消
if (cancelled) {
terminalTabbedManager.addTerminalTab(tabIndex, tab)
} else if (lastVisitTabIndex > 0) {
terminalTabbedManager.addTerminalTab(lastVisitTabIndex, tab)
} else if (lastVisitTabIndex == 0) {
terminalTabbedManager.addTerminalTab(1, tab)
} else {
terminalTabbedManager.addTerminalTab(tab)
}
}
}
} }

View File

@@ -38,6 +38,8 @@ class PtyConnectorFactory : Disposable {
val locale = Locale.getDefault() val locale = Locale.getDefault()
if (StringUtils.isNoneBlank(locale.language, locale.country)) { if (StringUtils.isNoneBlank(locale.language, locale.country)) {
envs["LANG"] = "${locale.language}_${locale.country}.${Charset.defaultCharset().name()}" envs["LANG"] = "${locale.language}_${locale.country}.${Charset.defaultCharset().name()}"
} else {
envs["LANG"] = "en_US.UTF-8"
} }
} }
} }

View File

@@ -23,8 +23,9 @@ abstract class PtyHostTerminalTab(
private var readerJob: Job? = null private var readerJob: Job? = null
private val ptyConnectorDelegate = PtyConnectorDelegate() private val ptyConnectorDelegate = PtyConnectorDelegate()
protected val terminalPanel = private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
TerminalPanelFactory.getInstance(windowScope).createTerminalPanel(terminal, ptyConnectorDelegate) protected val terminalPanel = terminalPanelFactory.createTerminalPanel(terminal, ptyConnectorDelegate)
.apply { Disposer.register(this@PtyHostTerminalTab, this) }
protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance(windowScope) protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance(windowScope)
init { init {
@@ -53,8 +54,12 @@ abstract class PtyHostTerminalTab(
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
delay(250.milliseconds) delay(250.milliseconds)
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
ptyConnector.write(host.options.startupCommand) val charset = ptyConnector.getCharset()
ptyConnector.write(terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))) ptyConnector.write(host.options.startupCommand.toByteArray(charset))
ptyConnector.write(
terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
.toByteArray(charset)
)
} }
} }
} }
@@ -116,6 +121,7 @@ abstract class PtyHostTerminalTab(
override fun dispose() { override fun dispose() {
stop() stop()
terminalPanel
super.dispose() super.dispose()

View File

@@ -1,5 +1,7 @@
package app.termora package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.actions.TabReconnectAction import app.termora.actions.TabReconnectAction
import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor
import app.termora.keyboardinteractive.TerminalUserInteraction import app.termora.keyboardinteractive.TerminalUserInteraction
@@ -27,11 +29,13 @@ import org.apache.sshd.common.session.SessionListener.Event
import org.apache.sshd.common.util.net.SshdSocketAddress import org.apache.sshd.common.util.net.SshdSocketAddress
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.EventObject
import javax.swing.JComponent import javax.swing.JComponent
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) { class SSHTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
companion object { companion object {
private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java) private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java)
} }
@@ -41,6 +45,9 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
private var sshClient: SshClient? = null private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null private var sshSession: ClientSession? = null
private var sshChannelShell: ChannelShell? = null private var sshChannelShell: ChannelShell? = null
private val terminalTabbedManager
get() = AnActionEvent(getJComponent(), StringUtils.EMPTY, EventObject(getJComponent()))
.getData(DataProviders.TerminalTabbedManager)
init { init {
terminalPanel.dropFiles = false terminalPanel.dropFiles = false
@@ -119,13 +126,25 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
override fun channelClosed(channel: Channel, reason: Throwable?) { override fun channelClosed(channel: Channel, reason: Throwable?) {
coroutineScope.launch(Dispatchers.Swing) { coroutineScope.launch(Dispatchers.Swing) {
terminal.write("\r\n\r\n${ControlCharacters.ESC}[31m") terminal.write("\r\n\r\n${ControlCharacters.ESC}[31m")
terminal.write("Channel has been disconnected.") terminal.write(I18n.getString("termora.terminal.channel-disconnected"))
if (reconnectShortcut is KeyShortcut) { if (reconnectShortcut is KeyShortcut) {
terminal.write(" Type $reconnectShortcut to reconnect.") terminal.write(
I18n.getString(
"termora.terminal.channel-reconnect",
reconnectShortcut.toString()
)
)
} }
terminal.write("\r\n") terminal.write("\r\n")
terminal.write("${ControlCharacters.ESC}[0m") terminal.write("${ControlCharacters.ESC}[0m")
terminalModel.setData(DataKey.ShowCursor, false) terminalModel.setData(DataKey.ShowCursor, false)
if (Database.getDatabase().terminal.autoCloseTabWhenDisconnected) {
terminalTabbedManager?.let { manager ->
SwingUtilities.invokeLater {
manager.closeTerminalTab(this@SSHTerminalTab, true)
}
}
}
} }
} }
}) })

View File

@@ -0,0 +1,61 @@
package app.termora
import app.termora.terminal.PtyConnector
import com.fazecast.jSerialComm.SerialPort
import com.fazecast.jSerialComm.SerialPortDataListener
import com.fazecast.jSerialComm.SerialPortEvent
import java.nio.charset.Charset
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
class SerialPortPtyConnector(
private val serialPort: SerialPort,
private val charset: Charset = Charsets.UTF_8
) : PtyConnector, SerialPortDataListener {
private val queue = LinkedBlockingQueue<Char>()
init {
serialPort.addDataListener(this)
}
override fun read(buffer: CharArray): Int {
buffer[0] = queue.poll(1, TimeUnit.SECONDS) ?: return 0
return 1
}
override fun write(buffer: ByteArray, offset: Int, len: Int) {
serialPort.writeBytes(buffer, len, offset)
}
override fun resize(rows: Int, cols: Int) {
}
override fun waitFor(): Int {
return 0
}
override fun close() {
queue.clear()
serialPort.closePort()
}
override fun getListeningEvents(): Int {
return SerialPort.LISTENING_EVENT_DATA_RECEIVED
}
override fun serialEvent(event: SerialPortEvent) {
if (event.eventType == SerialPort.LISTENING_EVENT_DATA_RECEIVED) {
val data = event.receivedData
if (data.isEmpty()) return
for (c in String(data, charset).toCharArray()) {
queue.add(c)
}
}
}
override fun getCharset(): Charset {
return charset
}
}

View File

@@ -0,0 +1,21 @@
package app.termora
import app.termora.terminal.PtyConnector
import org.apache.commons.io.Charsets
import java.nio.charset.StandardCharsets
import javax.swing.Icon
class SerialTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
override suspend fun openPtyConnector(): PtyConnector {
val serialPort = Serials.openPort(host)
return SerialPortPtyConnector(
serialPort,
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8)
)
}
override fun getIcon(): Icon {
return Icons.plugin
}
}

View File

@@ -0,0 +1,38 @@
package app.termora
import com.fazecast.jSerialComm.SerialPort
object Serials {
fun openPort(host: Host): SerialPort {
val serialComm = host.options.serialComm
val serialPort = SerialPort.getCommPort(serialComm.port)
serialPort.setBaudRate(serialComm.baudRate)
serialPort.setNumDataBits(serialComm.dataBits)
when (serialComm.parity) {
SerialCommParity.None -> serialPort.setParity(SerialPort.NO_PARITY)
SerialCommParity.Mark -> serialPort.setParity(SerialPort.MARK_PARITY)
SerialCommParity.Even -> serialPort.setParity(SerialPort.EVEN_PARITY)
SerialCommParity.Odd -> serialPort.setParity(SerialPort.ODD_PARITY)
SerialCommParity.Space -> serialPort.setParity(SerialPort.SPACE_PARITY)
}
when (serialComm.stopBits) {
"1" -> serialPort.setNumStopBits(SerialPort.ONE_STOP_BIT)
"1.5" -> serialPort.setNumStopBits(SerialPort.ONE_POINT_FIVE_STOP_BITS)
"2" -> serialPort.setNumStopBits(SerialPort.TWO_STOP_BITS)
}
when (serialComm.flowControl) {
SerialCommFlowControl.None -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED)
SerialCommFlowControl.RTS_CTS -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_RTS_ENABLED or SerialPort.FLOW_CONTROL_CTS_ENABLED)
SerialCommFlowControl.XON_XOFF -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_XONXOFF_IN_ENABLED or SerialPort.FLOW_CONTROL_XONXOFF_OUT_ENABLED)
}
if (!serialPort.openPort()) {
throw IllegalStateException("Open serial port [${serialComm.port}] failed")
}
return serialPort
}
}

View File

@@ -4,9 +4,14 @@ import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson import app.termora.Application.ohMyJson
import app.termora.actions.AnAction import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.highlight.KeywordHighlight
import app.termora.highlight.KeywordHighlightManager import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.Keymap
import app.termora.keymap.KeymapManager
import app.termora.keymap.KeymapPanel import app.termora.keymap.KeymapPanel
import app.termora.keymgr.KeyManager import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.macro.MacroManager import app.termora.macro.MacroManager
import app.termora.native.FileChooser import app.termora.native.FileChooser
import app.termora.sync.SyncConfig import app.termora.sync.SyncConfig
@@ -17,8 +22,10 @@ import app.termora.terminal.CursorStyle
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.terminal.panel.TerminalPanel import app.termora.terminal.panel.TerminalPanel
import cash.z.ecc.android.bip39.Mnemonics import cash.z.ecc.android.bip39.Mnemonics
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.components.* import com.formdev.flatlaf.extras.components.*
import com.formdev.flatlaf.util.FontUtils
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout import com.jgoodies.forms.layout.FormLayout
@@ -27,17 +34,17 @@ import com.sun.jna.LastErrorException
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.*
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils import org.apache.commons.lang3.time.DateFormatUtils
import org.jdesktop.swingx.JXEditorPane import org.jdesktop.swingx.JXEditorPane
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
import java.awt.event.ItemEvent import java.awt.event.ItemEvent
import java.io.File import java.io.File
@@ -54,6 +61,11 @@ import kotlin.time.Duration.Companion.milliseconds
class SettingsOptionsPane : OptionsPane() { class SettingsOptionsPane : OptionsPane() {
private val owner get() = SwingUtilities.getWindowAncestor(this@SettingsOptionsPane) private val owner get() = SwingUtilities.getWindowAncestor(this@SettingsOptionsPane)
private val database get() = Database.getDatabase() private val database get() = Database.getDatabase()
private val hostManager get() = HostManager.getInstance()
private val keymapManager get() = KeymapManager.getInstance()
private val macroManager get() = MacroManager.getInstance()
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
private val keyManager get() = KeyManager.getInstance()
companion object { companion object {
private val log = LoggerFactory.getLogger(SettingsOptionsPane::class.java) private val log = LoggerFactory.getLogger(SettingsOptionsPane::class.java)
@@ -288,12 +300,14 @@ class SettingsOptionsPane : OptionsPane() {
private inner class TerminalOption : JPanel(BorderLayout()), Option { private inner class TerminalOption : JPanel(BorderLayout()), Option {
private val cursorStyleComboBox = FlatComboBox<CursorStyle>() private val cursorStyleComboBox = FlatComboBox<CursorStyle>()
private val debugComboBox = YesOrNoComboBox() private val debugComboBox = YesOrNoComboBox()
private val beepComboBox = YesOrNoComboBox()
private val fontComboBox = FlatComboBox<String>() private val fontComboBox = FlatComboBox<String>()
private val shellComboBox = FlatComboBox<String>() private val shellComboBox = FlatComboBox<String>()
private val maxRowsTextField = IntSpinner(0, 0) private val maxRowsTextField = IntSpinner(0, 0)
private val fontSizeTextField = IntSpinner(0, 9, 99) private val fontSizeTextField = IntSpinner(0, 9, 99)
private val terminalSetting get() = Database.getDatabase().terminal private val terminalSetting get() = Database.getDatabase().terminal
private val selectCopyComboBox = YesOrNoComboBox() private val selectCopyComboBox = YesOrNoComboBox()
private val autoCloseTabComboBox = YesOrNoComboBox()
init { init {
initView() initView()
@@ -309,6 +323,13 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
autoCloseTabComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.autoCloseTabWhenDisconnected = autoCloseTabComboBox.selectedItem as Boolean
}
}
autoCloseTabComboBox.toolTipText = I18n.getString("termora.settings.terminal.auto-close-tab-description")
selectCopyComboBox.addItemListener { e -> selectCopyComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) { if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.selectCopy = selectCopyComboBox.selectedItem as Boolean terminalSetting.selectCopy = selectCopyComboBox.selectedItem as Boolean
@@ -345,6 +366,13 @@ class SettingsOptionsPane : OptionsPane() {
} }
beepComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.beep = beepComboBox.selectedItem as Boolean
}
}
shellComboBox.addItemListener { shellComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) { if (it.stateChange == ItemEvent.SELECTED) {
terminalSetting.localShell = shellComboBox.selectedItem as String terminalSetting.localShell = shellComboBox.selectedItem as String
@@ -379,6 +407,28 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
fontComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
if (value is String) {
return super.getListCellRendererComponent(
list,
"<html><font face='$value'>$value</font></html>",
index,
isSelected,
cellHasFocus
)
}
return super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
}
}
fontComboBox.maximumSize = fontComboBox.preferredSize
cursorStyleComboBox.addItem(CursorStyle.Block) cursorStyleComboBox.addItem(CursorStyle.Block)
cursorStyleComboBox.addItem(CursorStyle.Bar) cursorStyleComboBox.addItem(CursorStyle.Bar)
cursorStyleComboBox.addItem(CursorStyle.Underline) cursorStyleComboBox.addItem(CursorStyle.Underline)
@@ -391,13 +441,40 @@ class SettingsOptionsPane : OptionsPane() {
shellComboBox.selectedItem = terminalSetting.localShell shellComboBox.selectedItem = terminalSetting.localShell
fontComboBox.addItem("JetBrains Mono") val fonts = linkedSetOf(
fontComboBox.addItem("Source Code Pro") "JetBrains Mono",
"Source Code Pro",
"Monospaced",
"Andale Mono",
"Ayuthaya",
"Courier New",
"Droid Sans Mono",
"Fira Code",
"PCMyungjo",
"Menlo",
"Monaco",
"Osaka",
"PT Mono",
"SimSong",
)
for (font in FontUtils.getAllFonts()) {
if (fonts.contains(font.family)) {
continue
}
fonts.remove(font.family)
}
for (font in fonts) {
fontComboBox.addItem(font)
}
fontComboBox.selectedItem = terminalSetting.font fontComboBox.selectedItem = terminalSetting.font
debugComboBox.selectedItem = terminalSetting.debug debugComboBox.selectedItem = terminalSetting.debug
beepComboBox.selectedItem = terminalSetting.beep
cursorStyleComboBox.selectedItem = terminalSetting.cursor cursorStyleComboBox.selectedItem = terminalSetting.cursor
selectCopyComboBox.selectedItem = terminalSetting.selectCopy selectCopyComboBox.selectedItem = terminalSetting.selectCopy
autoCloseTabComboBox.selectedItem = terminalSetting.autoCloseTabWhenDisconnected
} }
override fun getIcon(isSelected: Boolean): Icon { override fun getIcon(isSelected: Boolean): Icon {
@@ -415,9 +492,14 @@ class SettingsOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent { private fun getCenterComponent(): JComponent {
val layout = FormLayout( val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow", "left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
) )
val beepBtn = JButton(Icons.run)
beepBtn.isFocusable = false
beepBtn.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON)
beepBtn.addActionListener { Toolkit.getDefaultToolkit().beep() }
var rows = 1 var rows = 1
val step = 2 val step = 2
val panel = FormBuilder.create().layout(layout) val panel = FormBuilder.create().layout(layout)
@@ -430,10 +512,15 @@ class SettingsOptionsPane : OptionsPane() {
.add(maxRowsTextField).xy(3, rows).apply { rows += step } .add(maxRowsTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.debug")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.debug")}:").xy(1, rows)
.add(debugComboBox).xy(3, rows).apply { rows += step } .add(debugComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.beep")}:").xy(1, rows)
.add(beepComboBox).xy(3, rows)
.add(beepBtn).xy(5, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.select-copy")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.select-copy")}:").xy(1, rows)
.add(selectCopyComboBox).xy(3, rows).apply { rows += step } .add(selectCopyComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows)
.add(cursorStyleComboBox).xy(3, rows).apply { rows += step } .add(cursorStyleComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.auto-close-tab")}:").xy(1, rows)
.add(autoCloseTabComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.local-shell")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.local-shell")}:").xy(1, rows)
.add(shellComboBox).xyw(3, rows, 5) .add(shellComboBox).xyw(3, rows, 5)
.build() .build()
@@ -451,6 +538,7 @@ class SettingsOptionsPane : OptionsPane() {
val domainTextField = OutlineTextField(255) val domainTextField = OutlineTextField(255)
val uploadConfigButton = JButton(I18n.getString("termora.settings.sync.push"), Icons.upload) val uploadConfigButton = JButton(I18n.getString("termora.settings.sync.push"), Icons.upload)
val exportConfigButton = JButton(I18n.getString("termora.settings.sync.export"), Icons.export) val exportConfigButton = JButton(I18n.getString("termora.settings.sync.export"), Icons.export)
val importConfigButton = JButton(I18n.getString("termora.settings.sync.import"), Icons.import)
val downloadConfigButton = JButton(I18n.getString("termora.settings.sync.pull"), Icons.download) val downloadConfigButton = JButton(I18n.getString("termora.settings.sync.pull"), Icons.download)
val lastSyncTimeLabel = JLabel() val lastSyncTimeLabel = JLabel()
val sync get() = database.sync val sync get() = database.sync
@@ -492,12 +580,6 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
if (typeComboBox.selectedItem == SyncType.Gitee) {
gistTextField.trailingComponent = null
} else {
gistTextField.trailingComponent = visitGistBtn
}
removeAll() removeAll()
add(getCenterComponent(), BorderLayout.CENTER) add(getCenterComponent(), BorderLayout.CENTER)
revalidate() revalidate()
@@ -562,6 +644,7 @@ class SettingsOptionsPane : OptionsPane() {
} }
exportConfigButton.addActionListener { export() } exportConfigButton.addActionListener { export() }
importConfigButton.addActionListener { import() }
keysCheckBox.addActionListener { refreshButtons() } keysCheckBox.addActionListener { refreshButtons() }
hostsCheckBox.addActionListener { refreshButtons() } hostsCheckBox.addActionListener { refreshButtons() }
@@ -578,6 +661,7 @@ class SettingsOptionsPane : OptionsPane() {
|| keywordHighlightsCheckBox.isSelected || keywordHighlightsCheckBox.isSelected
uploadConfigButton.isEnabled = downloadConfigButton.isEnabled uploadConfigButton.isEnabled = downloadConfigButton.isEnabled
exportConfigButton.isEnabled = downloadConfigButton.isEnabled exportConfigButton.isEnabled = downloadConfigButton.isEnabled
importConfigButton.isEnabled = downloadConfigButton.isEnabled
} }
private fun export() { private fun export() {
@@ -593,6 +677,109 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
private fun import() {
val fileChooser = FileChooser()
fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY
fileChooser.osxAllowedFileTypes = listOf("json")
fileChooser.win32Filters.add(Pair("JSON files", listOf("json")))
fileChooser.showOpenDialog(owner).thenAccept { files ->
if (files.isNotEmpty()) {
SwingUtilities.invokeLater { importFromFile(files.first()) }
}
}
}
private fun importFromFile(file: File) {
if (!file.exists()) {
return
}
val ranges = getSyncConfig().ranges
if (ranges.isEmpty()) {
return
}
// 最大 100MB
if (file.length() >= 1024 * 1024 * 100) {
OptionPane.showMessageDialog(
owner, I18n.getString("termora.settings.sync.import.file-too-large"),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
val text = file.readText()
val jsonResult = ohMyJson.runCatching { decodeFromString<JsonObject>(text) }
if (jsonResult.isFailure) {
val e = jsonResult.exceptionOrNull() ?: return
OptionPane.showMessageDialog(
owner, ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
val json = jsonResult.getOrNull() ?: return
if (ranges.contains(SyncRange.Hosts)) {
val hosts = json["hosts"]
if (hosts is JsonArray) {
ohMyJson.runCatching { decodeFromJsonElement<List<Host>>(hosts.jsonArray) }.onSuccess {
for (host in it) {
hostManager.addHost(host)
}
}
}
}
if (ranges.contains(SyncRange.KeyPairs)) {
val keyPairs = json["keyPairs"]
if (keyPairs is JsonArray) {
ohMyJson.runCatching { decodeFromJsonElement<List<OhKeyPair>>(keyPairs.jsonArray) }.onSuccess {
for (keyPair in it) {
keyManager.addOhKeyPair(keyPair)
}
}
}
}
if (ranges.contains(SyncRange.KeywordHighlights)) {
val keywordHighlights = json["keywordHighlights"]
if (keywordHighlights is JsonArray) {
ohMyJson.runCatching { decodeFromJsonElement<List<KeywordHighlight>>(keywordHighlights.jsonArray) }
.onSuccess {
for (keyPair in it) {
keywordHighlightManager.addKeywordHighlight(keyPair)
}
}
}
}
if (ranges.contains(SyncRange.Macros)) {
val macros = json["macros"]
if (macros is JsonArray) {
ohMyJson.runCatching { decodeFromJsonElement<List<Macro>>(macros.jsonArray) }.onSuccess {
for (macro in it) {
macroManager.addMacro(macro)
}
}
}
}
if (ranges.contains(SyncRange.Keymap)) {
val keymaps = json["keymaps"]
if (keymaps is JsonArray) {
for (keymap in keymaps.jsonArray.mapNotNull { Keymap.fromJSON(it.jsonObject) }) {
keymapManager.addKeymap(keymap)
}
}
}
OptionPane.showMessageDialog(
owner, I18n.getString("termora.settings.sync.import.successful"),
messageType = JOptionPane.INFORMATION_MESSAGE
)
}
private fun exportText(file: File) { private fun exportText(file: File) {
val syncConfig = getSyncConfig() val syncConfig = getSyncConfig()
val text = ohMyJson.encodeToString(buildJsonObject { val text = ohMyJson.encodeToString(buildJsonObject {
@@ -603,21 +790,29 @@ class SettingsOptionsPane : OptionsPane() {
put("os", SystemUtils.OS_NAME) put("os", SystemUtils.OS_NAME)
put("exportDateHuman", DateFormatUtils.ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.format(Date(now))) put("exportDateHuman", DateFormatUtils.ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.format(Date(now)))
if (syncConfig.ranges.contains(SyncRange.Hosts)) { if (syncConfig.ranges.contains(SyncRange.Hosts)) {
put("hosts", ohMyJson.encodeToJsonElement(HostManager.getInstance().hosts())) put("hosts", ohMyJson.encodeToJsonElement(hostManager.hosts()))
} }
if (syncConfig.ranges.contains(SyncRange.KeyPairs)) { if (syncConfig.ranges.contains(SyncRange.KeyPairs)) {
put("keyPairs", ohMyJson.encodeToJsonElement(KeyManager.getInstance().getOhKeyPairs())) put("keyPairs", ohMyJson.encodeToJsonElement(keyManager.getOhKeyPairs()))
} }
if (syncConfig.ranges.contains(SyncRange.KeywordHighlights)) { if (syncConfig.ranges.contains(SyncRange.KeywordHighlights)) {
put( put(
"keywordHighlights", "keywordHighlights",
ohMyJson.encodeToJsonElement(KeywordHighlightManager.getInstance().getKeywordHighlights()) ohMyJson.encodeToJsonElement(keywordHighlightManager.getKeywordHighlights())
) )
} }
if (syncConfig.ranges.contains(SyncRange.Macros)) { if (syncConfig.ranges.contains(SyncRange.Macros)) {
put( put(
"macros", "macros",
ohMyJson.encodeToJsonElement(MacroManager.getInstance().getMacros()) ohMyJson.encodeToJsonElement(macroManager.getMacros())
)
}
if (syncConfig.ranges.contains(SyncRange.Keymap)) {
val keymaps = keymapManager.getKeymaps().filter { !it.isReadonly }
.map { it.toJSONObject() }
put(
"keymaps",
ohMyJson.encodeToJsonElement(keymaps)
) )
} }
put("settings", buildJsonObject { put("settings", buildJsonObject {
@@ -662,6 +857,7 @@ class SettingsOptionsPane : OptionsPane() {
) )
} }
@Suppress("DuplicatedCode")
private suspend fun pushOrPull(push: Boolean) { private suspend fun pushOrPull(push: Boolean) {
if (typeComboBox.selectedItem == SyncType.GitLab) { if (typeComboBox.selectedItem == SyncType.GitLab) {
@@ -717,6 +913,7 @@ class SettingsOptionsPane : OptionsPane() {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
exportConfigButton.isEnabled = false exportConfigButton.isEnabled = false
importConfigButton.isEnabled = false
downloadConfigButton.isEnabled = false downloadConfigButton.isEnabled = false
uploadConfigButton.isEnabled = false uploadConfigButton.isEnabled = false
typeComboBox.isEnabled = false typeComboBox.isEnabled = false
@@ -752,6 +949,7 @@ class SettingsOptionsPane : OptionsPane() {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
downloadConfigButton.isEnabled = true downloadConfigButton.isEnabled = true
exportConfigButton.isEnabled = true exportConfigButton.isEnabled = true
importConfigButton.isEnabled = true
uploadConfigButton.isEnabled = true uploadConfigButton.isEnabled = true
keysCheckBox.isEnabled = true keysCheckBox.isEnabled = true
hostsCheckBox.isEnabled = true hostsCheckBox.isEnabled = true
@@ -813,6 +1011,7 @@ class SettingsOptionsPane : OptionsPane() {
typeComboBox.addItem(SyncType.GitHub) typeComboBox.addItem(SyncType.GitHub)
typeComboBox.addItem(SyncType.GitLab) typeComboBox.addItem(SyncType.GitLab)
typeComboBox.addItem(SyncType.Gitee) typeComboBox.addItem(SyncType.Gitee)
typeComboBox.addItem(SyncType.WebDAV)
hostsCheckBox.isFocusable = false hostsCheckBox.isFocusable = false
keysCheckBox.isFocusable = false keysCheckBox.isFocusable = false
@@ -831,7 +1030,31 @@ class SettingsOptionsPane : OptionsPane() {
tokenTextField.text = sync.token tokenTextField.text = sync.token
domainTextField.trailingComponent = JButton(Icons.externalLink).apply { domainTextField.trailingComponent = JButton(Icons.externalLink).apply {
addActionListener { addActionListener {
if (typeComboBox.selectedItem == SyncType.GitLab) {
Application.browse(URI.create("https://docs.gitlab.com/ee/api/snippets.html")) Application.browse(URI.create("https://docs.gitlab.com/ee/api/snippets.html"))
} else if (typeComboBox.selectedItem == SyncType.WebDAV) {
val url = domainTextField.text
if (url.isNullOrBlank()) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.settings.sync.webdav.help")
)
} else {
val uri = URI.create(url)
val sb = StringBuilder()
sb.append(uri.scheme).append("://")
if (tokenTextField.password.isNotEmpty() && gistTextField.text.isNotBlank()) {
sb.append(String(tokenTextField.password)).append(":").append(gistTextField.text)
sb.append('@')
}
sb.append(uri.authority).append(uri.path)
if (!uri.query.isNullOrBlank()) {
sb.append('?').append(uri.query)
}
Application.browse(URI.create(sb.toString()))
}
}
} }
} }
@@ -841,12 +1064,15 @@ class SettingsOptionsPane : OptionsPane() {
tokenTextField.trailingComponent = if (tokenTextField.password.isEmpty()) getTokenBtn else null tokenTextField.trailingComponent = if (tokenTextField.password.isEmpty()) getTokenBtn else null
if (typeComboBox.selectedItem == SyncType.GitLab) {
if (domainTextField.text.isBlank()) { if (domainTextField.text.isBlank()) {
if (typeComboBox.selectedItem == SyncType.GitLab) {
domainTextField.text = StringUtils.defaultIfBlank(sync.domain, "https://gitlab.com/api") domainTextField.text = StringUtils.defaultIfBlank(sync.domain, "https://gitlab.com/api")
} else if (typeComboBox.selectedItem == SyncType.WebDAV) {
domainTextField.text = sync.domain
} }
} }
val lastSyncTime = sync.lastSyncTime val lastSyncTime = sync.lastSyncTime
lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${ lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${
if (lastSyncTime > 0) DateFormatUtils.format( if (lastSyncTime > 0) DateFormatUtils.format(
@@ -892,29 +1118,50 @@ class SettingsOptionsPane : OptionsPane() {
var rows = 1 var rows = 1
val step = 2 val step = 2
val builder = FormBuilder.create().layout(layout).debug(false); val builder = FormBuilder.create().layout(layout).debug(false)
val box = Box.createHorizontalBox() val box = Box.createHorizontalBox()
box.add(typeComboBox) box.add(typeComboBox)
if (typeComboBox.selectedItem == SyncType.GitLab) { if (typeComboBox.selectedItem == SyncType.GitLab || typeComboBox.selectedItem == SyncType.WebDAV) {
box.add(Box.createHorizontalStrut(4)) box.add(Box.createHorizontalStrut(4))
box.add(domainTextField) box.add(domainTextField)
} }
builder.add("${I18n.getString("termora.settings.sync.type")}:").xy(1, rows) builder.add("${I18n.getString("termora.settings.sync.type")}:").xy(1, rows)
.add(box).xy(3, rows).apply { rows += step } .add(box).xy(3, rows).apply { rows += step }
builder.add("${I18n.getString("termora.settings.sync.token")}:").xy(1, rows) val isWebDAV = typeComboBox.selectedItem == SyncType.WebDAV
.add(tokenTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.sync.gist")}:").xy(1, rows) val tokenText = if (isWebDAV) {
.add(gistTextField).xy(3, rows).apply { rows += step } I18n.getString("termora.new-host.general.username")
} else {
I18n.getString("termora.settings.sync.token")
}
val gistText = if (isWebDAV) {
I18n.getString("termora.new-host.general.password")
} else {
I18n.getString("termora.settings.sync.gist")
}
if (typeComboBox.selectedItem == SyncType.Gitee || isWebDAV) {
gistTextField.trailingComponent = null
} else {
gistTextField.trailingComponent = visitGistBtn
}
builder.add("${tokenText}:").xy(1, rows)
.add(if (isWebDAV) gistTextField else tokenTextField).xy(3, rows).apply { rows += step }
.add("${gistText}:").xy(1, rows)
.add(if (isWebDAV) tokenTextField else gistTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows) .add("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows)
.add(rangeBox).xy(3, rows).apply { rows += step } .add(rangeBox).xy(3, rows).apply { rows += step }
// Sync buttons // Sync buttons
.add( .add(
FormBuilder.create() FormBuilder.create()
.layout(FormLayout("left:pref, $formMargin, left:pref, $formMargin, left:pref", "pref")) .layout(FormLayout("pref, 2dlu, pref, 2dlu, pref, 2dlu, pref", "pref"))
.add(uploadConfigButton).xy(1, 1) .add(uploadConfigButton).xy(1, 1)
.add(downloadConfigButton).xy(3, 1) .add(downloadConfigButton).xy(3, 1)
.add(exportConfigButton).xy(5, 1) .add(exportConfigButton).xy(5, 1)
.add(importConfigButton).xy(7, 1)
.build() .build()
).xy(3, rows, "center, fill").apply { rows += step } ).xy(3, rows, "center, fill").apply { rows += step }
.add(lastSyncTimeLabel).xy(3, rows, "center, fill").apply { rows += step } .add(lastSyncTimeLabel).xy(3, rows, "center, fill").apply { rows += step }
@@ -1009,8 +1256,6 @@ class SettingsOptionsPane : OptionsPane() {
private val tip = FlatLabel() private val tip = FlatLabel()
private val safeBtn = FlatButton() private val safeBtn = FlatButton()
private val doorman get() = Doorman.getInstance() private val doorman get() = Doorman.getInstance()
private val hostManager get() = HostManager.getInstance()
private val keyManager get() = KeyManager.getInstance()
init { init {
initView() initView()

View File

@@ -6,10 +6,13 @@ import org.apache.sshd.client.ClientBuilder
import org.apache.sshd.client.SshClient import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ChannelShell import org.apache.sshd.client.channel.ChannelShell
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
import org.apache.sshd.client.kex.DHGClient
import org.apache.sshd.client.session.ClientSession import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.SshException import org.apache.sshd.common.SshException
import org.apache.sshd.common.channel.PtyChannelConfiguration import org.apache.sshd.common.channel.PtyChannelConfiguration
import org.apache.sshd.common.global.KeepAliveHandler import org.apache.sshd.common.global.KeepAliveHandler
import org.apache.sshd.common.kex.BuiltinDHFactories
import org.apache.sshd.common.keyprovider.KeyIdentityProvider
import org.apache.sshd.common.util.net.SshdSocketAddress import org.apache.sshd.common.util.net.SshdSocketAddress
import org.apache.sshd.core.CoreModuleProperties import org.apache.sshd.core.CoreModuleProperties
import org.apache.sshd.server.forward.AcceptAllForwardingFilter import org.apache.sshd.server.forward.AcceptAllForwardingFilter
@@ -133,6 +136,18 @@ object SshClients {
builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE)) builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE))
.factory { JGitSshClient() } .factory { JGitSshClient() }
val keyExchangeFactories = ClientBuilder.setUpDefaultKeyExchanges(true).toMutableList()
// https://github.com/TermoraDev/termora/issues/123
keyExchangeFactories.addAll(
listOf(
DHGClient.newFactory(BuiltinDHFactories.dhg1),
DHGClient.newFactory(BuiltinDHFactories.dhg14),
DHGClient.newFactory(BuiltinDHFactories.dhgex),
)
)
builder.keyExchangeFactories(keyExchangeFactories)
if (host.tunnelings.isEmpty() && host.options.jumpHosts.isEmpty()) { if (host.tunnelings.isEmpty() && host.options.jumpHosts.isEmpty()) {
builder.forwardingFilter(RejectAllForwardingFilter.INSTANCE) builder.forwardingFilter(RejectAllForwardingFilter.INSTANCE)
} else { } else {
@@ -142,8 +157,15 @@ object SshClients {
builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY) builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY)
val sshClient = builder.build() as JGitSshClient val sshClient = builder.build() as JGitSshClient
// https://github.com/TermoraDev/termora/issues/180
// JGit 会尝试读取本地的私钥或缓存的私钥
sshClient.keyIdentityProvider = KeyIdentityProvider { mutableListOf() }
val heartbeatInterval = max(host.options.heartbeatInterval, 3) val heartbeatInterval = max(host.options.heartbeatInterval, 3)
CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong())) CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong()))
CoreModuleProperties.ALLOW_DHG1_KEX_FALLBACK.set(sshClient, true)
sshClient.setKeyPasswordProviderFactory { IdentityPasswordProvider(CredentialsProvider.getDefault()) } sshClient.setKeyPasswordProviderFactory { IdentityPasswordProvider(CredentialsProvider.getDefault()) }
if (host.proxy.type != ProxyType.No) { if (host.proxy.type != ProxyType.No) {

View File

@@ -21,6 +21,11 @@ class TerminalFactory private constructor() : Disposable {
// terminal logger listener // terminal logger listener
terminal.getTerminalModel().addDataListener(TerminalLoggerDataListener(terminal)) terminal.getTerminalModel().addDataListener(TerminalLoggerDataListener(terminal))
terminal.addTerminalListener(object : TerminalListener {
override fun onClose(terminal: Terminal) {
terminals.remove(terminal)
}
})
terminals.add(terminal) terminals.add(terminal)
return terminal return terminal
@@ -51,6 +56,11 @@ class TerminalFactory private constructor() : Disposable {
return colorPalette return colorPalette
} }
override fun bell() {
if (config.beep) {
super.bell()
}
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(key: DataKey<T>): T { override fun <T : Any> getData(key: DataKey<T>): T {

View File

@@ -23,6 +23,11 @@ class TerminalPanelFactory {
terminalPanel.addTerminalPaintListener(MultipleTerminalListener()) terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance()) terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance()) terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
Disposer.register(terminalPanel, object : Disposable {
override fun dispose() {
terminalPanels.remove(terminalPanel)
}
})
terminalPanels.add(terminalPanel) terminalPanels.add(terminalPanel)
return terminalPanel return terminalPanel
} }
@@ -47,4 +52,8 @@ class TerminalPanelFactory {
} }
} }
fun removeTerminalPanel(terminalPanel: TerminalPanel) {
terminalPanels.remove(terminalPanel)
}
} }

View File

@@ -10,14 +10,19 @@ import app.termora.transport.TransportPanel
import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatTabbedPane import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils
import java.awt.* import java.awt.*
import java.awt.event.AWTEventListener import java.awt.event.AWTEventListener
import java.awt.event.ActionEvent import java.awt.event.ActionEvent
import java.awt.event.MouseAdapter import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.beans.PropertyChangeListener import java.beans.PropertyChangeListener
import javax.swing.* import java.util.*
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
import javax.swing.SwingUtilities
import kotlin.math.min import kotlin.math.min
class TerminalTabbed( class TerminalTabbed(
@@ -30,7 +35,7 @@ class TerminalTabbed(
private val toolbar = termoraToolBar.getJToolBar() private val toolbar = termoraToolBar.getJToolBar()
private val actionManager = ActionManager.getInstance() private val actionManager = ActionManager.getInstance()
private val dataProviderSupport = DataProviderSupport() private val dataProviderSupport = DataProviderSupport()
private val titleProperty = UUID.randomUUID().toSimpleString()
private val iconListener = PropertyChangeListener { e -> private val iconListener = PropertyChangeListener { e ->
val source = e.source val source = e.source
if (e.propertyName == "icon" && source is TerminalTab) { if (e.propertyName == "icon" && source is TerminalTab) {
@@ -52,9 +57,6 @@ class TerminalTabbed(
tabbedPane.isTabsClosable = true tabbedPane.isTabsClosable = true
tabbedPane.tabType = FlatTabbedPane.TabType.card tabbedPane.tabType = FlatTabbedPane.TabType.card
tabbedPane.styleMap = mapOf(
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground")
)
tabbedPane.trailingComponent = toolbar tabbedPane.trailingComponent = toolbar
add(tabbedPane, BorderLayout.CENTER) add(tabbedPane, BorderLayout.CENTER)
@@ -190,16 +192,16 @@ class TerminalTabbed(
// 修改名称 // 修改名称
val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename")) val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename"))
rename.addActionListener { rename.addActionListener {
val index = tabbedPane.selectedIndex if (tabIndex > 0) {
if (index > 0) {
val dialog = InputDialog( val dialog = InputDialog(
SwingUtilities.getWindowAncestor(this), SwingUtilities.getWindowAncestor(this),
title = rename.text, title = rename.text,
text = tabbedPane.getTitleAt(index), text = tabbedPane.getTitleAt(tabIndex),
) )
val text = dialog.getText() val text = dialog.getText()
if (!text.isNullOrBlank()) { if (!text.isNullOrBlank()) {
tabbedPane.setTitleAt(index, text) tabbedPane.setTitleAt(tabIndex, text)
c.putClientProperty(titleProperty, text)
} }
} }
@@ -276,9 +278,8 @@ class TerminalTabbed(
popupMenu.addSeparator() popupMenu.addSeparator()
val reconnect = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.reconnect")) val reconnect = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.reconnect"))
reconnect.addActionListener { reconnect.addActionListener {
val index = tabbedPane.selectedIndex if (tabIndex > 0) {
if (index > 0) { tabs[tabIndex].reconnect()
tabs[index].reconnect()
} }
} }
@@ -289,18 +290,24 @@ class TerminalTabbed(
} }
fun addTab(tab: TerminalTab) { private fun addTab(index: Int, tab: TerminalTab) {
tabbedPane.addTab( val c = tab.getJComponent()
tab.getTitle(), val title = (c.getClientProperty(titleProperty) ?: tab.getTitle()).toString()
tabbedPane.insertTab(
title,
tab.getIcon(), tab.getIcon(),
tab.getJComponent() c,
StringUtils.EMPTY,
index
) )
c.putClientProperty(titleProperty, title)
// 监听 icons 变化 // 监听 icons 变化
tab.addPropertyChangeListener(iconListener) tab.addPropertyChangeListener(iconListener)
tabs.add(tab) tabs.add(index, tab)
tabbedPane.selectedIndex = tabbedPane.tabCount - 1 tabbedPane.selectedIndex = index
Disposer.register(this, tab) Disposer.register(this, tab)
} }
@@ -393,7 +400,11 @@ class TerminalTabbed(
} }
override fun addTerminalTab(tab: TerminalTab) { override fun addTerminalTab(tab: TerminalTab) {
addTab(tab) addTab(tabs.size, tab)
}
override fun addTerminalTab(index: Int, tab: TerminalTab) {
addTab(index, tab)
} }
override fun getSelectedTerminalTab(): TerminalTab? { override fun getSelectedTerminalTab(): TerminalTab? {
@@ -418,10 +429,10 @@ class TerminalTabbed(
} }
} }
override fun closeTerminalTab(tab: TerminalTab) { override fun closeTerminalTab(tab: TerminalTab, disposable: Boolean) {
for (i in 0 until tabs.size) { for (i in 0 until tabs.size) {
if (tabs[i] == tab) { if (tabs[i] == tab) {
removeTabAt(i, true) removeTabAt(i, disposable)
break break
} }
} }

View File

@@ -2,8 +2,9 @@ package app.termora
interface TerminalTabbedManager { interface TerminalTabbedManager {
fun addTerminalTab(tab: TerminalTab) fun addTerminalTab(tab: TerminalTab)
fun addTerminalTab(index: Int, tab: TerminalTab)
fun getSelectedTerminalTab(): TerminalTab? fun getSelectedTerminalTab(): TerminalTab?
fun getTerminalTabs(): List<TerminalTab> fun getTerminalTabs(): List<TerminalTab>
fun setSelectedTerminalTab(tab: TerminalTab) fun setSelectedTerminalTab(tab: TerminalTab)
fun closeTerminalTab(tab: TerminalTab) fun closeTerminalTab(tab: TerminalTab, disposable: Boolean = true)
} }

View File

@@ -101,7 +101,7 @@ class TermoraFrame : JFrame(), DataProvider {
} }
minimumSize = Dimension(640, 400) minimumSize = Dimension(640, 400)
terminalTabbed.addTab(welcomePanel) terminalTabbed.addTerminalTab(welcomePanel)
// macOS 要避开左边的控制栏 // macOS 要避开左边的控制栏
if (SystemInfo.isMacOS) { if (SystemInfo.isMacOS) {
@@ -116,6 +116,7 @@ class TermoraFrame : JFrame(), DataProvider {
Disposer.register(windowScope, terminalTabbed) Disposer.register(windowScope, terminalTabbed)
add(terminalTabbed) add(terminalTabbed)
dataProviderSupport.addData(DataProviders.TabbedPane, tabbedPane)
dataProviderSupport.addData(DataProviders.TermoraFrame, this) dataProviderSupport.addData(DataProviders.TermoraFrame, this)
dataProviderSupport.addData(DataProviders.WindowScope, windowScope) dataProviderSupport.addData(DataProviders.WindowScope, windowScope)
} }

View File

@@ -4,7 +4,8 @@ import com.formdev.flatlaf.util.SystemInfo
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.event.WindowAdapter import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent import java.awt.event.WindowEvent
import javax.swing.WindowConstants.DISPOSE_ON_CLOSE import javax.swing.JOptionPane
import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE
import kotlin.system.exitProcess import kotlin.system.exitProcess
class TermoraFrameManager { class TermoraFrameManager {
@@ -22,7 +23,7 @@ class TermoraFrameManager {
val frame = TermoraFrame() val frame = TermoraFrame()
registerCloseCallback(frame) registerCloseCallback(frame)
frame.title = if (SystemInfo.isLinux) null else Application.getName() frame.title = if (SystemInfo.isLinux) null else Application.getName()
frame.defaultCloseOperation = DISPOSE_ON_CLOSE frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE
frame.setSize(1280, 800) frame.setSize(1280, 800)
frame.setLocationRelativeTo(null) frame.setLocationRelativeTo(null)
return frame return frame
@@ -43,6 +44,21 @@ class TermoraFrameManager {
this@TermoraFrameManager.dispose() this@TermoraFrameManager.dispose()
} }
} }
override fun windowClosing(e: WindowEvent) {
if (ApplicationScope.windowScopes().size == 1) {
if (OptionPane.showConfirmDialog(
window,
I18n.getString("termora.quit-confirm", Application.getName()),
optionType = JOptionPane.YES_NO_OPTION,
) == JOptionPane.YES_OPTION
) {
window.dispose()
}
} else {
window.dispose()
}
}
}) })
} }

View File

@@ -99,6 +99,8 @@ class OutlinePasswordField(
styleMap = mapOf( styleMap = mapOf(
"showRevealButton" to true "showRevealButton" to true
) )
putClientProperty("JPasswordField.cutCopyAllowed", true)
} }
} }

View File

@@ -4,6 +4,7 @@ import app.termora.Application.ohMyJson
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import okhttp3.Request import okhttp3.Request
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.commonmark.node.BulletList import org.commonmark.node.BulletList
import org.commonmark.node.Heading import org.commonmark.node.Heading
import org.commonmark.node.Paragraph import org.commonmark.node.Paragraph
@@ -97,7 +98,14 @@ class UpdaterManager private constructor() {
} }
val parser = Parser.builder().build() val parser = Parser.builder().build()
val document = parser.parse("# ${name.trim()}\n${body.trim()}") val document = parser.parse(
"# 🎉 ${name.trim()} (${
DateFormatUtils.format(
publishedDate,
"yyyy-MM-dd"
)
}) \n${body.trim()}"
)
val renderer = HtmlRenderer.builder() val renderer = HtmlRenderer.builder()
.attributeProviderFactory { .attributeProviderFactory {
AttributeProvider { node, _, attributes -> AttributeProvider { node, _, attributes ->
@@ -106,7 +114,7 @@ class UpdaterManager private constructor() {
attributes["style"] = "margin: 5px 0;" attributes["style"] = "margin: 5px 0;"
} else if (node is BulletList) { } else if (node is BulletList) {
attributes["style"] = "margin: 0 20px;" attributes["style"] = "margin: 0 20px;"
}else if(node is Paragraph){ } else if (node is Paragraph) {
attributes["style"] = "margin: 0;" attributes["style"] = "margin: 0;"
} }
} }

View File

@@ -7,6 +7,7 @@ object DataProviders {
val Terminal = DataKey(app.termora.terminal.Terminal::class) val Terminal = DataKey(app.termora.terminal.Terminal::class)
val PtyConnector = DataKey(app.termora.terminal.PtyConnector::class) val PtyConnector = DataKey(app.termora.terminal.PtyConnector::class)
val TabbedPane = DataKey(app.termora.MyTabbedPane::class)
val TerminalTabbed = DataKey(app.termora.TerminalTabbed::class) val TerminalTabbed = DataKey(app.termora.TerminalTabbed::class)
val TerminalTab = DataKey(app.termora.TerminalTab::class) val TerminalTab = DataKey(app.termora.TerminalTab::class)
val TerminalTabbedManager = DataKey(app.termora.TerminalTabbedManager::class) val TerminalTabbedManager = DataKey(app.termora.TerminalTabbedManager::class)

View File

@@ -1,9 +1,6 @@
package app.termora.actions package app.termora.actions
import app.termora.LocalTerminalTab import app.termora.*
import app.termora.OpenHostActionEvent
import app.termora.Protocol
import app.termora.SSHTerminalTab
class OpenHostAction : AnAction() { class OpenHostAction : AnAction() {
companion object { companion object {
@@ -18,9 +15,11 @@ class OpenHostAction : AnAction() {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
val windowScope = evt.getData(DataProviders.WindowScope) ?: return val windowScope = evt.getData(DataProviders.WindowScope) ?: return
val tab = if (evt.host.protocol == Protocol.SSH) val tab = when (evt.host.protocol) {
SSHTerminalTab(windowScope, evt.host) Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
else LocalTerminalTab(windowScope, evt.host) Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
else -> LocalTerminalTab(windowScope, evt.host)
}
terminalTabbedManager.addTerminalTab(tab) terminalTabbedManager.addTerminalTab(tab)
tab.start() tab.start()

View File

@@ -28,6 +28,7 @@ class TerminalUserInteraction(
prompt[i], prompt[i],
true true
) )
dialog.setLocationRelativeTo(owner)
dialog.title = instruction ?: name ?: StringUtils.EMPTY dialog.title = instruction ?: name ?: StringUtils.EMPTY
passwords[i] = dialog.getText() passwords[i] = dialog.getText()
if (passwords[i].isBlank()) { if (passwords[i].isBlank()) {

View File

@@ -1,9 +1,6 @@
package app.termora.keymap package app.termora.keymap
import app.termora.ApplicationScope import app.termora.*
import app.termora.Database
import app.termora.DialogWrapper
import app.termora.Disposable
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders import app.termora.actions.DataProviders
import app.termora.findeverywhere.FindEverywhereAction import app.termora.findeverywhere.FindEverywhereAction
@@ -12,34 +9,34 @@ import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.KeyEventDispatcher import java.awt.KeyEventDispatcher
import java.awt.KeyEventPostProcessor
import java.awt.KeyboardFocusManager import java.awt.KeyboardFocusManager
import java.awt.event.KeyEvent import java.awt.event.KeyEvent
import javax.swing.JComponent
import javax.swing.JDialog import javax.swing.JDialog
import javax.swing.KeyStroke import javax.swing.KeyStroke
import javax.swing.SwingUtilities
class KeymapManager private constructor() : Disposable { class KeymapManager private constructor() : Disposable {
companion object { companion object {
private val log = LoggerFactory.getLogger(KeymapManager::class.java) private val log = LoggerFactory.getLogger(KeymapManager::class.java)
const val PROCESS_GLOBAL_KEYMAP = "PROCESS_GLOBAL_KEYMAP"
fun getInstance(): KeymapManager { fun getInstance(): KeymapManager {
return ApplicationScope.forApplicationScope() return ApplicationScope.forApplicationScope()
.getOrCreate(KeymapManager::class) { KeymapManager() } .getOrCreate(KeymapManager::class) { KeymapManager() }
} }
} }
private val myKeyEventPostProcessor = MyKeyEventPostProcessor() private val keymapKeyEventDispatcher = KeymapKeyEventDispatcher()
private val myKeyEventDispatcher = MyKeyEventDispatcher() private val myKeyEventDispatcher = MyKeyEventDispatcher()
private val database get() = Database.getDatabase() private val database get() = Database.getDatabase()
private val properties get() = database.properties
private val keymaps = linkedMapOf<String, Keymap>() private val keymaps = linkedMapOf<String, Keymap>()
private val activeKeymap get() = database.properties.getString("Keymap.Active") private val activeKeymap get() = properties.getString("Keymap.Active")
private val keyboardFocusManager by lazy { KeyboardFocusManager.getCurrentKeyboardFocusManager() } private val keyboardFocusManager by lazy { KeyboardFocusManager.getCurrentKeyboardFocusManager() }
init { init {
keyboardFocusManager.addKeyEventPostProcessor(myKeyEventPostProcessor) keyboardFocusManager.addKeyEventDispatcher(keymapKeyEventDispatcher)
keyboardFocusManager.addKeyEventDispatcher(myKeyEventDispatcher) keyboardFocusManager.addKeyEventDispatcher(myKeyEventDispatcher)
try { try {
@@ -97,12 +94,26 @@ class KeymapManager private constructor() : Disposable {
database.removeKeymap(name) database.removeKeymap(name)
} }
private inner class MyKeyEventPostProcessor : KeyEventPostProcessor { private inner class KeymapKeyEventDispatcher : KeyEventDispatcher {
override fun postProcessKeyEvent(e: KeyEvent): Boolean {
// 只处理 PRESSED 和 带有 modifiers 键的事件 override fun dispatchKeyEvent(e: KeyEvent): Boolean {
if (!e.isConsumed && e.id == KeyEvent.KEY_PRESSED && e.modifiersEx != 0) { if (e.isConsumed || e.id != KeyEvent.KEY_PRESSED || e.modifiersEx == 0) {
return false
}
val keyStroke = KeyStroke.getKeyStrokeForEvent(e)
val component = e.source
if (component is JComponent) {
// 如果这个键已经被组件注册了,那么忽略
if (component.getConditionForKeyStroke(keyStroke) != JComponent.UNDEFINED_CONDITION) {
return false
}
}
val shortcuts = getActiveKeymap() val shortcuts = getActiveKeymap()
val actionIds = shortcuts.getActionIds(KeyShortcut(KeyStroke.getKeyStrokeForEvent(e))) val actionIds = shortcuts.getActionIds(KeyShortcut(keyStroke))
if (actionIds.isEmpty()) { if (actionIds.isEmpty()) {
return false return false
} }
@@ -128,27 +139,45 @@ class KeymapManager private constructor() : Disposable {
return true return true
} }
} }
}
return false return false
} }
} }
@Deprecated(message = "Deprecated")
private inner class MyKeyEventDispatcher : KeyEventDispatcher { private inner class MyKeyEventDispatcher : KeyEventDispatcher {
// double shift // double shift
private var lastTime = -1L private var lastTime = -1L
private val findEverywhereAction
get() = ActionManager.getInstance().getAction(FindEverywhereAction.FIND_EVERYWHERE)
private val deprecatedKey by lazy { "${Application.getVersion()}.FindEverywhereActionDeprecated" }
override fun dispatchKeyEvent(e: KeyEvent): Boolean { override fun dispatchKeyEvent(e: KeyEvent): Boolean {
if (e.keyCode == KeyEvent.VK_SHIFT && e.id == KeyEvent.KEY_PRESSED) { if (e.keyCode == KeyEvent.VK_SHIFT && e.id == KeyEvent.KEY_PRESSED) {
val owner = AnActionEvent(e.source, StringUtils.EMPTY, e).getData(DataProviders.TermoraFrame) val evt = AnActionEvent(e.source, StringUtils.EMPTY, e)
?: return false val owner = evt.getData(DataProviders.TermoraFrame) ?: return false
if (keyboardFocusManager.focusedWindow == owner) { if (keyboardFocusManager.focusedWindow == owner) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - 250 < lastTime) { if (now - 250 < lastTime) {
app.termora.actions.ActionManager.getInstance() if (!properties.getString(deprecatedKey, "false").toBoolean()) {
.getAction(FindEverywhereAction.FIND_EVERYWHERE) properties.putString(deprecatedKey, "true")
?.actionPerformed(AnActionEvent(e.source, StringUtils.EMPTY, e)) val shortcut = getActiveKeymap().getShortcut(FindEverywhereAction.FIND_EVERYWHERE)
.firstOrNull()
if (shortcut == null) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.find-everywhere.double-shift-deprecated")
)
} else {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.find-everywhere.double-shift-deprecated-instead", shortcut)
)
}
}
SwingUtilities.invokeLater { findEverywhereAction?.actionPerformed(evt) }
} }
lastTime = now lastTime = now
} }
@@ -163,7 +192,7 @@ class KeymapManager private constructor() : Disposable {
override fun dispose() { override fun dispose() {
keyboardFocusManager.removeKeyEventPostProcessor(myKeyEventPostProcessor) keyboardFocusManager.removeKeyEventDispatcher(keymapKeyEventDispatcher)
keyboardFocusManager.removeKeyEventDispatcher(myKeyEventDispatcher) keyboardFocusManager.removeKeyEventDispatcher(myKeyEventDispatcher)
} }
} }

View File

@@ -21,6 +21,7 @@ class KeyManager private constructor() {
if (keyPair == OhKeyPair.empty) { if (keyPair == OhKeyPair.empty) {
return return
} }
keyPairs.remove(keyPair)
keyPairs.add(keyPair) keyPairs.add(keyPair)
database.addKeyPair(keyPair) database.addKeyPair(keyPair)
} }

View File

@@ -2,6 +2,9 @@ package app.termora.keymgr
import app.termora.* import app.termora.*
import app.termora.AES.decodeBase64 import app.termora.AES.decodeBase64
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.native.FileChooser import app.termora.native.FileChooser
import com.formdev.flatlaf.extras.components.FlatComboBox import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatTable import com.formdev.flatlaf.extras.components.FlatTable
@@ -48,6 +51,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
private val exportBtn = JButton(I18n.getString("termora.keymgr.export")) private val exportBtn = JButton(I18n.getString("termora.keymgr.export"))
private val editBtn = JButton(I18n.getString("termora.keymgr.edit")) private val editBtn = JButton(I18n.getString("termora.keymgr.edit"))
private val deleteBtn = JButton(I18n.getString("termora.remove")) private val deleteBtn = JButton(I18n.getString("termora.remove"))
private val sshCopyIdBtn = JButton("ssh-copy-id")
init { init {
initView() initView()
@@ -59,6 +63,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
exportBtn.isEnabled = false exportBtn.isEnabled = false
editBtn.isEnabled = false editBtn.isEnabled = false
sshCopyIdBtn.isEnabled = false
deleteBtn.isEnabled = false deleteBtn.isEnabled = false
keyPairTableModel.addColumn(I18n.getString("termora.keymgr.table.name")) keyPairTableModel.addColumn(I18n.getString("termora.keymgr.table.name"))
@@ -75,7 +80,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
val formMargin = "4dlu" val formMargin = "4dlu"
val layout = FormLayout( val layout = FormLayout(
"default:grow", "default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin" "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref"
) )
var rows = 1 var rows = 1
@@ -91,6 +96,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
.add(importBtn).xy(1, rows).apply { rows += step } .add(importBtn).xy(1, rows).apply { rows += step }
.add(exportBtn).xy(1, rows).apply { rows += step } .add(exportBtn).xy(1, rows).apply { rows += step }
.add(deleteBtn).xy(1, rows).apply { rows += step } .add(deleteBtn).xy(1, rows).apply { rows += step }
.add(sshCopyIdBtn).xy(1, rows).apply { rows += step }
.build(), BorderLayout.EAST) .build(), BorderLayout.EAST)
border = BorderFactory.createEmptyBorder(if (SystemInfo.isWindows || SystemInfo.isLinux) 6 else 0, 12, 12, 12) border = BorderFactory.createEmptyBorder(if (SystemInfo.isWindows || SystemInfo.isLinux) 6 else 0, 12, 12, 12)
@@ -175,13 +181,48 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
} }
}) })
sshCopyIdBtn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
sshCopyId(evt)
}
})
keyPairTable.selectionModel.addListSelectionListener { keyPairTable.selectionModel.addListSelectionListener {
exportBtn.isEnabled = keyPairTable.selectedRowCount > 0 exportBtn.isEnabled = keyPairTable.selectedRowCount > 0
editBtn.isEnabled = exportBtn.isEnabled editBtn.isEnabled = exportBtn.isEnabled
deleteBtn.isEnabled = exportBtn.isEnabled deleteBtn.isEnabled = exportBtn.isEnabled
sshCopyIdBtn.isEnabled = exportBtn.isEnabled
} }
} }
private fun sshCopyId(evt: AnActionEvent) {
val windowScope = evt.getData(DataProviders.WindowScope) ?: return
val keyPairs = keyPairTable.selectedRows.map { keyPairTableModel.getOhKeyPair(it) }
val publicKeys = mutableListOf<Pair<String, String>>()
for (keyPair in keyPairs) {
val publicKey = OhKeyPairKeyPairProvider.generateKeyPair(keyPair).public
val baos = ByteArrayOutputStream()
OpenSSHKeyPairResourceWriter.INSTANCE.writePublicKey(publicKey, keyPair.name, baos)
publicKeys.add(Pair(keyPair.name, baos.toString(Charsets.UTF_8)))
}
if (publicKeys.isEmpty()) {
return
}
val owner = SwingUtilities.getWindowAncestor(this) ?: return
val hostTreeDialog = HostTreeDialog(owner) {
it.protocol == Protocol.SSH
}
hostTreeDialog.isVisible = true
val hosts = hostTreeDialog.hosts
if (hosts.isEmpty()) {
return
}
SSHCopyIdDialog(owner, windowScope, hosts, publicKeys).start()
}
private fun exportKeyPairs(file: File, keyPairs: List<OhKeyPair>) { private fun exportKeyPairs(file: File, keyPairs: List<OhKeyPair>) {
file.outputStream().use { fis -> file.outputStream().use { fis ->
val names = mutableMapOf<String, Int>() val names = mutableMapOf<String, Int>()

View File

@@ -0,0 +1,197 @@
package app.termora.keymgr
import app.termora.*
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnectorDelegate
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.IOUtils
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ClientChannelEvent
import org.apache.sshd.client.session.ClientSession
import org.slf4j.LoggerFactory
import java.awt.Dimension
import java.awt.Window
import java.io.ByteArrayOutputStream
import java.time.Duration
import java.util.*
import javax.swing.AbstractAction
import javax.swing.JComponent
import javax.swing.UIManager
class SSHCopyIdDialog(
owner: Window,
private val windowScope: WindowScope,
private val hosts: List<Host>,
// key: name , value: public key
private val publicKeys: List<Pair<String, String>>,
) : DialogWrapper(owner) {
companion object {
private val log = LoggerFactory.getLogger(SSHCopyIdDialog::class.java)
}
private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
private val terminal by lazy {
TerminalFactory.getInstance(windowScope).createTerminal().apply {
getTerminalModel().setData(DataKey.ShowCursor, false)
getTerminalModel().setData(DataKey.AutoNewline, true)
}
}
private val terminalPanel by lazy {
terminalPanelFactory.createTerminalPanel(terminal, PtyConnectorDelegate())
}
private val coroutineScope = CoroutineScope(Job() + Dispatchers.IO)
init {
size = Dimension(UIManager.getInt("Dialog.width") - 100, UIManager.getInt("Dialog.height") - 100)
isModal = true
title = "SSH Copy ID"
setLocationRelativeTo(null)
Disposer.register(disposable, object : Disposable {
override fun dispose() {
coroutineScope.cancel()
terminal.close()
Disposer.dispose(terminalPanel)
}
})
init()
}
override fun createCenterPanel(): JComponent {
return terminalPanel
}
fun start() {
coroutineScope.launch {
try {
doStart()
} catch (e: Exception) {
log.error(e.message, e)
}
}
isVisible = true
}
override fun createActions(): List<AbstractAction> {
return listOf(CancelAction())
}
private fun magenta(text: Any): String {
return "${ControlCharacters.ESC}[35m${text}${ControlCharacters.ESC}[0m"
}
private fun cyan(text: Any): String {
return "${ControlCharacters.ESC}[36m${text}${ControlCharacters.ESC}[0m"
}
private fun red(text: Any): String {
return "${ControlCharacters.ESC}[31m${text}${ControlCharacters.ESC}[0m"
}
private fun green(text: Any): String {
return "${ControlCharacters.ESC}[32m${text}${ControlCharacters.ESC}[0m"
}
private suspend fun doStart() {
withContext(Dispatchers.Swing) {
terminal.write(
I18n.getString(
"termora.keymgr.ssh-copy-id.number",
magenta(hosts.size),
magenta(publicKeys.size)
)
)
terminal.getDocument().newline()
terminal.getDocument().newline()
}
var myClient: SshClient? = null
var mySession: ClientSession? = null
val timeout = Duration.ofMinutes(1)
// 获取公钥名称最长的
val publicKeyNameLength = publicKeys.maxOfOrNull { it.first.length } ?: 0
for (index in hosts.indices) {
if (!coroutineScope.isActive) {
return
}
val host = hosts[index]
withContext(Dispatchers.Swing) {
terminal.write("[${cyan(index + 1)}/${cyan(hosts.size)}] ${host.name}")
terminal.getDocument().newline()
}
for ((j, e) in publicKeys.withIndex()) {
if (!coroutineScope.isActive) {
return
}
val publicKeyName = e.first.padEnd(publicKeyNameLength, ' ')
val publicKey = e.second
withContext(Dispatchers.Swing) {
// @formatter:off
terminal.write("\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] $publicKeyName ${I18n.getString("termora.transport.sftp.connecting")}")
// @formatter:on
}
try {
val client = SshClients.openClient(host).apply { myClient = this }
client.userInteraction = TerminalUserInteraction(owner)
val session = SshClients.openSession(host, client).apply { mySession = this }
val channel =
session.createExecChannel("mkdir -p ~/.ssh && grep -qxF \"$publicKey\" ~/.ssh/authorized_keys || echo \"$publicKey\" >> ~/.ssh/authorized_keys")
val baos = ByteArrayOutputStream()
channel.out = baos
if (channel.open().verify(timeout).await(timeout)) {
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout);
}
if (channel.exitStatus != 0) {
throw IllegalStateException("Server response: ${channel.exitStatus}")
}
withContext(Dispatchers.Swing) {
terminal.getDocument().eraseInLine(2)
// @formatter:off
terminal.write("\r\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] $publicKeyName ${green(I18n.getString("termora.keymgr.ssh-copy-id.successful"))}")
// @formatter:on
}
} catch (e: Exception) {
withContext(Dispatchers.Swing) {
terminal.getDocument().eraseInLine(2)
// @formatter:off
terminal.write("\r\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] $publicKeyName ${red("${I18n.getString("termora.keymgr.ssh-copy-id.failed")}: ${e.message}")}")
// @formatter:on
}
} finally {
IOUtils.closeQuietly(mySession)
IOUtils.closeQuietly(myClient)
}
withContext(Dispatchers.Swing) {
terminal.getDocument().newline()
}
}
withContext(Dispatchers.Swing) {
terminal.getDocument().newline()
}
}
withContext(Dispatchers.Swing) {
terminal.write(I18n.getString("termora.keymgr.ssh-copy-id.end"))
}
}
}

View File

@@ -17,6 +17,10 @@ class FileChooser {
var allowsOtherFileTypes = true var allowsOtherFileTypes = true
var canCreateDirectories = true var canCreateDirectories = true
var win32Filters = mutableListOf<Pair<String, List<String>>>() var win32Filters = mutableListOf<Pair<String, List<String>>>()
/**
* e.g. listOf("json")
*/
var osxAllowedFileTypes = emptyList<String>() var osxAllowedFileTypes = emptyList<String>()
/** /**

View File

@@ -1,42 +1,19 @@
package app.termora.sync package app.termora.sync
import app.termora.*
import app.termora.AES.CBC.aesCBCDecrypt
import app.termora.AES.CBC.aesCBCEncrypt
import app.termora.AES.decodeBase64
import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson import app.termora.Application.ohMyJson
import app.termora.highlight.KeywordHighlight import app.termora.ResponseException
import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.Keymap
import app.termora.keymap.KeymapManager
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.macro.MacroManager
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.apache.commons.lang3.ArrayUtils import org.apache.commons.lang3.ArrayUtils
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import javax.swing.SwingUtilities
abstract class GitSyncer : Syncer { abstract class GitSyncer : SafetySyncer() {
companion object { companion object {
private val log = LoggerFactory.getLogger(GitSyncer::class.java) private val log = LoggerFactory.getLogger(GitSyncer::class.java)
} }
protected val description = "${Application.getName()} config"
protected val httpClient get() = Application.httpClient
protected val hostManager get() = HostManager.getInstance()
protected val keyManager get() = KeyManager.getInstance()
protected val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
protected val macroManager get() = MacroManager.getInstance()
protected val keymapManager get() = KeymapManager.getInstance()
override fun pull(config: SyncConfig): GistResponse { override fun pull(config: SyncConfig): GistResponse {
if (log.isInfoEnabled) { if (log.isInfoEnabled) {
@@ -92,174 +69,6 @@ abstract class GitSyncer : Syncer {
return gistResponse return gistResponse
} }
private fun decodeHosts(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedHosts = ohMyJson.decodeFromString<List<EncryptedHost>>(text)
val hosts = hostManager.hosts().associateBy { it.id }
for (encryptedHost in encryptedHosts) {
val oldHost = hosts[encryptedHost.id]
// 如果一样,则无需配置
if (oldHost != null) {
if (oldHost.updateDate == encryptedHost.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(encryptedHost.id)
val host = Host(
id = encryptedHost.id,
name = encryptedHost.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
protocol = Protocol.valueOf(
encryptedHost.protocol.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
host = encryptedHost.host.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
port = encryptedHost.port.decodeBase64().aesCBCDecrypt(key, iv)
.decodeToString().toIntOrNull() ?: 0,
username = encryptedHost.username.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedHost.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
authentication = ohMyJson.decodeFromString(
encryptedHost.authentication.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
proxy = ohMyJson.decodeFromString(
encryptedHost.proxy.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
options = ohMyJson.decodeFromString(
encryptedHost.options.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
tunnelings = ohMyJson.decodeFromString(
encryptedHost.tunnelings.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
sort = encryptedHost.sort,
parentId = encryptedHost.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
ownerId = encryptedHost.ownerId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
createDate = encryptedHost.createDate,
updateDate = encryptedHost.updateDate,
deleted = encryptedHost.deleted
)
SwingUtilities.invokeLater { hostManager.addHost(host) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode host: ${encryptedHost.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode hosts: {}", text)
}
}
private fun decodeKeys(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeys = ohMyJson.decodeFromString<List<OhKeyPair>>(text)
for (encryptedKey in encryptedKeys) {
try {
// aes iv
val iv = getIv(encryptedKey.id)
val keyPair = OhKeyPair(
id = encryptedKey.id,
publicKey = encryptedKey.publicKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
privateKey = encryptedKey.privateKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
type = encryptedKey.type.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
name = encryptedKey.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedKey.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
length = encryptedKey.length,
sort = encryptedKey.sort
)
SwingUtilities.invokeLater { keyManager.addOhKeyPair(keyPair) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode key: ${encryptedKey.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode keys: {}", text)
}
}
private fun decodeKeywordHighlights(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeywordHighlights = ohMyJson.decodeFromString<List<KeywordHighlight>>(text)
for (e in encryptedKeywordHighlights) {
try {
// aes iv
val iv = getIv(e.id)
keywordHighlightManager.addKeywordHighlight(
e.copy(
keyword = e.keyword.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
description = e.description.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode KeywordHighlight: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode KeywordHighlight: {}", text)
}
}
private fun decodeMacros(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedMacros = ohMyJson.decodeFromString<List<Macro>>(text)
for (e in encryptedMacros) {
try {
// aes iv
val iv = getIv(e.id)
macroManager.addMacro(
e.copy(
name = e.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
macro = e.macro.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode Macro: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode Macros: {}", text)
}
}
private fun decodeKeymaps(text: String, config: SyncConfig) {
for (keymap in ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }) {
keymapManager.addKeymap(keymap)
}
if (log.isDebugEnabled) {
log.debug("Decode Keymaps: {}", text)
}
}
private fun getKey(config: SyncConfig): ByteArray {
return ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16)
}
private fun getIv(id: String): ByteArray {
return ArrayUtils.subarray(id.padEnd(16, '0').toByteArray(), 0, 16)
}
override fun push(config: SyncConfig): GistResponse { override fun push(config: SyncConfig): GistResponse {
val gistFiles = mutableListOf<GistFile>() val gistFiles = mutableListOf<GistFile>()
@@ -268,62 +77,16 @@ abstract class GitSyncer : Syncer {
// Hosts // Hosts
if (config.ranges.contains(SyncRange.Hosts)) { if (config.ranges.contains(SyncRange.Hosts)) {
val encryptedHosts = mutableListOf<EncryptedHost>() val hostsContent = encodeHosts(key)
for (host in hostManager.hosts()) {
// aes iv
val iv = ArrayUtils.subarray(host.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedHost = EncryptedHost()
encryptedHost.id = host.id
encryptedHost.name = host.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.protocol = host.protocol.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.host = host.host.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.port = "${host.port}".aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.username = host.username.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.remark = host.remark.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.authentication = ohMyJson.encodeToString(host.authentication)
.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.proxy = ohMyJson.encodeToString(host.proxy).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.options =
ohMyJson.encodeToString(host.options).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.tunnelings =
ohMyJson.encodeToString(host.tunnelings).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.sort = host.sort
encryptedHost.deleted = host.deleted
encryptedHost.parentId = host.parentId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.ownerId = host.ownerId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.creatorId = host.creatorId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.createDate = host.createDate
encryptedHost.updateDate = host.updateDate
encryptedHosts.add(encryptedHost)
}
val hostsContent = ohMyJson.encodeToString(encryptedHosts)
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Push encryptedHosts: {}", hostsContent) log.debug("Push encryptedHosts: {}", hostsContent)
} }
gistFiles.add(GistFile("Hosts", hostsContent)) gistFiles.add(GistFile("Hosts", hostsContent))
} }
// KeyPairs // KeyPairs
if (config.ranges.contains(SyncRange.KeyPairs)) { if (config.ranges.contains(SyncRange.KeyPairs)) {
val encryptedKeys = mutableListOf<OhKeyPair>() val keysContent = encodeKeys(key)
for (keyPair in keyManager.getOhKeyPairs()) {
// aes iv
val iv = ArrayUtils.subarray(keyPair.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedKeyPair = OhKeyPair(
id = keyPair.id,
publicKey = keyPair.publicKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
privateKey = keyPair.privateKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
type = keyPair.type.aesCBCEncrypt(key, iv).encodeBase64String(),
name = keyPair.name.aesCBCEncrypt(key, iv).encodeBase64String(),
remark = keyPair.remark.aesCBCEncrypt(key, iv).encodeBase64String(),
length = keyPair.length,
sort = keyPair.sort
)
encryptedKeys.add(encryptedKeyPair)
}
val keysContent = ohMyJson.encodeToString(encryptedKeys)
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Push encryptedKeys: {}", keysContent) log.debug("Push encryptedKeys: {}", keysContent)
} }
@@ -332,17 +95,7 @@ abstract class GitSyncer : Syncer {
// Highlights // Highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) { if (config.ranges.contains(SyncRange.KeywordHighlights)) {
val keywordHighlights = mutableListOf<KeywordHighlight>() val keywordHighlightsContent = encodeKeywordHighlights(key)
for (keywordHighlight in keywordHighlightManager.getKeywordHighlights()) {
// aes iv
val iv = getIv(keywordHighlight.id)
val encryptedKeyPair = keywordHighlight.copy(
keyword = keywordHighlight.keyword.aesCBCEncrypt(key, iv).encodeBase64String(),
description = keywordHighlight.description.aesCBCEncrypt(key, iv).encodeBase64String(),
)
keywordHighlights.add(encryptedKeyPair)
}
val keywordHighlightsContent = ohMyJson.encodeToString(keywordHighlights)
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Push keywordHighlights: {}", keywordHighlightsContent) log.debug("Push keywordHighlights: {}", keywordHighlightsContent)
} }
@@ -351,17 +104,7 @@ abstract class GitSyncer : Syncer {
// Macros // Macros
if (config.ranges.contains(SyncRange.Macros)) { if (config.ranges.contains(SyncRange.Macros)) {
val macros = mutableListOf<Macro>() val macrosContent = encodeMacros(key)
for (macro in macroManager.getMacros()) {
val iv = getIv(macro.id)
macros.add(
macro.copy(
name = macro.name.aesCBCEncrypt(key, iv).encodeBase64String(),
macro = macro.macro.aesCBCEncrypt(key, iv).encodeBase64String()
)
)
}
val macrosContent = ohMyJson.encodeToString(macros)
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Push macros: {}", macrosContent) log.debug("Push macros: {}", macrosContent)
} }
@@ -370,23 +113,12 @@ abstract class GitSyncer : Syncer {
// Keymap // Keymap
if (config.ranges.contains(SyncRange.Keymap)) { if (config.ranges.contains(SyncRange.Keymap)) {
val keymaps = mutableListOf<JsonObject>() val keymapsContent = encodeKeymaps()
for (keymap in keymapManager.getKeymaps()) {
// 只读的是内置的
if (keymap.isReadonly) {
continue
}
keymaps.add(keymap.toJSONObject())
}
if (keymaps.isNotEmpty()) {
val keymapsContent = ohMyJson.encodeToString(keymaps)
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Push keymaps: {}", keymapsContent) log.debug("Push keymaps: {}", keymapsContent)
} }
gistFiles.add(GistFile("Keymaps", keymapsContent)) gistFiles.add(GistFile("Keymaps", keymapsContent))
} }
}
if (gistFiles.isEmpty()) { if (gistFiles.isEmpty()) {
throw IllegalArgumentException("No gist files found") throw IllegalArgumentException("No gist files found")

View File

@@ -0,0 +1,299 @@
package app.termora.sync
import app.termora.*
import app.termora.AES.CBC.aesCBCDecrypt
import app.termora.AES.CBC.aesCBCEncrypt
import app.termora.AES.decodeBase64
import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson
import app.termora.highlight.KeywordHighlight
import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.Keymap
import app.termora.keymap.KeymapManager
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.macro.MacroManager
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonObject
import org.apache.commons.lang3.ArrayUtils
import org.slf4j.LoggerFactory
import javax.swing.SwingUtilities
abstract class SafetySyncer : Syncer {
companion object {
private val log = LoggerFactory.getLogger(SafetySyncer::class.java)
}
protected val description = "${Application.getName()} config"
protected val httpClient get() = Application.httpClient
protected val hostManager get() = HostManager.getInstance()
protected val keyManager get() = KeyManager.getInstance()
protected val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
protected val macroManager get() = MacroManager.getInstance()
protected val keymapManager get() = KeymapManager.getInstance()
protected fun decodeHosts(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedHosts = ohMyJson.decodeFromString<List<EncryptedHost>>(text)
val hosts = hostManager.hosts().associateBy { it.id }
for (encryptedHost in encryptedHosts) {
val oldHost = hosts[encryptedHost.id]
// 如果一样,则无需配置
if (oldHost != null) {
if (oldHost.updateDate == encryptedHost.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(encryptedHost.id)
val host = Host(
id = encryptedHost.id,
name = encryptedHost.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
protocol = Protocol.valueOf(
encryptedHost.protocol.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
host = encryptedHost.host.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
port = encryptedHost.port.decodeBase64().aesCBCDecrypt(key, iv)
.decodeToString().toIntOrNull() ?: 0,
username = encryptedHost.username.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedHost.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
authentication = ohMyJson.decodeFromString(
encryptedHost.authentication.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
proxy = ohMyJson.decodeFromString(
encryptedHost.proxy.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
options = ohMyJson.decodeFromString(
encryptedHost.options.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
tunnelings = ohMyJson.decodeFromString(
encryptedHost.tunnelings.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
sort = encryptedHost.sort,
parentId = encryptedHost.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
ownerId = encryptedHost.ownerId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
createDate = encryptedHost.createDate,
updateDate = encryptedHost.updateDate,
deleted = encryptedHost.deleted
)
SwingUtilities.invokeLater { hostManager.addHost(host) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode host: ${encryptedHost.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode hosts: {}", text)
}
}
protected fun encodeHosts(key: ByteArray): String {
val encryptedHosts = mutableListOf<EncryptedHost>()
for (host in hostManager.hosts()) {
// aes iv
val iv = ArrayUtils.subarray(host.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedHost = EncryptedHost()
encryptedHost.id = host.id
encryptedHost.name = host.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.protocol = host.protocol.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.host = host.host.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.port = "${host.port}".aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.username = host.username.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.remark = host.remark.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.authentication = ohMyJson.encodeToString(host.authentication)
.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.proxy = ohMyJson.encodeToString(host.proxy).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.options =
ohMyJson.encodeToString(host.options).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.tunnelings =
ohMyJson.encodeToString(host.tunnelings).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.sort = host.sort
encryptedHost.deleted = host.deleted
encryptedHost.parentId = host.parentId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.ownerId = host.ownerId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.creatorId = host.creatorId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.createDate = host.createDate
encryptedHost.updateDate = host.updateDate
encryptedHosts.add(encryptedHost)
}
return ohMyJson.encodeToString(encryptedHosts)
}
protected fun decodeKeys(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeys = ohMyJson.decodeFromString<List<OhKeyPair>>(text)
for (encryptedKey in encryptedKeys) {
try {
// aes iv
val iv = getIv(encryptedKey.id)
val keyPair = OhKeyPair(
id = encryptedKey.id,
publicKey = encryptedKey.publicKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
privateKey = encryptedKey.privateKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
type = encryptedKey.type.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
name = encryptedKey.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedKey.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
length = encryptedKey.length,
sort = encryptedKey.sort
)
SwingUtilities.invokeLater { keyManager.addOhKeyPair(keyPair) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode key: ${encryptedKey.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode keys: {}", text)
}
}
protected fun encodeKeys(key: ByteArray): String {
val encryptedKeys = mutableListOf<OhKeyPair>()
for (keyPair in keyManager.getOhKeyPairs()) {
// aes iv
val iv = ArrayUtils.subarray(keyPair.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedKeyPair = OhKeyPair(
id = keyPair.id,
publicKey = keyPair.publicKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
privateKey = keyPair.privateKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
type = keyPair.type.aesCBCEncrypt(key, iv).encodeBase64String(),
name = keyPair.name.aesCBCEncrypt(key, iv).encodeBase64String(),
remark = keyPair.remark.aesCBCEncrypt(key, iv).encodeBase64String(),
length = keyPair.length,
sort = keyPair.sort
)
encryptedKeys.add(encryptedKeyPair)
}
return ohMyJson.encodeToString(encryptedKeys)
}
protected fun decodeKeywordHighlights(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeywordHighlights = ohMyJson.decodeFromString<List<KeywordHighlight>>(text)
for (e in encryptedKeywordHighlights) {
try {
// aes iv
val iv = getIv(e.id)
keywordHighlightManager.addKeywordHighlight(
e.copy(
keyword = e.keyword.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
description = e.description.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode KeywordHighlight: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode KeywordHighlight: {}", text)
}
}
protected fun encodeKeywordHighlights(key: ByteArray): String {
val keywordHighlights = mutableListOf<KeywordHighlight>()
for (keywordHighlight in keywordHighlightManager.getKeywordHighlights()) {
// aes iv
val iv = getIv(keywordHighlight.id)
val encryptedKeyPair = keywordHighlight.copy(
keyword = keywordHighlight.keyword.aesCBCEncrypt(key, iv).encodeBase64String(),
description = keywordHighlight.description.aesCBCEncrypt(key, iv).encodeBase64String(),
)
keywordHighlights.add(encryptedKeyPair)
}
return ohMyJson.encodeToString(keywordHighlights)
}
protected fun decodeMacros(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedMacros = ohMyJson.decodeFromString<List<Macro>>(text)
for (e in encryptedMacros) {
try {
// aes iv
val iv = getIv(e.id)
macroManager.addMacro(
e.copy(
name = e.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
macro = e.macro.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode Macro: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode Macros: {}", text)
}
}
protected fun encodeMacros(key: ByteArray): String {
val macros = mutableListOf<Macro>()
for (macro in macroManager.getMacros()) {
val iv = getIv(macro.id)
macros.add(
macro.copy(
name = macro.name.aesCBCEncrypt(key, iv).encodeBase64String(),
macro = macro.macro.aesCBCEncrypt(key, iv).encodeBase64String()
)
)
}
return ohMyJson.encodeToString(macros)
}
protected fun decodeKeymaps(text: String, config: SyncConfig) {
for (keymap in ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }) {
keymapManager.addKeymap(keymap)
}
if (log.isDebugEnabled) {
log.debug("Decode Keymaps: {}", text)
}
}
protected fun encodeKeymaps(): String {
val keymaps = mutableListOf<JsonObject>()
for (keymap in keymapManager.getKeymaps()) {
// 只读的是内置的
if (keymap.isReadonly) {
continue
}
keymaps.add(keymap.toJSONObject())
}
return ohMyJson.encodeToString(keymaps)
}
protected open fun getKey(config: SyncConfig): ByteArray {
return ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16)
}
protected fun getIv(id: String): ByteArray {
return ArrayUtils.subarray(id.padEnd(16, '0').toByteArray(), 0, 16)
}
}

View File

@@ -4,6 +4,7 @@ enum class SyncType {
GitLab, GitLab,
GitHub, GitHub,
Gitee, Gitee,
WebDAV,
} }
enum class SyncRange { enum class SyncRange {

View File

@@ -15,6 +15,7 @@ class SyncerProvider private constructor() {
SyncType.GitHub -> GitHubSyncer.getInstance() SyncType.GitHub -> GitHubSyncer.getInstance()
SyncType.Gitee -> GiteeSyncer.getInstance() SyncType.Gitee -> GiteeSyncer.getInstance()
SyncType.GitLab -> GitLabSyncer.getInstance() SyncType.GitLab -> GitLabSyncer.getInstance()
SyncType.WebDAV -> WebDAVSyncer.getInstance()
} }
} }
} }

View File

@@ -0,0 +1,152 @@
package app.termora.sync
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.PBKDF2
import app.termora.ResponseException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.Credentials
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.slf4j.LoggerFactory
class WebDAVSyncer private constructor() : SafetySyncer() {
companion object {
private val log = LoggerFactory.getLogger(WebDAVSyncer::class.java)
fun getInstance(): WebDAVSyncer {
return ApplicationScope.forApplicationScope().getOrCreate(WebDAVSyncer::class) { WebDAVSyncer() }
}
}
override fun pull(config: SyncConfig): GistResponse {
val response = httpClient.newCall(newRequestBuilder(config).get().build()).execute()
if (!response.isSuccessful) {
throw ResponseException(response.code, response)
}
val text = response.use { resp -> resp.body?.use { it.string() } }
?: throw ResponseException(response.code, response)
val json = ohMyJson.decodeFromString<JsonObject>(text)
// decode hosts
json["Hosts"]?.jsonPrimitive?.content?.let {
decodeHosts(it, config)
}
// decode KeyPairs
json["KeyPairs"]?.jsonPrimitive?.content?.let {
decodeKeys(it, config)
}
// decode Highlights
json["KeywordHighlights"]?.jsonPrimitive?.content?.let {
decodeKeywordHighlights(it, config)
}
// decode Macros
json["Macros"]?.jsonPrimitive?.content?.let {
decodeMacros(it, config)
}
// decode Keymaps
json["Keymaps"]?.jsonPrimitive?.content?.let {
decodeKeymaps(it, config)
}
return GistResponse(config, emptyList())
}
override fun push(config: SyncConfig): GistResponse {
// aes key
val key = getKey(config)
val json = buildJsonObject {
// Hosts
if (config.ranges.contains(SyncRange.Hosts)) {
val hostsContent = encodeHosts(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedHosts: {}", hostsContent)
}
put("Hosts", hostsContent)
}
// KeyPairs
if (config.ranges.contains(SyncRange.KeyPairs)) {
val keysContent = encodeKeys(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedKeys: {}", keysContent)
}
put("KeyPairs", keysContent)
}
// Highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
val keywordHighlightsContent = encodeKeywordHighlights(key)
if (log.isDebugEnabled) {
log.debug("Push keywordHighlights: {}", keywordHighlightsContent)
}
put("KeywordHighlights", keywordHighlightsContent)
}
// Macros
if (config.ranges.contains(SyncRange.Macros)) {
val macrosContent = encodeMacros(key)
if (log.isDebugEnabled) {
log.debug("Push macros: {}", macrosContent)
}
put("Macros", macrosContent)
}
// Keymap
if (config.ranges.contains(SyncRange.Keymap)) {
val keymapsContent = encodeKeymaps()
if (log.isDebugEnabled) {
log.debug("Push keymaps: {}", keymapsContent)
}
put("Keymaps", keymapsContent)
}
}
val response = httpClient.newCall(
newRequestBuilder(config).put(
ohMyJson.encodeToString(json)
.toRequestBody("application/json".toMediaType())
).build()
).execute()
if (!response.isSuccessful) {
throw ResponseException(response.code, response)
}
return GistResponse(
config = config,
gists = emptyList()
)
}
private fun getWebDavFileUrl(config: SyncConfig): String {
return config.options["domain"] ?: throw IllegalStateException("domain is not defined")
}
override fun getKey(config: SyncConfig): ByteArray {
return PBKDF2.generateSecret(
config.gistId.toCharArray(),
config.token.toByteArray(),
10000, 128
)
}
private fun newRequestBuilder(config: SyncConfig): Request.Builder {
return Request.Builder()
.header("Authorization", Credentials.basic(config.gistId, config.token, Charsets.UTF_8))
.url(getWebDavFileUrl(config))
}
}

View File

@@ -485,9 +485,11 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
val m = args.first() val m = args.first()
if (m == '6') { if (m == '6') {
val position = terminal.getCursorModel().getPosition() val position = terminal.getCursorModel().getPosition()
ptyConnector.write("${ControlCharacters.ESC}[${position.y};${position.x}R") val bytes = "${ControlCharacters.ESC}[${position.y};${position.x}R".toByteArray(ptyConnector.getCharset())
ptyConnector.write(bytes)
} else if (m == '5') { } else if (m == '5') {
ptyConnector.write("${ControlCharacters.ESC}[0n") val bytes = "${ControlCharacters.ESC}[0n".toByteArray(ptyConnector.getCharset())
ptyConnector.write(bytes)
} }
} }
@@ -689,6 +691,13 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer // https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer
1049 -> { 1049 -> {
// Save cursor
if (enable) {
CursorStoreStores.store(terminal)
} else {
CursorStoreStores.restore(terminal)
}
// 如果是关闭 清屏 // 如果是关闭 清屏
if (!enable) { if (!enable) {
terminal.getDocument().eraseInDisplay(2) terminal.getDocument().eraseInDisplay(2)
@@ -922,7 +931,7 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
else -> { else -> {
if (log.isWarnEnabled) { if (log.isWarnEnabled) {
log.warn("xterm-256 foreground color, code: $code") log.warn("xterm-256 background color, code: $code")
} }
} }
} }

View File

@@ -0,0 +1,66 @@
package app.termora.terminal
import org.slf4j.LoggerFactory
object CursorStoreStores {
private val log = LoggerFactory.getLogger(CursorStoreStores::class.java)
fun restore(terminal: Terminal) {
val terminalModel = terminal.getTerminalModel()
val cursorStore = if (terminalModel.hasData(DataKey.SaveCursor)) {
terminalModel.getData(DataKey.SaveCursor)
} else {
CursorStore(
position = Position(1, 1),
textStyle = TextStyle.Default,
autoWarpMode = false,
originMode = false,
graphicCharacterSet = GraphicCharacterSet()
)
}
terminalModel.setData(DataKey.OriginMode, cursorStore.originMode)
terminalModel.setData(DataKey.TextStyle, cursorStore.textStyle)
terminalModel.setData(DataKey.AutoWrapMode, cursorStore.autoWarpMode)
terminalModel.setData(DataKey.GraphicCharacterSet, cursorStore.graphicCharacterSet)
val region = if (terminalModel.isOriginMode()) terminalModel.getScrollingRegion()
else ScrollingRegion(top = 1, bottom = terminalModel.getRows())
var y = cursorStore.position.y
if (y < region.top) {
y = 1
} else if (y > region.bottom) {
y = region.bottom
}
terminal.getCursorModel().move(row = y, col = cursorStore.position.x)
if (log.isDebugEnabled) {
log.debug("Restore Cursor (DECRC). $cursorStore")
}
}
fun store(terminal: Terminal) {
val terminalModel = terminal.getTerminalModel()
val graphicCharacterSet = terminalModel.getData(DataKey.GraphicCharacterSet)
// 避免引用
val characterSets = mutableMapOf<Graphic, CharacterSet>()
characterSets.putAll(graphicCharacterSet.characterSets)
val cursorStore = CursorStore(
position = terminal.getCursorModel().getPosition(),
textStyle = terminalModel.getData(DataKey.TextStyle),
autoWarpMode = terminalModel.getData(DataKey.AutoWrapMode, false),
originMode = terminalModel.isOriginMode(),
graphicCharacterSet = graphicCharacterSet.copy(characterSets = characterSets),
)
terminalModel.setData(DataKey.SaveCursor, cursorStore)
if (log.isDebugEnabled) {
log.debug("Save Cursor (DECSC). $cursorStore")
}
}
}

View File

@@ -1,36 +0,0 @@
package app.termora.terminal
import org.slf4j.LoggerFactory
class DeviceControlProcessor(private val terminal: Terminal) : Processor {
private val args = StringBuilder()
companion object {
private val log = LoggerFactory.getLogger(DeviceControlProcessor::class.java)
}
override fun process(ch: Char): ProcessorState {
val state = when (ch) {
ControlCharacters.ST -> {
if (log.isWarnEnabled) {
log.warn("Ignore DCS: {}", args)
}
TerminalState.READY
}
else -> {
args.append(ch)
TerminalState.DCS
}
}
if (state == TerminalState.READY) {
args.clear()
}
return state
}
}

View File

@@ -0,0 +1,46 @@
package app.termora.terminal
import org.slf4j.LoggerFactory
class DeviceControlStringProcessor(terminal: Terminal, reader: TerminalReader) : AbstractProcessor(terminal, reader) {
companion object {
private val log = LoggerFactory.getLogger(DeviceControlStringProcessor::class.java)
}
private val systemCommandSequence = SystemCommandSequence()
override fun process(ch: Char): ProcessorState {
// 回退回去,然后重新读取出来
reader.addFirst(ch)
do {
if (systemCommandSequence.process(reader.read())) {
break
}
// 如果没有检测到结束,那么退出重新来
if (reader.isEmpty()) {
return TerminalState.DCS
}
} while (reader.isNotEmpty())
processCommand(systemCommandSequence.getCommand())
systemCommandSequence.reset()
return TerminalState.READY
}
private fun processCommand(command: String) {
if (command.isEmpty()) {
return
}
if (log.isWarnEnabled) {
log.warn("Cannot process command: {}", command)
}
}
}

View File

@@ -128,9 +128,9 @@ class EscapeSequenceProcessor(terminal: Terminal, reader: TerminalReader) : Abst
} }
// TODO Device Control String (DCS is 0x90). // Device Control String (DCS is 0x90).
'P' -> { 'P' -> {
state = TerminalState.DCS
} }
// Start of Guarded Area (SPA is 0x96). // Start of Guarded Area (SPA is 0x96).
@@ -333,59 +333,12 @@ class EscapeSequenceProcessor(terminal: Terminal, reader: TerminalReader) : Abst
// ESC 7 Save Cursor (DECSC), VT100. // ESC 7 Save Cursor (DECSC), VT100.
'7' -> { '7' -> {
val graphicCharacterSet = terminalModel.getData(DataKey.GraphicCharacterSet) CursorStoreStores.store(terminal)
// 避免引用
val characterSets = mutableMapOf<Graphic, CharacterSet>()
characterSets.putAll(graphicCharacterSet.characterSets)
val cursorStore = CursorStore(
position = terminal.getCursorModel().getPosition(),
textStyle = terminalModel.getData(DataKey.TextStyle),
autoWarpMode = terminalModel.getData(DataKey.AutoWrapMode, false),
originMode = terminalModel.isOriginMode(),
graphicCharacterSet = graphicCharacterSet.copy(characterSets = characterSets),
)
terminalModel.setData(DataKey.SaveCursor, cursorStore)
if (log.isDebugEnabled) {
log.debug("Save Cursor (DECSC). $cursorStore")
}
} }
// Restore Cursor (DECRC), VT100. // Restore Cursor (DECRC), VT100.
'8' -> { '8' -> {
val cursorStore = if (terminalModel.hasData(DataKey.SaveCursor)) { CursorStoreStores.restore(terminal)
terminalModel.getData(DataKey.SaveCursor)
} else {
CursorStore(
position = Position(1, 1),
textStyle = TextStyle.Default,
autoWarpMode = false,
originMode = false,
graphicCharacterSet = GraphicCharacterSet()
)
}
terminalModel.setData(DataKey.OriginMode, cursorStore.originMode)
terminalModel.setData(DataKey.TextStyle, cursorStore.textStyle)
terminalModel.setData(DataKey.AutoWrapMode, cursorStore.autoWarpMode)
terminalModel.setData(DataKey.GraphicCharacterSet, cursorStore.graphicCharacterSet)
val region = if (terminalModel.isOriginMode()) terminalModel.getScrollingRegion()
else ScrollingRegion(top = 1, bottom = terminalModel.getRows())
var y = cursorStore.position.y
if (y < region.top) {
y = 1
} else if (y > region.bottom) {
y = region.bottom
}
terminal.getCursorModel().move(row = y, col = cursorStore.position.x)
if (log.isDebugEnabled) {
log.debug("Restore Cursor (DECRC). $cursorStore")
}
} }

View File

@@ -1,13 +1,14 @@
package app.termora.terminal package app.termora.terminal
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.lang3.StringUtils
import java.awt.event.KeyEvent import java.awt.event.KeyEvent
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataListener { open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataListener {
private val mapping = mutableMapOf<TerminalKeyEvent, String>() private val mapping = mutableMapOf<TerminalKeyEvent, String>()
private val nothing = String() private val nothing = StringUtils.EMPTY
init { init {
@@ -27,6 +28,7 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
configureLeftRight() configureLeftRight()
// Ctrl + C
putCode(TerminalKeyEvent(keyCode = 8), String(byteArrayOf(127))) putCode(TerminalKeyEvent(keyCode = 8), String(byteArrayOf(127)))
// Enter // Enter
@@ -38,15 +40,15 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
// Page Up // Page Up
putCode(TerminalKeyEvent(keyCode = 0x21), encode = "${ControlCharacters.ESC}[5~") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_PAGE_UP), encode = "${ControlCharacters.ESC}[5~")
// Page Down // Page Down
putCode(TerminalKeyEvent(keyCode = 0x22), encode = "${ControlCharacters.ESC}[6~") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_PAGE_DOWN), encode = "${ControlCharacters.ESC}[6~")
// Insert // Insert
putCode(TerminalKeyEvent(keyCode = 0x9B), encode = "${ControlCharacters.ESC}[2~") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_INSERT), encode = "${ControlCharacters.ESC}[2~")
// Delete // Delete
putCode(TerminalKeyEvent(keyCode = 0x7F), encode = "${ControlCharacters.ESC}[3~") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_DELETE), encode = "${ControlCharacters.ESC}[3~")
// Function Keys // Function Keys
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F1), encode = "${ControlCharacters.ESC}OP") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F1), encode = "${ControlCharacters.ESC}OP")
@@ -84,26 +86,29 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
fun arrowKeysApplicationSequences() { fun arrowKeysApplicationSequences() {
// Up // Up
putCode(TerminalKeyEvent(keyCode = 0x26), encode = "${ControlCharacters.ESC}OA") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_UP), encode = "${ControlCharacters.ESC}OA")
// Down // Down
putCode(TerminalKeyEvent(keyCode = 0x28), encode = "${ControlCharacters.ESC}OB") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_DOWN), encode = "${ControlCharacters.ESC}OB")
// Left // Left
putCode(TerminalKeyEvent(keyCode = 0x25), encode = "${ControlCharacters.ESC}OD") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_LEFT), encode = "${ControlCharacters.ESC}OD")
// Right // Right
putCode(TerminalKeyEvent(keyCode = 0x27), encode = "${ControlCharacters.ESC}OC") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_RIGHT), encode = "${ControlCharacters.ESC}OC")
} }
fun arrowKeysAnsiCursorSequences() { fun arrowKeysAnsiCursorSequences() {
// Up // Up
putCode(TerminalKeyEvent(keyCode = 0x26), encode = "${ControlCharacters.ESC}[A") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_UP), encode = "${ControlCharacters.ESC}[A")
// Down // Down
putCode(TerminalKeyEvent(keyCode = 0x28), encode = "${ControlCharacters.ESC}[B") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_DOWN), encode = "${ControlCharacters.ESC}[B")
// Left // Left
putCode(TerminalKeyEvent(keyCode = 0x25), encode = "${ControlCharacters.ESC}[D") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_LEFT), encode = "${ControlCharacters.ESC}[D")
// Right // Right
putCode(TerminalKeyEvent(keyCode = 0x27), encode = "${ControlCharacters.ESC}[C") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_RIGHT), encode = "${ControlCharacters.ESC}[C")
} }
/**
* Alt + Left/Right
*/
fun configureLeftRight() { fun configureLeftRight() {
if (SystemInfo.isMacOS) { if (SystemInfo.isMacOS) {
putCode( putCode(
@@ -141,32 +146,32 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
fun keypadApplicationSequences() { fun keypadApplicationSequences() {
// Up // Up
putCode(TerminalKeyEvent(keyCode = 0xE0), encode = "${ControlCharacters.ESC}OA") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_UP), encode = "${ControlCharacters.ESC}OA")
// Down // Down
putCode(TerminalKeyEvent(keyCode = 0xE1), encode = "${ControlCharacters.ESC}OB") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_DOWN), encode = "${ControlCharacters.ESC}OB")
// Left // Left
putCode(TerminalKeyEvent(keyCode = 0xE2), encode = "${ControlCharacters.ESC}OD") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_LEFT), encode = "${ControlCharacters.ESC}OD")
// Right // Right
putCode(TerminalKeyEvent(keyCode = 0xE3), encode = "${ControlCharacters.ESC}OC") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_RIGHT), encode = "${ControlCharacters.ESC}OC")
// Home // Home
putCode(TerminalKeyEvent(keyCode = 0x24), encode = "${ControlCharacters.ESC}OH") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_HOME), encode = "${ControlCharacters.ESC}OH")
// End // End
putCode(TerminalKeyEvent(keyCode = 0x23), encode = "${ControlCharacters.ESC}OF") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_END), encode = "${ControlCharacters.ESC}OF")
} }
fun keypadAnsiSequences() { fun keypadAnsiSequences() {
// Up // Up
putCode(TerminalKeyEvent(keyCode = 0xE0), encode = "${ControlCharacters.ESC}[A") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_UP), encode = "${ControlCharacters.ESC}[A")
// Down // Down
putCode(TerminalKeyEvent(keyCode = 0xE1), encode = "${ControlCharacters.ESC}[B") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_DOWN), encode = "${ControlCharacters.ESC}[B")
// Left // Left
putCode(TerminalKeyEvent(keyCode = 0xE2), encode = "${ControlCharacters.ESC}[D") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_LEFT), encode = "${ControlCharacters.ESC}[D")
// Right // Right
putCode(TerminalKeyEvent(keyCode = 0xE3), encode = "${ControlCharacters.ESC}[C") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_RIGHT), encode = "${ControlCharacters.ESC}[C")
// Home // Home
putCode(TerminalKeyEvent(keyCode = 0x24), encode = "${ControlCharacters.ESC}[H") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_HOME), encode = "${ControlCharacters.ESC}[H")
// End // End
putCode(TerminalKeyEvent(keyCode = 0x23), encode = "${ControlCharacters.ESC}[F") putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_END), encode = "${ControlCharacters.ESC}[F")
} }
override fun onChanged(key: DataKey<*>, data: Any) { override fun onChanged(key: DataKey<*>, data: Any) {

View File

@@ -7,7 +7,7 @@ import java.awt.datatransfer.StringSelection
class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader) : class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader) :
AbstractProcessor(terminal, reader) { AbstractProcessor(terminal, reader) {
private val args = StringBuilder() private val systemCommandSequence = SystemCommandSequence()
private val colorPalette get() = terminal.getTerminalModel().getColorPalette() private val colorPalette get() = terminal.getTerminalModel().getColorPalette()
companion object { companion object {
@@ -20,14 +20,7 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
do { do {
val c = reader.read() if (systemCommandSequence.process(reader.read())) {
args.append(c)
if (c == ControlCharacters.BEL || c == ControlCharacters.ST) {
args.deleteAt(args.lastIndex)
break
} else if (c == '\\' && args.length >= 2 && args[args.length - 2] == ControlCharacters.ESC) {
args.deleteAt(args.lastIndex)
args.deleteAt(args.lastIndex)
break break
} }
@@ -42,7 +35,7 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
// process osc // process osc
processOperatingSystemCommandProcessor() processOperatingSystemCommandProcessor()
args.clear() systemCommandSequence.reset()
return TerminalState.READY return TerminalState.READY
} }
@@ -52,6 +45,7 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
* https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands * https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
*/ */
private fun processOperatingSystemCommandProcessor() { private fun processOperatingSystemCommandProcessor() {
val args = systemCommandSequence.getCommand()
val idx = args.indexOfFirst { it == ';' } val idx = args.indexOfFirst { it == ';' }
if (idx == -1) { if (idx == -1) {
return return

View File

@@ -1,6 +1,7 @@
package app.termora.terminal package app.termora.terminal
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.charset.Charset
interface PtyConnector { interface PtyConnector {
@@ -15,15 +16,18 @@ interface PtyConnector {
*/ */
fun write(buffer: ByteArray, offset: Int, len: Int) fun write(buffer: ByteArray, offset: Int, len: Int)
/**
* 写入数组。
*
* 如果要写入 String 字符串,请通过 [getCharset] 编码。
*/
fun write(buffer: ByteArray) { fun write(buffer: ByteArray) {
write(buffer, 0, buffer.size) write(buffer, 0, buffer.size)
} }
fun write(buffer: String) { /**
if (buffer.isEmpty()) return * 写入单个 Int
write(buffer.toByteArray()) */
}
fun write(buffer: Int) { fun write(buffer: Int) {
write(ByteBuffer.allocate(Integer.BYTES).putInt(buffer).flip().array()) write(ByteBuffer.allocate(Integer.BYTES).putInt(buffer).flip().array())
} }
@@ -43,4 +47,8 @@ interface PtyConnector {
*/ */
fun close() fun close()
/**
* 编码
*/
fun getCharset(): Charset = Charsets.UTF_8
} }

View File

@@ -1,5 +1,7 @@
package app.termora.terminal package app.termora.terminal
import java.nio.charset.Charset
open class PtyConnectorDelegate( open class PtyConnectorDelegate(
@Volatile var ptyConnector: PtyConnector? = null @Volatile var ptyConnector: PtyConnector? = null
) : PtyConnector { ) : PtyConnector {
@@ -26,5 +28,7 @@ open class PtyConnectorDelegate(
ptyConnector = null ptyConnector = null
} }
override fun getCharset(): Charset {
return ptyConnector?.getCharset() ?: super.getCharset()
}
} }

View File

@@ -20,9 +20,6 @@ class PtyProcessConnector(private val process: PtyProcess, private val charset:
output.flush() output.flush()
} }
override fun write(buffer: String) {
write(buffer.toByteArray(charset))
}
override fun resize(rows: Int, cols: Int) { override fun resize(rows: Int, cols: Int) {
process.winSize = WinSize(cols, rows) process.winSize = WinSize(cols, rows)
@@ -38,5 +35,7 @@ class PtyProcessConnector(private val process: PtyProcess, private val charset:
process.destroyForcibly() process.destroyForcibly()
} }
override fun getCharset(): Charset {
return charset
}
} }

View File

@@ -0,0 +1,37 @@
package app.termora.terminal
class SystemCommandSequence {
private var isTerminated = false
private val command = StringBuilder()
/**
* @return 返回 true 表示处理完毕
*/
fun process(c: Char): Boolean {
if (isTerminated) {
throw UnsupportedOperationException("Cannot be processed, call the reset method")
}
command.append(c)
if (c == ControlCharacters.BEL || c == ControlCharacters.ST) {
command.deleteAt(command.lastIndex)
isTerminated = true
} else if (c == '\\' && command.length >= 2 && command[command.length - 2] == ControlCharacters.ESC) {
command.deleteAt(command.lastIndex)
command.deleteAt(command.lastIndex)
isTerminated = true
}
return isTerminated
}
fun getCommand(): String {
return command.toString()
}
fun reset() {
isTerminated = false
command.clear()
}
}

View File

@@ -1,5 +1,6 @@
package app.termora.terminal package app.termora.terminal
import org.apache.commons.lang3.ArrayUtils
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.Toolkit import java.awt.Toolkit
import kotlin.reflect.cast import kotlin.reflect.cast
@@ -8,7 +9,7 @@ open class TerminalModelImpl(private val terminal: Terminal) : TerminalModel {
private var rows: Int = 27 private var rows: Int = 27
private var cols: Int = 80 private var cols: Int = 80
private val data = mutableMapOf<DataKey<*>, Any>() private val data = mutableMapOf<DataKey<*>, Any>()
private val listeners = mutableListOf<DataListener>() private var listeners = emptyArray<DataListener>()
private val colorPalette = ColorPaletteImpl(terminal) private val colorPalette = ColorPaletteImpl(terminal)
companion object { companion object {
@@ -92,11 +93,11 @@ open class TerminalModelImpl(private val terminal: Terminal) : TerminalModel {
} }
override fun addDataListener(listener: DataListener) { override fun addDataListener(listener: DataListener) {
listeners.add(listener) listeners = ArrayUtils.add(listeners, listener)
} }
override fun removeDataListener(listener: DataListener) { override fun removeDataListener(listener: DataListener) {
listeners.remove(listener) listeners = ArrayUtils.removeElement(listeners, listener)
} }
override fun bell() { override fun bell() {
@@ -129,9 +130,8 @@ open class TerminalModelImpl(private val terminal: Terminal) : TerminalModel {
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
protected fun <T : Any> fireDataChanged(key: DataKey<T>, data: T) { protected fun <T : Any> fireDataChanged(key: DataKey<T>, data: T) {
val size = listeners.size for (listener in listeners) {
for (i in 0 until size) { listener.onChanged(key, data)
listeners.getOrNull(i)?.onChanged(key, data)
} }
} }

View File

@@ -129,7 +129,7 @@ private class MyProcessor(private val terminal: Terminal, reader: TerminalReader
TerminalState.CSI to ControlSequenceIntroducerProcessor(terminal, reader), TerminalState.CSI to ControlSequenceIntroducerProcessor(terminal, reader),
TerminalState.OSC to OperatingSystemCommandProcessor(terminal, reader), TerminalState.OSC to OperatingSystemCommandProcessor(terminal, reader),
TerminalState.ESC_LPAREN to EscapeDesignateCharacterSetProcessor(terminal, reader), TerminalState.ESC_LPAREN to EscapeDesignateCharacterSetProcessor(terminal, reader),
TerminalState.DCS to DeviceControlProcessor(terminal), TerminalState.DCS to DeviceControlStringProcessor(terminal, reader),
TerminalState.Text to TextProcessor(terminal, reader), TerminalState.Text to TextProcessor(terminal, reader),
) )

View File

@@ -1,8 +1,8 @@
package app.termora.terminal.panel package app.termora.terminal.panel
import app.termora.Database
import app.termora.DynamicColor import app.termora.DynamicColor
import app.termora.assertEventDispatchThread import app.termora.assertEventDispatchThread
import app.termora.Database
import app.termora.terminal.* import app.termora.terminal.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
@@ -49,6 +49,8 @@ class TerminalDisplay(
init { init {
terminalPanel.addTerminalPaintListener(toaster) terminalPanel.addTerminalPaintListener(toaster)
putClientProperty(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) putClientProperty(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON)
cursor = Cursor.getPredefinedCursor(Cursor.TEXT_CURSOR)
} }
override fun paint(g: Graphics) { override fun paint(g: Graphics) {

View File

@@ -1,5 +1,6 @@
package app.termora.terminal.panel package app.termora.terminal.panel
import app.termora.Disposable
import app.termora.actions.DataProvider import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders import app.termora.actions.DataProviders
@@ -30,7 +31,7 @@ import kotlin.time.Duration.Companion.milliseconds
class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnector) : class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnector) :
JPanel(BorderLayout()), DataProvider { JPanel(BorderLayout()), DataProvider, Disposable {
companion object { companion object {
val Debug = DataKey(Boolean::class) val Debug = DataKey(Boolean::class)
@@ -298,7 +299,7 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
// 输入法提交 // 输入法提交
if (committedCharacterCount > 0) { if (committedCharacterCount > 0) {
ptyConnector.write(sb.toString()) ptyConnector.write(sb.toString().toByteArray(ptyConnector.getCharset()))
} else { } else {
val breakIterator = BreakIterator.getCharacterInstance() val breakIterator = BreakIterator.getCharacterInstance()
val chars = mutableListOf<Char>() val chars = mutableListOf<Char>()
@@ -397,16 +398,20 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
* 执行粘贴操作 * 执行粘贴操作
*/ */
fun paste(text: String) { fun paste(text: String) {
val content = if (SystemInfo.isWindows) { var content = text
text.replace("${ControlCharacters.CR}${ControlCharacters.LF}", "${ControlCharacters.LF}") if (!SystemInfo.isWindows) {
} else { content = content.replace("\r\n", "\n")
text.replace(ControlCharacters.LF, ControlCharacters.CR)
} }
content = content.replace('\n', '\r')
if (terminal.getTerminalModel().getData(DataKey.BracketedPasteMode, false)) { if (terminal.getTerminalModel().getData(DataKey.BracketedPasteMode, false)) {
ptyConnector.write("${ControlCharacters.ESC}[200~${content}${ControlCharacters.ESC}[201~") ptyConnector.write(
"${ControlCharacters.ESC}[200~${content}${ControlCharacters.ESC}[201~".toByteArray(
ptyConnector.getCharset()
)
)
} else { } else {
ptyConnector.write(content) ptyConnector.write(content.toByteArray(ptyConnector.getCharset()))
} }
terminal.getScrollingModel().scrollToRow( terminal.getScrollingModel().scrollToRow(

View File

@@ -1,5 +1,7 @@
package app.termora.terminal.panel package app.termora.terminal.panel
import app.termora.keymap.KeyShortcut
import app.termora.keymap.KeymapManager
import app.termora.terminal.PtyConnector import app.termora.terminal.PtyConnector
import app.termora.terminal.Terminal import app.termora.terminal.Terminal
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
@@ -12,8 +14,9 @@ class TerminalPanelKeyAdapter(
private val terminalPanel: TerminalPanel, private val terminalPanel: TerminalPanel,
private val terminal: Terminal, private val terminal: Terminal,
private val ptyConnector: PtyConnector private val ptyConnector: PtyConnector
) : ) : KeyAdapter() {
KeyAdapter() {
private val activeKeymap get() = KeymapManager.getInstance().getActiveKeymap()
override fun keyTyped(e: KeyEvent) { override fun keyTyped(e: KeyEvent) {
if (Character.isISOControl(e.keyChar)) { if (Character.isISOControl(e.keyChar)) {
@@ -21,7 +24,7 @@ class TerminalPanelKeyAdapter(
} }
terminal.getSelectionModel().clearSelection() terminal.getSelectionModel().clearSelection()
ptyConnector.write("${e.keyChar}") ptyConnector.write("${e.keyChar}".toByteArray(ptyConnector.getCharset()))
terminal.getScrollingModel().scrollTo(Int.MAX_VALUE) terminal.getScrollingModel().scrollTo(Int.MAX_VALUE)
} }
@@ -44,7 +47,7 @@ class TerminalPanelKeyAdapter(
val encode = terminal.getKeyEncoder().encode(AWTTerminalKeyEvent(e)) val encode = terminal.getKeyEncoder().encode(AWTTerminalKeyEvent(e))
if (encode.isNotEmpty()) { if (encode.isNotEmpty()) {
ptyConnector.write(encode) ptyConnector.write(encode.toByteArray(ptyConnector.getCharset()))
} }
// https://github.com/TermoraDev/termora/issues/52 // https://github.com/TermoraDev/termora/issues/52
@@ -52,11 +55,16 @@ class TerminalPanelKeyAdapter(
return return
} }
// 如果命中了全局快捷键,那么不处理
if (keyStroke.modifiers != 0 && activeKeymap.getActionIds(KeyShortcut(keyStroke)).isNotEmpty()) {
return
}
if (Character.isISOControl(e.keyChar)) { if (Character.isISOControl(e.keyChar)) {
terminal.getSelectionModel().clearSelection() terminal.getSelectionModel().clearSelection()
// 如果不为空表示已经发送过了,所以这里为空的时候再发送 // 如果不为空表示已经发送过了,所以这里为空的时候再发送
if (encode.isEmpty()) { if (encode.isEmpty()) {
ptyConnector.write("${e.keyChar}") ptyConnector.write("${e.keyChar}".toByteArray(ptyConnector.getCharset()))
} }
terminal.getScrollingModel().scrollTo(Int.MAX_VALUE) terminal.getScrollingModel().scrollTo(Int.MAX_VALUE)
} }

View File

@@ -70,9 +70,9 @@ class TerminalPanelMouseTrackingAdapter(
val encode = terminal.getKeyEncoder() val encode = terminal.getKeyEncoder()
.encode(TerminalKeyEvent(if (e.wheelRotation < 0) KeyEvent.VK_UP else KeyEvent.VK_DOWN)) .encode(TerminalKeyEvent(if (e.wheelRotation < 0) KeyEvent.VK_UP else KeyEvent.VK_DOWN))
if (encode.isBlank()) return if (encode.isBlank()) return
val bytes = encode.toByteArray(ptyConnector.getCharset())
for (i in 0 until abs(unitsToScroll)) { for (i in 0 until abs(unitsToScroll)) {
ptyConnector.write(encode) ptyConnector.write(bytes)
} }
} }
} }

View File

@@ -9,7 +9,10 @@ import java.io.FileNotFoundException
import java.nio.file.Files import java.nio.file.Files
import javax.swing.Icon import javax.swing.Icon
class LogViewerTerminalTab(windowScope: WindowScope, private val file: File) : PtyHostTerminalTab( class LogViewerTerminalTab(
windowScope: WindowScope,
private val file: File,
) : PtyHostTerminalTab(
windowScope, windowScope,
Host( Host(
name = file.name, name = file.name,

View File

@@ -25,9 +25,8 @@ import java.awt.Component
import java.awt.Desktop import java.awt.Desktop
import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
import java.awt.dnd.DnDConstants import java.awt.datatransfer.Transferable
import java.awt.dnd.DropTarget import java.awt.datatransfer.UnsupportedFlavorException
import java.awt.dnd.DropTargetDropEvent
import java.awt.event.ActionEvent import java.awt.event.ActionEvent
import java.awt.event.MouseAdapter import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
@@ -80,6 +79,8 @@ class FileSystemPanel(
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdir.toString()) bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdir.toString())
table.setUI(FlatTableUI()) table.setUI(FlatTableUI())
table.dragEnabled = true
table.dropMode = DropMode.INSERT_ROWS
table.rowHeight = UIManager.getInt("Table.rowHeight") table.rowHeight = UIManager.getInt("Table.rowHeight")
table.autoResizeMode = JTable.AUTO_RESIZE_OFF table.autoResizeMode = JTable.AUTO_RESIZE_OFF
table.fillsViewportHeight = true table.fillsViewportHeight = true
@@ -231,17 +232,45 @@ class FileSystemPanel(
} }
}) })
// 本地文件系统不支持本地拖拽进去
if (!tableModel.isLocalFileSystem) { table.transferHandler = object : TransferHandler() {
table.dropTarget = object : DropTarget() { override fun canImport(support: TransferSupport): Boolean {
override fun drop(dtde: DropTargetDropEvent) { if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
dtde.acceptDrop(DnDConstants.ACTION_COPY) val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
val files = dtde.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*> return data is FileSystemTableRowTransferable && data.fileSystemPanel != this@FileSystemPanel
if (files.isEmpty()) return } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
copyLocalFileToFileSystem(files.filterIsInstance<File>()) return !tableModel.isLocalFileSystem
} }
}.apply { return false
this.defaultActions = DnDConstants.ACTION_COPY }
override fun importData(comp: JComponent?, t: Transferable): Boolean {
if (t.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = t.getTransferData(FileSystemTableRowTransferable.dataFlavor)
if (data !is FileSystemTableRowTransferable) {
return false
}
data.fileSystemPanel.transport(data.paths)
return true
} else if (t.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
val files = t.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return false
copyLocalFileToFileSystem(files.filterIsInstance<File>())
return true
}
return false
}
override fun getSourceActions(c: JComponent?): Int {
return COPY
}
override fun createTransferable(c: JComponent?): Transferable? {
val paths = table.selectedRows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
if (paths.isEmpty()) {
return null
}
return FileSystemTableRowTransferable(this@FileSystemPanel, paths)
} }
} }
@@ -838,4 +867,28 @@ class FileSystemPanel(
} }
private class FileSystemTableRowTransferable(
val fileSystemPanel: FileSystemPanel,
val paths: List<FileSystemTableModel.CacheablePath>
) : Transferable {
companion object {
val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable")
}
override fun getTransferDataFlavors(): Array<DataFlavor> {
return arrayOf(dataFlavor)
}
override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean {
return flavor == dataFlavor
}
override fun getTransferData(flavor: DataFlavor?): Any {
if (flavor != dataFlavor) {
throw UnsupportedFlavorException(flavor)
}
return this
}
}
} }

View File

@@ -66,7 +66,10 @@ class FileSystemTableModel(private val fileSystem: FileSystem) : DefaultTableMod
when (column) { when (column) {
COLUMN_NAME -> path COLUMN_NAME -> path
COLUMN_FILE_SIZE -> if (path.isDirectory) StringUtils.EMPTY else formatBytes(path.fileSize) COLUMN_FILE_SIZE -> if (path.isDirectory) StringUtils.EMPTY else formatBytes(path.fileSize)
COLUMN_TYPE -> if (path.isDirectory) I18n.getString("termora.transport.table.type.folder") else path.extension COLUMN_TYPE -> if (path.isDirectory) I18n.getString("termora.transport.table.type.folder")
else if (path.isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link")
else path.extension
COLUMN_LAST_MODIFIED_TIME -> DateFormatUtils.format(Date(path.lastModifiedTime), "yyyy/MM/dd HH:mm") COLUMN_LAST_MODIFIED_TIME -> DateFormatUtils.format(Date(path.lastModifiedTime), "yyyy/MM/dd HH:mm")
// 如果是本地的并且还是Windows系统 // 如果是本地的并且还是Windows系统
@@ -173,6 +176,7 @@ class FileSystemTableModel(private val fileSystem: FileSystem) : DefaultTableMod
val extension by lazy { path.extension } val extension by lazy { path.extension }
open val isDirectory by lazy { path.isDirectory() } open val isDirectory by lazy { path.isDirectory() }
open val isSymbolicLink by lazy { path.isSymbolicLink() }
open val isHidden by lazy { fileName != ".." && path.isHidden() } open val isHidden by lazy { fileName != ".." && path.isHidden() }
open val fileSize by lazy { path.fileSize() } open val fileSize by lazy { path.fileSize() }
open val lastModifiedTime by lazy { Files.getLastModifiedTime(path).toMillis() } open val lastModifiedTime by lazy { Files.getLastModifiedTime(path).toMillis() }
@@ -227,8 +231,10 @@ class FileSystemTableModel(private val fileSystem: FileSystem) : DefaultTableMod
} }
} }
override val isDirectory: Boolean override val isDirectory by lazy { attributes.isDirectory || (isSymbolicLink && Files.isDirectory(path)) }
get() = attributes.isDirectory
override val isSymbolicLink: Boolean
get() = attributes.isSymbolicLink
override val isHidden: Boolean override val isHidden: Boolean
get() = fileName != ".." && fileName.startsWith(".") get() = fileName != ".." && fileName.startsWith(".")

View File

@@ -11,6 +11,8 @@ termora.date-format=MM/dd/yyyy hh:mm:ss a
termora.finder=Finder termora.finder=Finder
termora.folder=Folder termora.folder=Folder
termora.explorer=Explorer termora.explorer=Explorer
termora.quit-confirm=Quit {0}?
# update # update
termora.update.title=New version termora.update.title=New version
@@ -61,16 +63,22 @@ termora.settings.terminal.font=Font
termora.settings.terminal.size=Size termora.settings.terminal.size=Size
termora.settings.terminal.max-rows=Max rows termora.settings.terminal.max-rows=Max rows
termora.settings.terminal.debug=Debug mode termora.settings.terminal.debug=Debug mode
termora.settings.terminal.beep=Beep
termora.settings.terminal.select-copy=Select copy termora.settings.terminal.select-copy=Select copy
termora.settings.terminal.cursor-style=Cursor type termora.settings.terminal.cursor-style=Cursor type
termora.settings.terminal.local-shell=Local shell termora.settings.terminal.local-shell=Local shell
termora.settings.terminal.auto-close-tab=Auto Close Tab
termora.settings.terminal.auto-close-tab-description=Automatically close the tab when the terminal is disconnected normally
termora.settings.sync=Sync termora.settings.sync=Sync
termora.settings.sync.push=Push termora.settings.sync.push=Push
termora.settings.sync.push-warning=Pushing will overwrite the configuration. It is recommended to pull before pushing termora.settings.sync.push-warning=Pushing will overwrite the configuration. It is recommended to pull before pushing
termora.settings.sync.pull=Pull termora.settings.sync.pull=Pull
termora.settings.sync.done=Synchronized data successfully termora.settings.sync.done=Synchronized data successfully
termora.settings.sync.export=Export termora.settings.sync.export=${termora.keymgr.export}
termora.settings.sync.import=${termora.keymgr.import}
termora.settings.sync.import.file-too-large=The file is too large
termora.settings.sync.import.successful=Import data successfully
termora.settings.sync.export-done=The export was successful termora.settings.sync.export-done=The export was successful
termora.settings.sync.export-done-open-folder=The export was successful. Do you want to open the folder? termora.settings.sync.export-done-open-folder=The export was successful. Do you want to open the folder?
termora.settings.sync.range=Range termora.settings.sync.range=Range
@@ -80,6 +88,7 @@ termora.settings.sync.last-sync-time=Last sync time
termora.settings.sync.gist=Gist termora.settings.sync.gist=Gist
termora.settings.sync.token=Token termora.settings.sync.token=Token
termora.settings.sync.type=Type termora.settings.sync.type=Type
termora.settings.sync.webdav.help=WebDAV storage address, e.g. https://yourhost/webdav/termora.json
termora.settings.about=About termora.settings.about=About
termora.settings.about.author=Author termora.settings.about.author=Author
@@ -106,10 +115,13 @@ termora.find-everywhere.groups.opened-hosts=Opened hosts
termora.find-everywhere.groups.tools=Tools termora.find-everywhere.groups.tools=Tools
termora.find-everywhere.groups.settings=${termora.setting} termora.find-everywhere.groups.settings=${termora.setting}
termora.find-everywhere.quick-command.local-terminal=Local Terminal termora.find-everywhere.quick-command.local-terminal=Local Terminal
termora.find-everywhere.double-shift-deprecated=The double-click Shift shortcut will be removed in a future version
termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywhere.double-shift-deprecated}, use {0} instead
# Welcome # Welcome
termora.welcome.my-hosts=My hosts termora.welcome.my-hosts=My hosts
termora.welcome.contextmenu.open=Open termora.welcome.contextmenu.open=Open
termora.welcome.contextmenu.open-in-new-window=${termora.tabbed.contextmenu.open-in-new-window}
termora.welcome.contextmenu.copy=${termora.copy} termora.welcome.contextmenu.copy=${termora.copy}
termora.welcome.contextmenu.remove=${termora.remove} termora.welcome.contextmenu.remove=${termora.remove}
termora.welcome.contextmenu.rename=Rename termora.welcome.contextmenu.rename=Rename
@@ -141,6 +153,14 @@ termora.new-host.terminal.heartbeat-interval=Heartbeat Interval
termora.new-host.terminal.startup-commands=Startup Command termora.new-host.terminal.startup-commands=Startup Command
termora.new-host.terminal.env=Environment termora.new-host.terminal.env=Environment
termora.new-host.serial=Serial
termora.new-host.serial.port=Port
termora.new-host.serial.baud-rate=Baud rate
termora.new-host.serial.data-bits=Data bits
termora.new-host.serial.parity=Parity
termora.new-host.serial.stop-bits=Stop bits
termora.new-host.serial.flow-control=Flow control
termora.new-host.tunneling=Tunneling termora.new-host.tunneling=Tunneling
termora.new-host.tunneling.table.name=Name termora.new-host.tunneling.table.name=Name
termora.new-host.tunneling.table.type=Type termora.new-host.tunneling.table.type=Type
@@ -168,6 +188,12 @@ termora.keymgr.table.type=Type
termora.keymgr.table.length=Length termora.keymgr.table.length=Length
termora.keymgr.table.remark=Description termora.keymgr.table.remark=Description
termora.keymgr.ssh-copy-id.number=Number of hosts [{0}] Number of public keys [{1}]
termora.keymgr.ssh-copy-id.successful=${termora.terminal.copied}
termora.keymgr.ssh-copy-id.failed=Copy Failure
termora.keymgr.ssh-copy-id.end=End of public key copying
# Tabbed # Tabbed
termora.tabbed.contextmenu.rename=Rename termora.tabbed.contextmenu.rename=Rename
termora.tabbed.contextmenu.clone=Clone termora.tabbed.contextmenu.clone=Clone
@@ -221,6 +247,7 @@ termora.transport.bookmarks.down=Down
termora.transport.table.filename=Filename termora.transport.table.filename=Filename
termora.transport.table.type=Type termora.transport.table.type=Type
termora.transport.table.type.folder=${termora.welcome.contextmenu.new.folder} termora.transport.table.type.folder=${termora.welcome.contextmenu.new.folder}
termora.transport.table.type.symbolic-link=Symbolic Link
termora.transport.table.size=Size termora.transport.table.size=Size
termora.transport.table.modified-time=Modified termora.transport.table.modified-time=Modified
termora.transport.table.permissions=Permissions termora.transport.table.permissions=Permissions
@@ -298,6 +325,8 @@ termora.actions.switch-tab=Switch to specific Tab [1..9]
# Terminal # Terminal
termora.terminal.size=Size: {0} x {1} termora.terminal.size=Size: {0} x {1}
termora.terminal.copied=Copied termora.terminal.copied=Copied
termora.terminal.channel-disconnected=Channel has been disconnected.\u0020
termora.terminal.channel-reconnect=Type {0} to reconnect.
# zmodem # zmodem

View File

@@ -10,6 +10,7 @@ termora.date-format=yyyy-MM-dd HH:mm:ss
termora.finder=访达 termora.finder=访达
termora.folder=文件夹 termora.folder=文件夹
termora.explorer=文件管理器 termora.explorer=文件管理器
termora.quit-confirm=你要退出 {0} 吗?
# update # update
termora.update.title=新版本 termora.update.title=新版本
@@ -59,31 +60,38 @@ termora.find-everywhere.groups.opened-hosts=已打开的主机
termora.find-everywhere.groups.tools=工具 termora.find-everywhere.groups.tools=工具
termora.find-everywhere.groups.settings=${termora.setting} termora.find-everywhere.groups.settings=${termora.setting}
termora.find-everywhere.quick-command.local-terminal=本地终端 termora.find-everywhere.quick-command.local-terminal=本地终端
termora.find-everywhere.double-shift-deprecated=双击 Shift 快捷键将会在未来的版本中移除
termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywhere.double-shift-deprecated},请使用 {0} 代替
termora.settings.terminal=终端 termora.settings.terminal=终端
termora.settings.terminal.font=字体 termora.settings.terminal.font=字体
termora.settings.terminal.size=大小 termora.settings.terminal.size=大小
termora.settings.terminal.max-rows=最大行数 termora.settings.terminal.max-rows=最大行数
termora.settings.terminal.debug=调试模式 termora.settings.terminal.debug=调试模式
termora.settings.terminal.beep=蜂鸣声
termora.settings.terminal.select-copy=选中复制 termora.settings.terminal.select-copy=选中复制
termora.settings.terminal.cursor-style=光标样式 termora.settings.terminal.cursor-style=光标样式
termora.settings.terminal.local-shell=本地终端 termora.settings.terminal.local-shell=本地终端
termora.settings.terminal.auto-close-tab=自动关闭标签
termora.settings.terminal.auto-close-tab-description=当终端正常断开连接时自动关闭标签页
termora.settings.sync=同步 termora.settings.sync=同步
termora.settings.sync.push=推送 termora.settings.sync.push=推送
termora.settings.sync.push-warning=推送将覆盖已有配置,建议先拉取再推送 termora.settings.sync.push-warning=推送将覆盖已有配置,建议先拉取再推送
termora.settings.sync.pull=拉取 termora.settings.sync.pull=拉取
termora.settings.sync.export=导出
termora.settings.sync.export-done=导出成功 termora.settings.sync.export-done=导出成功
termora.settings.sync.export-done-open-folder=导出成功,是否需要打开所在文件夹? termora.settings.sync.export-done-open-folder=导出成功,是否需要打开所在文件夹?
termora.settings.sync.range=范围 termora.settings.sync.range=范围
termora.settings.sync.range.keys=我的密钥 termora.settings.sync.range.keys=我的密钥
termora.settings.sync.last-sync-time=最后同步时间 termora.settings.sync.last-sync-time=最后同步时间
termora.settings.sync.done=同步数据成功 termora.settings.sync.done=同步数据成功
termora.settings.sync.import.file-too-large=文件太大
termora.settings.sync.import.successful=导入数据成功
termora.settings.sync.gist=片段 termora.settings.sync.gist=片段
termora.settings.sync.token=令牌 termora.settings.sync.token=令牌
termora.settings.sync.type=类型 termora.settings.sync.type=类型
termora.settings.sync.webdav.help=WebDAV 的存储地址例如https://yourhost/webdav/termora.json
termora.settings.about=关于 termora.settings.about=关于
termora.settings.about.author=作者 termora.settings.about.author=作者
@@ -131,6 +139,14 @@ termora.new-host.terminal.startup-commands=启动命令
termora.new-host.terminal.env=环境 termora.new-host.terminal.env=环境
termora.new-host.serial=串口
termora.new-host.serial.port=端口
termora.new-host.serial.baud-rate=波特率
termora.new-host.serial.data-bits=数据位
termora.new-host.serial.parity=校验位
termora.new-host.serial.stop-bits=停止位
termora.new-host.serial.flow-control=流控
termora.new-host.test-connection=测试连接 termora.new-host.test-connection=测试连接
termora.new-host.test-connection-successful=连接成功 termora.new-host.test-connection-successful=连接成功
@@ -158,6 +174,10 @@ termora.keymgr.table.type=类型
termora.keymgr.table.length=长度 termora.keymgr.table.length=长度
termora.keymgr.table.remark=备注 termora.keymgr.table.remark=备注
termora.keymgr.ssh-copy-id.number=主机数量 [{0}] 公钥数量 [{1}]
termora.keymgr.ssh-copy-id.failed=复制失败
termora.keymgr.ssh-copy-id.end=复制公钥结束
# Tools # Tools
termora.tools.multiple=将命令发送到所有会话 termora.tools.multiple=将命令发送到所有会话
@@ -217,6 +237,7 @@ termora.transport.bookmarks.down=下移
termora.transport.table.filename=文件名 termora.transport.table.filename=文件名
termora.transport.table.type=类型 termora.transport.table.type=类型
termora.transport.table.size=大小 termora.transport.table.size=大小
termora.transport.table.type.symbolic-link=软链接
termora.transport.table.modified-time=修改时间 termora.transport.table.modified-time=修改时间
termora.transport.table.permissions=权限 termora.transport.table.permissions=权限
termora.transport.table.owner=所有者 termora.transport.table.owner=所有者
@@ -273,6 +294,8 @@ termora.toolbar.customize-toolbar=自定义工具栏...
termora.terminal.size=大小: {0} x {1} termora.terminal.size=大小: {0} x {1}
termora.terminal.copied=已复制 termora.terminal.copied=已复制
termora.terminal.channel-disconnected=终端断开连接,
termora.terminal.channel-reconnect=按 {0} 进行重连。
# Actions # Actions

View File

@@ -9,6 +9,7 @@ termora.date-format=yyyy/MM/dd HH:mm:ss
termora.finder=訪達 termora.finder=訪達
termora.folder=資料夾 termora.folder=資料夾
termora.explorer=檔案管理器 termora.explorer=檔案管理器
termora.quit-confirm=你要退出 {0} 嗎?
# update # update
termora.update.title=新版本 termora.update.title=新版本
@@ -64,30 +65,37 @@ termora.find-everywhere.groups.opened-hosts=已開啟的主機
termora.find-everywhere.groups.tools=工具 termora.find-everywhere.groups.tools=工具
termora.find-everywhere.groups.settings=${termora.setting} termora.find-everywhere.groups.settings=${termora.setting}
termora.find-everywhere.quick-command.local-terminal=本地端 termora.find-everywhere.quick-command.local-terminal=本地端
termora.find-everywhere.double-shift-deprecated=雙擊 Shift 快捷鍵將會在未來的版本中移除
termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywhere.double-shift-deprecated},請使用 {0} 代替
termora.settings.terminal=終端 termora.settings.terminal=終端
termora.settings.terminal.font=字體 termora.settings.terminal.font=字體
termora.settings.terminal.size=大小 termora.settings.terminal.size=大小
termora.settings.terminal.max-rows=最大行數 termora.settings.terminal.max-rows=最大行數
termora.settings.terminal.debug=偵錯模式 termora.settings.terminal.debug=偵錯模式
termora.settings.terminal.beep=蜂鳴聲
termora.settings.terminal.select-copy=選取複製 termora.settings.terminal.select-copy=選取複製
termora.settings.terminal.cursor-style=遊標風格 termora.settings.terminal.cursor-style=遊標風格
termora.settings.terminal.local-shell=本地端 termora.settings.terminal.local-shell=本地端
termora.settings.terminal.auto-close-tab=自動關閉標籤
termora.settings.terminal.auto-close-tab-description=當終端正常斷開連線時自動關閉標籤頁
termora.settings.sync=同步 termora.settings.sync=同步
termora.settings.sync.push=推送 termora.settings.sync.push=推送
termora.settings.sync.push-warning=推送將覆蓋先前的配置,建議先拉取再推送 termora.settings.sync.push-warning=推送將覆蓋先前的配置,建議先拉取再推送
termora.settings.sync.pull=拉取 termora.settings.sync.pull=拉取
termora.settings.sync.export=匯出
termora.settings.sync.export-done=匯出成功 termora.settings.sync.export-done=匯出成功
termora.settings.sync.export-done-open-folder=匯出成功,是否需要打開所在資料夾? termora.settings.sync.export-done-open-folder=匯出成功,是否需要打開所在資料夾?
termora.settings.sync.range=範圍 termora.settings.sync.range=範圍
termora.settings.sync.range.keys=我的密鑰 termora.settings.sync.range.keys=我的密鑰
termora.settings.sync.last-sync-time=最後同步時間 termora.settings.sync.last-sync-time=最後同步時間
termora.settings.sync.done=同步資料成功 termora.settings.sync.done=同步資料成功
termora.settings.sync.import.file-too-large=檔案太大
termora.settings.sync.import.successful=導入資料成功
termora.settings.sync.gist=片段 termora.settings.sync.gist=片段
termora.settings.sync.token=令牌 termora.settings.sync.token=令牌
termora.settings.sync.type=類型 termora.settings.sync.type=類型
termora.settings.sync.webdav.help=WebDAV 的儲存位址例如https://yourhost/webdav/termora.json
termora.settings.about=關於 termora.settings.about=關於
termora.settings.about.author=作者 termora.settings.about.author=作者
@@ -129,6 +137,14 @@ termora.new-host.terminal.startup-commands=啟動命令
termora.new-host.terminal.heartbeat-interval=心跳間隔 termora.new-host.terminal.heartbeat-interval=心跳間隔
termora.new-host.terminal.env=環境 termora.new-host.terminal.env=環境
termora.new-host.serial=串口
termora.new-host.serial.port=端口
termora.new-host.serial.baud-rate=波特率
termora.new-host.serial.data-bits=資料位
termora.new-host.serial.parity=校驗位
termora.new-host.serial.stop-bits=停止位
termora.new-host.serial.flow-control=流控
termora.new-host.test-connection=測試連接 termora.new-host.test-connection=測試連接
termora.new-host.test-connection-successful=連線成功 termora.new-host.test-connection-successful=連線成功
@@ -155,6 +171,10 @@ termora.keymgr.table.type=型別
termora.keymgr.table.length=長度 termora.keymgr.table.length=長度
termora.keymgr.table.remark=備註 termora.keymgr.table.remark=備註
termora.keymgr.ssh-copy-id.number=主機數量 [{0}] 公鑰數量 [{1}]
termora.keymgr.ssh-copy-id.failed=複製失敗
termora.keymgr.ssh-copy-id.end=複製公鑰結束
# Tools # Tools
termora.tools.multiple=將指令傳送到所有會話 termora.tools.multiple=將指令傳送到所有會話
@@ -211,6 +231,7 @@ termora.transport.bookmarks.down=下移
termora.transport.table.filename=檔名 termora.transport.table.filename=檔名
termora.transport.table.type=類型 termora.transport.table.type=類型
termora.transport.table.size=大小 termora.transport.table.size=大小
termora.transport.table.type.symbolic-link=軟連結
termora.transport.table.modified-time=修改時間 termora.transport.table.modified-time=修改時間
termora.transport.table.permissions=權限 termora.transport.table.permissions=權限
termora.transport.table.owner=所有者 termora.transport.table.owner=所有者
@@ -255,6 +276,8 @@ termora.toolbar.customize-toolbar=自訂工具列...
termora.terminal.size=大小: {0} x {1} termora.terminal.size=大小: {0} x {1}
termora.terminal.copied=已複製 termora.terminal.copied=已複製
termora.terminal.channel-disconnected=終端機連線中斷,
termora.terminal.channel-reconnect=按 {0} 進行重新連線。
# Actions # Actions
termora.actions.copy-from-terminal=從終端複製 termora.actions.copy-from-terminal=從終端複製

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 4H7C5.34315 4 4 5.34315 4 7V9C4 10.6569 5.34315 12 7 12H11V11V10V6V5V4ZM12 5V4C12 3.44772 11.5523 3 11 3H7C5.13616 3 3.57006 4.27477 3.12602 6H1C0.447715 6 0 6.44772 0 7V9C0 9.55228 0.447715 10 1 10H3.12602C3.57006 11.7252 5.13616 13 7 13H11C11.5523 13 12 12.5523 12 12V11H15.5C15.7761 11 16 10.7761 16 10.5C16 10.2239 15.7761 10 15.5 10H12V6H15.5C15.7761 6 16 5.77614 16 5.5C16 5.22386 15.7761 5 15.5 5H12ZM3 7V9H1V7L3 7Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 724 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 4H7C5.34315 4 4 5.34315 4 7V9C4 10.6569 5.34315 12 7 12H11V11V10V6V5V4ZM12 5V4C12 3.44772 11.5523 3 11 3H7C5.13616 3 3.57006 4.27477 3.12602 6H1C0.447715 6 0 6.44772 0 7V9C0 9.55228 0.447715 10 1 10H3.12602C3.57006 11.7252 5.13616 13 7 13H11C11.5523 13 12 12.5523 12 12V11H15.5C15.7761 11 16 10.7761 16 10.5C16 10.2239 15.7761 10 15.5 10H12V6H15.5C15.7761 6 16 5.77614 16 5.5C16 5.22386 15.7761 5 15.5 5H12ZM3 7V9H1V7L3 7Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 724 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 7.13397C14.1667 7.51888 14.1667 8.48113 13.5 8.86603L4.5 14.0622C3.83333 14.4471 3 13.966 3 13.1962L3 2.80385C3 2.03405 3.83333 1.55292 4.5 1.93782L13.5 7.13397Z" stroke="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 7.13397C14.1667 7.51888 14.1667 8.48113 13.5 8.86603L4.5 14.0622C3.83333 14.4471 3 13.966 3 13.1962L3 2.80385C3 2.03405 3.83333 1.55292 4.5 1.93782L13.5 7.13397Z" stroke="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -1,6 +1,6 @@
FROM linuxserver/openssh-server FROM linuxserver/openssh-server
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk update && apk add wget gcc g++ git make zsh htop && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \ && apk update && apk add wget gcc g++ git make zsh htop inetutils-telnet && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \
&& tar -xf lrzsz-0.12.20.tar.gz && cd lrzsz-0.12.20 && ./configure && make && make install \ && tar -xf lrzsz-0.12.20.tar.gz && cd lrzsz-0.12.20 && ./configure && make && make install \
&& ln -s /usr/local/bin/lrz /usr/local/bin/rz && ln -s /usr/local/bin/lsz /usr/local/bin/sz && ln -s /usr/local/bin/lrz /usr/local/bin/rz && ln -s /usr/local/bin/lsz /usr/local/bin/sz