commit 470b95cc426ce31e2d67c9e0f77e80154ac28aae Author: hstyi Date: Thu Jan 2 10:51:54 2025 +0800 Init Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a273c0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +.gradle +build/ +data/ +dist/ +certs/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Kotlin ### +.kotlin + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..79d000b --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Termora + +**Termora** 是一个终端模拟器和 SSH 客户端,支持 Windows,macOS 和 Linux。 + +
+ termora +
+ +**Termora** 采用 [Kotlin/JVM](https://kotlinlang.org/) 开发并实现了 [XTerm](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) 协议(尚未完全实现),它的最终目标是通过 [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) 实现全平台(含 Android、iOS、iPadOS 等)。 + +## 功能特性 + +- 支持 SSH 和本地终端 +- 支持 Windows、macOS、Linux 平台 +- 支持 Zmodem 协议 +- 支持 SSH 端口转发 +- 支持配置同步到 [Gist](https://gist.github.com) +- 支持宏(录制脚本并回放) +- 支持关键词高亮 +- 支持密钥管理器 +- 支持将命令发送到多个会话 +- 支持 [Find Everywhere](./docs/findeverywhere.png) 快速跳转 +- 支持数据加密 +- ... + +## 下载 + +- [releases](https://github.com/TermoraDev/termora/releases/latest) + +### macOS + +由于苹果开发者证书正在申请中,所以 macOS 用户需要执行 `sudo xattr -r -d com.apple.quarantine /Applications/Termora.app` 后才可以运行程序。 + +## 开发 + +建议使用 [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) 的 JDK 版本,通过 `./gradlew :run`即可运行程序。 + +通过 `./gradlew dist` 可以自动构建适用于本机的版本。在 macOS 上是:`dmg`,在 Windows 上是:`zip`,在 Linux 上是:`tar.gz`。 + +## 协议 + +本软件采用双重许可模式,您可以选择以下任意一种许可方式: + +- AGPL-3.0:根据 [AGPL-3.0](https://opensource.org/license/agpl-v3) 的条款,您可以自由使用、分发和修改本软件。 +- 专有许可:如果希望在闭源或专有环境中使用,请联系作者获取许可。 diff --git a/THIRDPARTY b/THIRDPARTY new file mode 100644 index 0000000..2178ef8 --- /dev/null +++ b/THIRDPARTY @@ -0,0 +1,231 @@ +annotations 24.0.1 +Apache License 2.0 +https://github.com/JetBrains/java-annotations/blob/master/LICENSE.txt + +bip39-lib-jvm 1.0.8 +MIT License +https://github.com/Electric-Coin-Company/kotlin-bip39/blob/main/LICENSE + +colorpicker 2.0.1 +BSD 3-Clause "New" or "Revised" License +https://github.com/dheid/colorpicker/blob/main/LICENSE + +commonmark 0.24.0 +BSD 2-Clause "Simplified" License +https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt + +commons-codec 1.17.1 +Apache License 2.0 +https://github.com/apache/commons-codec/blob/master/LICENSE.txt + +commons-compress 1.27.1 +Apache License 2.0 +https://github.com/apache/commons-compress/blob/master/LICENSE.txt + +commons-io 2.18.0 +Apache License 2.0 +https://github.com/apache/commons-io/blob/master/LICENSE.txt + +commons-lang3 3.17.0 +Apache License 2.0 +https://github.com/apache/commons-lang/blob/master/LICENSE.txt + +commons-net 3.11.1 +Apache License 2.0 +https://github.com/apache/commons-net/blob/master/LICENSE.txt + +commons-text 1.12.0 +Apache License 2.0 +https://github.com/apache/commons-text/blob/master/LICENSE.txt + +eddsa 0.3.0 +Creative Commons Zero v1.0 Universal +https://github.com/str4d/ed25519-java/blob/master/LICENSE.txt + +flatlaf 3.5.4 +Apache License 2.0 +https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE + +flatlaf-extras 3.5.4 +Apache License 2.0 +https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE + +flatlaf-swingx 3.5.4 +Apache License 2.0 +https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE + +JavaEWAH 1.2.3 +Apache License 2.0 +https://github.com/lemire/javaewah/blob/master/LICENSE + +jbr-api 17.1.10.1 +Apache License 2.0 +https://github.com/JetBrains/JetBrainsRuntimeApi/blob/main/LICENSE + +jcl-over-slf4j 1.7.36 +Apache License 2.0 +https://www.apache.org/licenses/LICENSE-2.0.txt + +jfa 1.2.0 +Apache License 2.0 +https://github.com/0x4a616e/jfa/blob/main/LICENSE + +jgoodies-common 1.8.1 +BSD-2-Clause License +http://www.opensource.org/licenses/bsd-license.html + +jgoodies-forms 1.9.0 +BSD-2-Clause License +http://www.opensource.org/licenses/bsd-license.html + +jna 5.16.0 +Apache License 2.0 +https://github.com/java-native-access/jna/blob/master/AL2.0 + +jna-platform 5.16.0 +Apache License 2.0 +https://github.com/java-native-access/jna/blob/master/AL2.0 + +jnafilechooser-api 1.1.2 +BSD 3-Clause "New" or "Revised" License +https://github.com/steos/jnafilechooser/blob/master/LICENSE + +jnafilechooser-win32 1.1.2 +BSD 3-Clause "New" or "Revised" License +https://github.com/steos/jnafilechooser/blob/master/LICENSE + +jsvg 1.4.0 +MIT License +https://github.com/weisJ/jsvg/blob/master/LICENSE + +jSystemThemeDetector 3.9.1 +Apache License 2.0 +https://github.com/Dansoftowner/jSystemThemeDetector/blob/master/LICENSE + +kotlin-logging 1.7.9 +Apache License 2.0 +https://github.com/oshai/kotlin-logging/blob/master/LICENSE + +kotlin-stdlib 2.1.0 +Apache License 2.0 +https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt + +kotlin-stdlib-jdk7 1.9.10 +Apache License 2.0 +https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt + +kotlin-stdlib-jdk8 1.9.10 +Apache License 2.0 +https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt + +kotlin-stdlib-jdk8 1.9.10 +Apache License 2.0 +https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt + +kotlinx-coroutines-core-jvm 1.10.1 +Apache License 2.0 +https://www.apache.org/licenses/LICENSE-2.0 + +kotlinx-coroutines-swing 1.10.1 +Apache License 2.0 +https://www.apache.org/licenses/LICENSE-2.0 + +kotlinx-serialization-core-jvm 1.7.3 +Apache License 2.0 +https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt + +kotlinx-serialization-json-jvm 1.7.3 +Apache License 2.0 +https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt + +logging-interceptor 4.12.0 +Apache License 2.0 +https://www.apache.org/licenses/LICENSE-2.0 + +okhttp 4.12.0 +Apache License 2.0 +https://www.apache.org/licenses/LICENSE-2.0 + +okio-jvm 3.6.0 +Apache License 2.0 +https://www.apache.org/licenses/LICENSE-2.0 + +org.eclipse.jgit.ssh.apache 7.1.0.202411261347-r +Eclipse Distribution License +https://www.eclipse.org/org/documents/edl-v10.php + +org.eclipse.jgit 7.1.0.202411261347-r +Eclipse Distribution License +https://www.eclipse.org/org/documents/edl-v10.php + +oshi-core 6.6.5 +MIT License +https://github.com/oshi/oshi/blob/master/LICENSE + +pty4j 0.13.2 +Eclipse Public License 1.0 +https://github.com/JetBrains/pty4j/blob/master/LICENSE + +slf4j-api 2.0.16 +MIT License +https://github.com/qos-ch/slf4j/blob/master/LICENSE.txt + +slf4j-tinylog 2.7.0 +Apache License 2.0 +https://github.com/tinylog-org/tinylog/blob/v2.7/license.txt + +sshd-common 2.14.0 +Apache License 2.0 +https://www.apache.org/licenses/LICENSE-2.0 + +sshd-core 2.14.0 +Apache License 2.0 +https://www.apache.org/licenses/LICENSE-2.0 + +sshd-osgi 2.14.0 +Apache License 2.0 +https://www.apache.org/licenses/LICENSE-2.0 + +sshd-sftp 2.14.0 +Apache License 2.0 +https://www.apache.org/licenses/LICENSE-2.0 + +swingx-all 1.6.5-1 +GNU LESSER GENERAL PUBLIC LICENSE v3 +https://www.gnu.org/licenses/lgpl-3.0 + +tinylog-api 2.7.0 +Apache License 2.0 +https://github.com/tinylog-org/tinylog/blob/v2.7/license.txt + +tinylog-impl 2.7.0 +Apache License 2.0 +https://github.com/tinylog-org/tinylog/blob/v2.7/license.txt + +versioncompare 1.4.1 +Apache License 2.0 +https://github.com/G00fY2/version-compare/blob/main/LICENSE + +xodus-compress 2.0.1 +Apache License 2.0 +https://github.com/JetBrains/xodus/blob/master/LICENSE.txt + +xodus-environment 2.0.1 +Apache License 2.0 +https://github.com/JetBrains/xodus/blob/master/LICENSE.txt + +xodus-openAPI 2.0.1 +Apache License 2.0 +https://github.com/JetBrains/xodus/blob/master/LICENSE.txt + +xodus-utils 2.0.1 +Apache License 2.0 +https://github.com/JetBrains/xodus/blob/master/LICENSE.txt + +xodus-vfs 2.0.1 +Apache License 2.0 +https://github.com/JetBrains/xodus/blob/master/LICENSE.txt + +jediterm +Apache License 2.0 +https://github.com/JetBrains/jediterm/blob/master/LICENSE-APACHE-2.0.txt \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..56e76f0 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,293 @@ +import org.gradle.internal.jvm.Jvm +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.DefaultOperatingSystem +import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils + +plugins { + java + application + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlinx.serialization) +} + + +group = "app.termora" +version = "1.0.0" + +val os: DefaultOperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem() +var arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture() + + +repositories { + mavenCentral() + maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies") + maven("https://www.jitpack.io") +} + +dependencies { + testImplementation(kotlin("test")) + testImplementation(libs.hutool) + testImplementation(libs.sshj) + testImplementation(platform(libs.koin.bom)) + testImplementation(libs.koin.core) + testImplementation(libs.jsch) + testImplementation(libs.rhino) + testImplementation(libs.delight.rhino.sandbox) + + implementation(libs.slf4j.api) + implementation(libs.pty4j) + implementation(libs.slf4j.tinylog) + implementation(libs.tinylog.impl) + implementation(libs.commons.codec) + implementation(libs.commons.io) + implementation(libs.commons.lang3) + implementation(libs.commons.net) + implementation(libs.commons.text) + implementation(libs.commons.compress) + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.flatlaf) + implementation(libs.flatlaf.extras) + implementation(libs.flatlaf.swingx) + implementation(libs.kotlinx.serialization.json) + implementation(libs.swingx) + implementation(libs.jgoodies.forms) + implementation(libs.jna) + implementation(libs.jna.platform) + implementation(libs.versioncompare) + implementation(libs.oshi.core) + implementation(libs.jSystemThemeDetector) { exclude(group = "*", module = "*") } + implementation(libs.jfa) { exclude(group = "*", module = "*") } + implementation(libs.jbr.api) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + implementation(libs.sshd.core) + implementation(libs.commonmark) + implementation(libs.jgit) + implementation(libs.jgit.sshd) + implementation(libs.jnafilechooser) + implementation(libs.xodus.vfs) + implementation(libs.xodus.openAPI) + implementation(libs.xodus.environment) + implementation(libs.bip39) + implementation(libs.colorpicker) +} + +application { + val args = mutableListOf( + "--add-exports java.base/sun.nio.ch=ALL-UNNAMED", + ) + + if (os.isMacOsX) { + args.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED") + args.add("-Dsun.java2d.metal=true") + args.add("-Dapple.awt.application.appearance=system") + } + + args.add("-Dapp-version=${project.version}") + + if (os.isLinux) { + args.add("-Dsun.java2d.opengl=true") + } + + applicationDefaultJvmArgs = args + mainClass = "app.termora.MainKt" +} + +tasks.test { + useJUnitPlatform() +} + +tasks.register("copy-dependencies") { + from(configurations.runtimeClasspath) + .into("${layout.buildDirectory.get()}/libs") +} + +tasks.register("jlink") { + val modules = listOf( + "java.base", + "java.desktop", + "java.logging", + "java.management", + "java.rmi", + "java.security.jgss", + "jdk.crypto.ec", + "jdk.unsupported", + ) + + commandLine( + "${Jvm.current().javaHome}/bin/jlink", + "--verbose", + "--strip-java-debug-attributes", + "--strip-native-commands", + "--strip-debug", + "--compress=zip-9", + "--no-header-files", + "--no-man-pages", + "--add-modules", + modules.joinToString(","), + "--output", + "${layout.buildDirectory.get()}/jlink" + ) +} + +tasks.register("jpackage") { + val buildDir = layout.buildDirectory.get() + val options = mutableListOf( + "--add-exports java.base/sun.nio.ch=ALL-UNNAMED", + "-Xmx2g", + "-XX:+HeapDumpOnOutOfMemoryError", + "-Dlogger.console.level=off", + "-Dkotlinx.coroutines.debug=off", + "-Dapp-version=${project.version}", + ) + + if (os.isMacOsX) { + options.add("-Dsun.java2d.metal=true") + options.add("-Dapple.awt.application.appearance=system") + options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED") + } else { + options.add("-Dsun.java2d.opengl=true") + } + + val arguments = mutableListOf("${Jvm.current().javaHome}/bin/jpackage", "--verbose") + arguments.addAll(listOf("--runtime-image", "${buildDir}/jlink")) + arguments.addAll(listOf("--name", project.name.uppercaseFirstChar())) + arguments.addAll(listOf("--app-version", "${project.version}")) + arguments.addAll(listOf("--main-jar", tasks.jar.get().archiveFileName.get())) + arguments.addAll(listOf("--main-class", application.mainClass.get())) + arguments.addAll(listOf("--input", "$buildDir/libs")) + arguments.addAll(listOf("--temp", "$buildDir/jpackage")) + arguments.addAll(listOf("--dest", "$buildDir/distributions")) + arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE))) + + + if (os.isMacOsX) { + arguments.addAll(listOf("--mac-package-name", project.name.uppercaseFirstChar())) + arguments.addAll(listOf("--mac-app-category", "developer-tools")) + arguments.addAll(listOf("--mac-package-identifier", "${project.group}")) + arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.icns")) + } + + if (os.isWindows) { + arguments.add("--win-dir-chooser") + arguments.add("--win-shortcut") + arguments.add("--win-shortcut-prompt") + arguments.addAll(listOf("--win-upgrade-uuid", "E1D93CAD-5BF8-442E-93BA-6E90DE601E4C")) + arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.ico")) + } + + + arguments.add("--type") + if (os.isMacOsX) { + arguments.add("dmg") + } else if (os.isWindows) { + arguments.add("msi") + } else if (os.isLinux) { + arguments.add("app-image") + } else { + throw UnsupportedOperationException() + } + + commandLine(arguments) + +} + +tasks.register("dist") { + doLast { + val vendor = Jvm.current().vendor ?: StringUtils.EMPTY + @Suppress("UnstableApiUsage") + if (!JvmVendorSpec.JETBRAINS.matches(vendor)) { + throw GradleException("JVM: $vendor is not supported") + } + + val distributionDir = layout.buildDirectory.dir("distributions").get() + val gradlew = File(projectDir, if (os.isWindows) "gradlew.bat" else "gradlew").absolutePath + + // 清空目录 + exec { commandLine(gradlew, "clean") } + + // 打包并复制依赖 + exec { commandLine(gradlew, "jar", "copy-dependencies") } + + // 检查依赖的开源协议 + exec { commandLine(gradlew, "check-license") } + + // jlink + exec { commandLine(gradlew, "jlink") } + + // 打包 + exec { commandLine(gradlew, "jpackage") } + + // pack + exec { + if (os.isWindows) { // zip + commandLine( + "tar", "-vacf", + distributionDir.file("${project.name}-${project.version}-windows-${arch.name}.zip").asFile.absolutePath, + project.name.uppercaseFirstChar() + ) + workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile + } else if (os.isLinux) { // tar.gz + commandLine( + "tar", "-czvf", + distributionDir.file("${project.name}-${project.version}-linux-${arch.name}.tar.gz").asFile.absolutePath, + project.name.uppercaseFirstChar() + ) + workingDir = distributionDir.asFile + } else if (os.isMacOsX) { // rename + commandLine( + "mv", + distributionDir.file("${project.name.uppercaseFirstChar()}-${project.version}.dmg").asFile.absolutePath, + distributionDir.file("${project.name}-${project.version}-osx-${arch.name}.dmg").asFile.absolutePath, + ) + } else { + throw GradleException("${os.name} is not supported") + } + } + } +} + +tasks.register("check-license") { + doLast { + val thirdParty = mutableMapOf() + val iterator = File(projectDir, "THIRDPARTY").readLines().iterator() + val thirdPartyNames = mutableSetOf() + + while (iterator.hasNext()) { + val nameWithVersion = iterator.next() + if (nameWithVersion.isBlank()) { + continue + } + + // ignore license name + iterator.next() + + val license = iterator.next() + thirdParty[nameWithVersion.replace(StringUtils.SPACE, "-")] = license + 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") + } + } + } + } +} + +kotlin { + jvmToolchain { + languageVersion = JavaLanguageVersion.of(21) + @Suppress("UnstableApiUsage") + vendor = JvmVendorSpec.JETBRAINS + } +} \ No newline at end of file diff --git a/docs/findeverywhere.png b/docs/findeverywhere.png new file mode 100644 index 0000000..90237e0 Binary files /dev/null and b/docs/findeverywhere.png differ diff --git a/docs/readme.png b/docs/readme.png new file mode 100644 index 0000000..0971a14 Binary files /dev/null and b/docs/readme.png differ diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..25b09e3 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.caching=true +org.gradle.parallel=true +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..f02b792 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,98 @@ +[versions] +kotlin = "2.1.0" +slf4j = "2.0.16" +pty4j = "0.13.2" +tinylog = "2.7.0" +kotlinx-coroutines = "1.10.1" +flatlaf = "3.5.4" +trove4j = "1.0.20200330" +kotlinx-serialization-json = "1.7.3" +commons-codec = "1.17.1" +commons-lang3 = "3.17.0" +commons-net = "3.11.1" +commons-text = "1.12.0" +commons-compress = "1.27.1" +koin-bom = "4.0.0" +swingx = "1.6.5-1" +jgoodies-forms = "1.9.0" +jfa = "1.2.0" +oshi = "6.6.5" +versioncompare = "1.4.1" +jna = "5.16.0" +jSystemThemeDetector = "3.9.1" +commons-io = "2.18.0" +jbr-api = "17.1.10.1" +leveldb = "0.12" +guava = "33.3.1-jre" +credential-secure-storage = "1.0.3" +hutool = "5.8.34" +jsch = "0.2.21" +okhttp = "4.12.0" +bcprov = "1.79" +sshj = "0.39.0" +sshd-core = "2.14.0" +jgit = "7.1.0.202411261347-r" +commonmark = "0.24.0" +jnafilechooser = "1.1.2" +xodus = "2.0.1" +bip39 = "1.0.8" +colorpicker = "2.0.1" +rhino = "1.7.15" +delight-rhino-sandbox = "0.0.17" + +[libraries] +kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } +slf4j-tinylog = { group = "org.tinylog", name = "slf4j-tinylog", version.ref = "tinylog" } +tinylog-impl = { group = "org.tinylog", name = "tinylog-impl", version.ref = "tinylog" } +commons-codec = { group = "commons-codec", name = "commons-codec", version.ref = "commons-codec" } +commons-net = { group = "commons-net", name = "commons-net", version.ref = "commons-net" } +commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version.ref = "commons-lang3" } +commons-text = { group = "org.apache.commons", name = "commons-text", version.ref = "commons-text" } +commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commons-compress" } +pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" } +flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" } +flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" } +trove4j = { group = "org.jetbrains.intellij.deps", name = "trove4j", version.ref = "trove4j" } +koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" } +koin-core = { module = "io.insert-koin:koin-core" } +swingx = { module = "org.swinglabs.swingx:swingx-all", version.ref = "swingx" } +jgoodies-forms = { module = "com.jgoodies:jgoodies-forms", version.ref = "jgoodies-forms" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } +jSystemThemeDetector = { module = "com.github.Dansoftowner:jSystemThemeDetector", version.ref = "jSystemThemeDetector" } +versioncompare = { module = "io.github.g00fy2:versioncompare", version.ref = "versioncompare" } +jfa = { module = "de.jangassen:jfa", version.ref = "jfa" } +oshi-core = { module = "com.github.oshi:oshi-core", version.ref = "oshi" } +commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" } +jbr-api = { module = "com.jetbrains:jbr-api", version.ref = "jbr-api" } +flatlaf-swingx = { module = "com.formdev:flatlaf-swingx", version.ref = "flatlaf" } +leveldb = { module = "org.iq80.leveldb:leveldb", version.ref = "leveldb" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } +hutool = { module = "cn.hutool:hutool-all", version.ref = "hutool" } +credential-secure-storage = { module = "com.microsoft:credential-secure-storage", version.ref = "credential-secure-storage" } +jsch = { module = "com.github.mwiede:jsch", version.ref = "jsch" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov" } +sshj = { module = "com.hierynomus:sshj", version.ref = "sshj" } +sshd-core = { module = "org.apache.sshd:sshd-core", version.ref = "sshd-core" } +jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" } +commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } +jgit-sshd = { module = "org.eclipse.jgit:org.eclipse.jgit.ssh.apache", version.ref = "jgit" } +xodus-openAPI = { module = "org.jetbrains.xodus:xodus-openAPI", version.ref = "xodus" } +xodus-entity-store = { module = "org.jetbrains.xodus:xodus-entity-store", version.ref = "xodus" } +xodus-environment = { module = "org.jetbrains.xodus:xodus-environment", version.ref = "xodus" } +xodus-crypto = { module = "org.jetbrains.xodus:xodus-crypto", version.ref = "xodus" } +xodus-vfs = { module = "org.jetbrains.xodus:xodus-vfs", version.ref = "xodus" } +jnafilechooser = { module = "com.github.steos.jnafilechooser:jnafilechooser-api", version.ref = "jnafilechooser" } +bip39 = { module = "cash.z.ecc.android:kotlin-bip39-jvm", version.ref = "bip39" } +rhino = { module = "org.mozilla:rhino", version.ref = "rhino" } +delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" } +colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..19d404b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.10.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d908e4b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" +} +rootProject.name = "termora" + diff --git a/src/main/java/app/termora/Disposable.java b/src/main/java/app/termora/Disposable.java new file mode 100644 index 0000000..f4287db --- /dev/null +++ b/src/main/java/app/termora/Disposable.java @@ -0,0 +1,14 @@ +// Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package app.termora; + +public interface Disposable { + + default void dispose() { + + } + + interface Parent extends Disposable { + void beforeTreeDispose(); + } + +} diff --git a/src/main/java/app/termora/Disposer.java b/src/main/java/app/termora/Disposer.java new file mode 100644 index 0000000..1727871 --- /dev/null +++ b/src/main/java/app/termora/Disposer.java @@ -0,0 +1,192 @@ +// Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package app.termora; + +import org.jetbrains.annotations.*; + +import java.util.Collections; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.function.Predicate; + +/** + *

Manages a parent-child relation of chained objects requiring cleanup.

+ * + *

A root node can be created via {@link #newDisposable()}, to which children are attached via subsequent calls to {@link #register(Disposable, Disposable)}. + * Invoking {@link #dispose(Disposable)} will process all its registered children's {@link Disposable#dispose()} method.

+ *

+ * See Disposer and Disposable in SDK Docs. + * + * @see Disposable + */ +public final class Disposer { + private static final ObjectTree ourTree = new ObjectTree(); + + public static boolean isDebugDisposerOn() { + return "on".equals(System.getProperty("idea.disposer.debug")); + } + + private static boolean ourDebugMode; + + private Disposer() { + } + + @NotNull + @Contract(pure = true, value = "->new") + public static Disposable newDisposable() { + // must not be lambda because we care about identity in ObjectTree.myObject2NodeMap + return newDisposable(""); + } + + @NotNull + @Contract(pure = true, value = "_->new") + public static Disposable newDisposable(@NotNull @NonNls String debugName) { + // must not be lambda because we care about identity in ObjectTree.myObject2NodeMap + return new Disposable() { + @Override + public void dispose() { + } + + @Override + public String toString() { + return debugName; + } + }; + } + + @Contract(pure = true, value = "_,_->new") + public static @NotNull Disposable newDisposable(@NotNull Disposable parentDisposable, @NotNull String debugName) { + Disposable result = newDisposable(debugName); + register(parentDisposable, result); + return result; + } + + private static final Map ourKeyDisposables = Collections.synchronizedMap(new WeakHashMap<>()); + + + public static void register(@NotNull Disposable parent, @NotNull Disposable child) { + RuntimeException e = ourTree.register(parent, child); + if (e != null) throw e; + } + + /** + * Registers child disposable under parent unless the parent has already been disposed + * + * @return whether the registration succeeded + */ + public static boolean tryRegister(@NotNull Disposable parent, @NotNull Disposable child) { + return ourTree.register(parent, child) == null; + } + + public static void register(@NotNull Disposable parent, @NotNull Disposable child, @NonNls @NotNull final String key) { + register(parent, child); + Disposable v = get(key); + if (v != null) throw new IllegalArgumentException("Key " + key + " already registered: " + v); + ourKeyDisposables.put(key, child); + register(child, new KeyDisposable(key)); + } + + private static final class KeyDisposable implements Disposable { + @NotNull + private final String myKey; + + KeyDisposable(@NotNull String key) { + myKey = key; + } + + @Override + public void dispose() { + ourKeyDisposables.remove(myKey); + } + + @Override + public String toString() { + return "KeyDisposable (" + myKey + ")"; + } + } + + /** + * @return true if {@code disposable} is disposed or being disposed (i.e. its {@link Disposable#dispose()} method is executing). + */ + public static boolean isDisposed(@NotNull Disposable disposable) { + return ourTree.getDisposalInfo(disposable) != null; + } + + /** + * @deprecated use {@link #isDisposed(Disposable)} instead + */ + @Deprecated + public static boolean isDisposing(@NotNull Disposable disposable) { + return isDisposed(disposable); + } + + public static Disposable get(@NotNull String key) { + return ourKeyDisposables.get(key); + } + + public static void dispose(@NotNull Disposable disposable) { + dispose(disposable, true); + } + + /** + * {@code predicate} is used only for direct children. + */ + @ApiStatus.Internal + public static void disposeChildren(@NotNull Disposable disposable, @Nullable Predicate predicate) { + ourTree.executeAllChildren(disposable, predicate); + } + + public static void dispose(@NotNull Disposable disposable, boolean processUnregistered) { + ourTree.executeAll(disposable, processUnregistered); + } + + @NotNull + public static ObjectTree getTree() { + return ourTree; + } + + public static void assertIsEmpty() { + assertIsEmpty(false); + } + + public static void assertIsEmpty(boolean throwError) { + if (ourDebugMode) { + ourTree.assertIsEmpty(throwError); + } + } + + /** + * @return old value + */ + public static boolean setDebugMode(boolean debugMode) { + if (debugMode) { + debugMode = !"off".equals(System.getProperty("idea.disposer.debug")); + } + boolean oldValue = ourDebugMode; + ourDebugMode = debugMode; + return oldValue; + } + + public static boolean isDebugMode() { + return ourDebugMode; + } + + /** + * @return object registered on {@code parentDisposable} which is equal to object, or {@code null} if not found + */ + @Nullable + public static T findRegisteredObject(@NotNull Disposable parentDisposable, @NotNull T object) { + return ourTree.findRegisteredObject(parentDisposable, object); + } + + public static Throwable getDisposalTrace(@NotNull Disposable disposable) { + if (getTree().getDisposalInfo(disposable) instanceof Throwable) { + return (Throwable) disposable; + } + return null; + } + + @ApiStatus.Internal + public static void clearDisposalTraces() { + ourTree.clearDisposedObjectTraces(); + } +} \ No newline at end of file diff --git a/src/main/java/app/termora/ObjectNode.java b/src/main/java/app/termora/ObjectNode.java new file mode 100644 index 0000000..ffb6441 --- /dev/null +++ b/src/main/java/app/termora/ObjectNode.java @@ -0,0 +1,128 @@ +// Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package app.termora; + +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +final class ObjectNode { + private final ObjectTree myTree; + + ObjectNode myParent; // guarded by myTree.treeLock + private final Disposable myObject; + + private List myChildren; // guarded by myTree.treeLock + private Throwable myTrace; // guarded by myTree.treeLock + + ObjectNode(@NotNull ObjectTree tree, + @Nullable ObjectNode parentNode, + @NotNull Disposable object) { + myTree = tree; + myParent = parentNode; + myObject = object; + + } + + void addChild(@NotNull ObjectNode child) { + List children = myChildren; + if (children == null) { + myChildren = new ArrayList<>(); + myChildren.add(child); + } else { + children.add(child); + } + child.myParent = this; + } + + void removeChild(@NotNull ObjectNode child) { + List children = myChildren; + if (children != null) { + // optimisation: iterate backwards + for (int i = children.size() - 1; i >= 0; i--) { + ObjectNode node = children.get(i); + if (node.equals(child)) { + children.remove(i); + break; + } + } + } + child.myParent = null; + } + + ObjectNode getParent() { + return myParent; + } + + void getAndRemoveRecursively(@NotNull List result) { + getAndRemoveChildrenRecursively(result, null); + myTree.removeObjectFromTree(this); + // already disposed. may happen when someone does `register(obj, ()->Disposer.dispose(t));` abomination + if (myTree.rememberDisposedTrace(myObject) == null) { + result.add(myObject); + } + myChildren = null; + myParent = null; + } + + /** + * {@code predicate} is used only for direct children. + */ + void getAndRemoveChildrenRecursively(@NotNull List result, @Nullable Predicate predicate) { + if (myChildren != null) { + for (int i = myChildren.size() - 1; i >= 0; i--) { + ObjectNode childNode = myChildren.get(i); + if (predicate == null || predicate.test(childNode.getObject())) { + childNode.getAndRemoveRecursively(result); + } + } + } + } + + @NotNull + Disposable getObject() { + return myObject; + } + + @Override + @NonNls + public String toString() { + return "Node: " + myObject; + } + + Throwable getTrace() { + return myTrace; + } + + void clearTrace() { + myTrace = null; + } + + @TestOnly + void assertNoReferencesKept(@NotNull Disposable aDisposable) { + assert getObject() != aDisposable; + if (myChildren != null) { + for (ObjectNode node : myChildren) { + node.assertNoReferencesKept(aDisposable); + } + } + } + + D findChildEqualTo(@NotNull D object) { + List children = myChildren; + if (children != null) { + for (ObjectNode node : children) { + Disposable nodeObject = node.getObject(); + if (nodeObject.equals(object)) { + //noinspection unchecked + return (D) nodeObject; + } + } + } + return null; + } +} diff --git a/src/main/java/app/termora/ObjectTree.java b/src/main/java/app/termora/ObjectTree.java new file mode 100644 index 0000000..79104e9 --- /dev/null +++ b/src/main/java/app/termora/ObjectTree.java @@ -0,0 +1,246 @@ +// Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package app.termora; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +import java.util.*; +import java.util.function.Predicate; +import java.util.function.Supplier; + +final class ObjectTree { + private static final ThreadLocal ourTopmostDisposeTrace = new ThreadLocal<>(); + + private final Set myRootObjects = new HashSet<>(); + // guarded by treeLock + private final Map myObject2NodeMap = new HashMap<>(); + // Disposable -> trace or boolean marker (if trace unavailable) + private final Map myDisposedObjects = new WeakHashMap<>(); // guarded by treeLock + + private final Object treeLock = new Object(); + + private ObjectNode getNode(@NotNull Disposable object) { + return myObject2NodeMap.get(object); + } + + private void putNode(@NotNull Disposable object, @Nullable("null means remove") ObjectNode node) { + if (node == null) { + myObject2NodeMap.remove(object); + } else { + myObject2NodeMap.put(object, node); + } + } + + @Nullable + final RuntimeException register(@NotNull Disposable parent, @NotNull Disposable child) { + if (parent == child) return new IllegalArgumentException("Cannot register to itself: " + parent); + synchronized (treeLock) { + Object wasDisposed = getDisposalInfo(parent); + if (wasDisposed != null) { + return new IllegalStateException("Sorry but parent: " + parent + " has already been disposed " + + "(see the cause for stacktrace) so the child: " + child + " will never be disposed", + wasDisposed instanceof Throwable ? (Throwable) wasDisposed : null); + } + + myDisposedObjects.remove(child); // if we dispose thing and then register it back it means it's not disposed anymore + ObjectNode parentNode = getNode(parent); + if (parentNode == null) parentNode = createNodeFor(parent, null); + + ObjectNode childNode = getNode(child); + if (childNode == null) { + childNode = createNodeFor(child, parentNode); + } else { + ObjectNode oldParent = childNode.getParent(); + if (oldParent != null) { + oldParent.removeChild(childNode); + } + } + myRootObjects.remove(child); + + RuntimeException e = checkWasNotAddedAlready(parentNode, childNode); + if (e != null) return e; + + parentNode.addChild(childNode); + } + return null; + } + + Object getDisposalInfo(@NotNull Disposable object) { + synchronized (treeLock) { + return myDisposedObjects.get(object); + } + } + + private static RuntimeException checkWasNotAddedAlready(@NotNull ObjectNode childNode, @NotNull ObjectNode parentNode) { + for (ObjectNode node = childNode; node != null; node = node.getParent()) { + if (node == parentNode) { + return new IllegalStateException("'" + childNode.getObject() + "' was already added as a child of '" + parentNode.getObject() + "'"); + } + } + return null; + } + + @NotNull + private ObjectNode createNodeFor(@NotNull Disposable object, @Nullable ObjectNode parentNode) { + final ObjectNode newNode = new ObjectNode(this, parentNode, object); + if (parentNode == null) { + myRootObjects.add(object); + } + putNode(object, newNode); + return newNode; + } + + private void runWithTrace(@NotNull Supplier> removeFromTreeAction) { + boolean needTrace = Disposer.isDebugMode() && ourTopmostDisposeTrace.get() == null; + if (needTrace) { + ourTopmostDisposeTrace.set(new Throwable()); + } + + // first, atomically remove disposables from the tree to avoid "register during dispose" race conditions + List disposables; + synchronized (treeLock) { + disposables = removeFromTreeAction.get(); + } + + // second, call "beforeTreeDispose" in pre-order (some clients are hardcoded to see parents-then-children order in "beforeTreeDispose") + List exceptions = null; + for (int i = disposables.size() - 1; i >= 0; i--) { + Disposable disposable = disposables.get(i); + if (disposable instanceof Disposable.Parent) { + try { + ((Disposable.Parent) disposable).beforeTreeDispose(); + } catch (Throwable t) { + if (exceptions == null) exceptions = new ArrayList<>(); + exceptions.add(t); + } + } + } + + // third, dispose in post-order (bottom-up) + for (Disposable disposable : disposables) { + try { + //noinspection SSBasedInspection + disposable.dispose(); + } catch (Throwable e) { + if (exceptions == null) exceptions = new ArrayList<>(); + exceptions.add(e); + } + } + + if (needTrace) { + ourTopmostDisposeTrace.remove(); + } + if (exceptions != null) { + handleExceptions(exceptions); + } + } + + void executeAllChildren(@NotNull Disposable object, @Nullable Predicate predicate) { + runWithTrace(() -> { + ObjectNode node = getNode(object); + if (node == null) { + return Collections.emptyList(); + } + + List disposables = new ArrayList<>(); + node.getAndRemoveChildrenRecursively(disposables, predicate); + return disposables; + }); + } + + void executeAll(@NotNull Disposable object, boolean processUnregistered) { + runWithTrace(() -> { + ObjectNode node = getNode(object); + if (node == null && !processUnregistered) { + return Collections.emptyList(); + } + List disposables = new ArrayList<>(); + if (node == null) { + if (rememberDisposedTrace(object) == null) { + disposables.add(object); + } + } else { + node.getAndRemoveRecursively(disposables); + } + return disposables; + }); + } + + private static void handleExceptions(@NotNull List exceptions) { + + } + + @TestOnly + void assertNoReferenceKeptInTree(@NotNull Disposable disposable) { + synchronized (treeLock) { + for (Map.Entry entry : myObject2NodeMap.entrySet()) { + Disposable key = entry.getKey(); + assert key != disposable; + ObjectNode node = entry.getValue(); + node.assertNoReferencesKept(disposable); + } + } + } + + void assertIsEmpty(boolean throwError) { + synchronized (treeLock) { + for (Disposable object : myRootObjects) { + if (object == null) continue; + ObjectNode objectNode = getNode(object); + if (objectNode == null) continue; + while (objectNode.getParent() != null) { + objectNode = objectNode.getParent(); + } + final Throwable trace = objectNode.getTrace(); + RuntimeException exception = new RuntimeException("Memory leak detected: '" + object + "' of " + object.getClass() + + "\nSee the cause for the corresponding Disposer.register() stacktrace:\n", + trace); + if (throwError) { + throw exception; + } + } + } + } + + + // return old value + Object rememberDisposedTrace(@NotNull Disposable object) { + synchronized (treeLock) { + Throwable trace = ourTopmostDisposeTrace.get(); + return myDisposedObjects.put(object, trace != null ? trace : Boolean.TRUE); + } + } + + void clearDisposedObjectTraces() { + synchronized (treeLock) { + myDisposedObjects.clear(); + for (ObjectNode value : myObject2NodeMap.values()) { + value.clearTrace(); + } + } + } + + @Nullable + D findRegisteredObject(@NotNull Disposable parentDisposable, @NotNull D object) { + synchronized (treeLock) { + ObjectNode parentNode = getNode(parentDisposable); + if (parentNode == null) return null; + return parentNode.findChildEqualTo(object); + } + } + + void removeObjectFromTree(@NotNull ObjectNode node) { + synchronized (treeLock) { + Disposable myObject = node.getObject(); + putNode(myObject, null); + ObjectNode parent = node.getParent(); + if (parent == null) { + myRootObjects.remove(myObject); + } else { + parent.removeChild(node); + } + node.myParent = null; + } + } +} diff --git a/src/main/java/zmodem/FileCopyStreamEvent.kt b/src/main/java/zmodem/FileCopyStreamEvent.kt new file mode 100644 index 0000000..0dfc092 --- /dev/null +++ b/src/main/java/zmodem/FileCopyStreamEvent.kt @@ -0,0 +1,34 @@ +package zmodem + +import org.apache.commons.net.io.CopyStreamEvent + +/** + * 如果一共两个文件,并且传输第一个文件时: + * + * remaining = 1 + * index = 1 + */ +class FileCopyStreamEvent( + source: Any, + // 本次传输的文件名 + val filename: String, + // 剩余未传输的文件数量 + val remaining: Int, + // 第几个文件 + val index: Int, + // 总字节数 + totalBytesTransferred: Long, + // 已经传输完成的字节数 + bytesTransferred: Int, + // 本次传输的字节数 + streamSize: Long, + /** + * 这个文件被跳过了 + */ + val skip: Boolean = false, +) : + CopyStreamEvent( + source, totalBytesTransferred, + bytesTransferred, + streamSize + ) \ No newline at end of file diff --git a/src/main/java/zmodem/XModem.java b/src/main/java/zmodem/XModem.java new file mode 100644 index 0000000..e3acd6f --- /dev/null +++ b/src/main/java/zmodem/XModem.java @@ -0,0 +1,24 @@ +package zmodem; + +import zmodem.xfer.zm.util.Modem; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Path; + +public class XModem { + private Modem modem; + + public XModem(InputStream inputStream, OutputStream outputStream) { + this.modem = new Modem(inputStream, outputStream); + } + + public void send(Path file, boolean useBlock1K) throws IOException, InterruptedException { + modem.send(file, useBlock1K); + } + + public void receive(Path file) throws IOException { + modem.receive(file, false); + } +} diff --git a/src/main/java/zmodem/YModem.java b/src/main/java/zmodem/YModem.java new file mode 100644 index 0000000..c65788d --- /dev/null +++ b/src/main/java/zmodem/YModem.java @@ -0,0 +1,207 @@ +package zmodem; + +import zmodem.xfer.util.CRC16; +import zmodem.xfer.util.CRC8; +import zmodem.xfer.util.XCRC; +import zmodem.xfer.zm.util.Modem; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Arrays; + +/** + * YModem.
+ * Block 0 contain minimal file information (only filename)
+ *

+ * Created by Anton Sirotinkin (aesirot@mail.ru), Moscow 2014
+ * I hope you will find this program useful.
+ * You are free to use/modify the code for any purpose, but please leave a reference to me.
+ *
+ */ +public class YModem { + private Modem modem; + + /** + * Constructor + * + * @param inputStream stream for reading received data from other side + * @param outputStream stream for writing data to other side + */ + public YModem(InputStream inputStream, OutputStream outputStream) { + this.modem = new Modem(inputStream, outputStream); + } + + /** + * Send a file.
+ *

+ * This method support correct thread interruption, when thread is interrupted "cancel of transmission" will be send. + * So you can move long transmission to other thread and interrupt it according to your algorithm. + * + * @param file + * @throws IOException + */ + public void send(Path file) throws IOException { + //check filename +// if (!file.getFileName().toString().matches("\\w{1,8}\\.\\w{1,3}")) { +// throw new IOException("Filename must be in DOS style (no spaces, max 8.3)"); +// } + + //open file + try (DataInputStream dataStream = new DataInputStream(Files.newInputStream(file))) { + + boolean useCRC16 = modem.waitReceiverRequest(); + XCRC crc; + if (useCRC16) + crc = new CRC16(); + else + crc = new CRC8(); + + //send block 0 + BasicFileAttributes readAttributes = Files.readAttributes(file, BasicFileAttributes.class); + String fileNameString = file.getFileName().toString() + (char) 0 + ((Long) Files.size(file)).toString() + " " + Long.toOctalString(readAttributes.lastModifiedTime().toMillis() / 1000); + byte[] fileNameBytes = Arrays.copyOf(fileNameString.getBytes(), 128); + modem.sendBlock(0, Arrays.copyOf(fileNameBytes, 128), 128, crc); + + modem.waitReceiverRequest(); + //send data + byte[] block = new byte[1024]; + modem.sendDataBlocks(dataStream, 1, crc, block); + + modem.sendEOT(); + } + } + + /** + * Send files in batch mode.
+ *

+ * This method support correct thread interruption, when thread is interrupted "cancel of transmission" will be send. + * So you can move long transmission to other thread and interrupt it according to your algorithm. + * + * @param files + * @throws IOException + */ + public void batchSend(Path... files) throws IOException { + for (Path file : files) { + send(file); + } + + sendBatchStop(); + } + + private void sendBatchStop() throws IOException { + boolean useCRC16 = modem.waitReceiverRequest(); + XCRC crc; + if (useCRC16) + crc = new CRC16(); + else + crc = new CRC8(); + + //send block 0 + byte[] bytes = new byte[128]; + modem.sendBlock(0, bytes, bytes.length, crc); + } + + /** + * Receive single file
+ *

+ * This method support correct thread interruption, when thread is interrupted "cancel of transmission" will be send. + * So you can move long transmission to other thread and interrupt it according to your algorithm. + * + * @param directory directory where file will be saved + * @return path to created file + * @throws IOException + */ + public Path receiveSingleFileInDirectory(Path directory) throws IOException { + return receive(directory, true); + } + + /** + * Receive files in batch mode
+ *

+ * This method support correct thread interruption, when thread is interrupted "cancel of transmission" will be send. + * So you can move long transmission to other thread and interrupt it according to your algorithm. + * + * @param directory directory where files will be saved + * @throws IOException + */ + public void receiveFilesInDirectory(Path directory) throws IOException { + while (receive(directory, true) != null) { + } + } + + /** + * Receive path
+ *

+ * This method support correct thread interruption, when thread is interrupted "cancel of transmission" will be send. + * So you can move long transmission to other thread and interrupt it according to your algorithm. + * + * @param path path to file where data will be saved + * @return path to file + * @throws IOException + */ + public Path receive(Path path) throws IOException { + return receive(path, false); + } + + private Path receive(Path path, boolean inDirectory) throws IOException { + DataOutputStream dataOutput = null; + Path filePath; + try { + XCRC crc = new CRC16(); + int errorCount = 0; + + // process block 0 + byte[] block; + int character; + while (true) { + character = modem.requestTransmissionStart(true); + try { + // read file name from zero block + block = modem.readBlock(0, (character == Modem.SOH), crc); + + if (inDirectory) { + StringBuilder sb = new StringBuilder(); + if (block[0] == 0) { + //this is stop block of batch file transfer + modem.sendByte(Modem.ACK); + return null; + } + for (int i = 0; i < block.length; i++) { + if (block[i] == 0) { + break; + } + sb.append((char) block[i]); + } + filePath = path.resolve(sb.toString()); + } else { + filePath = path; + } + dataOutput = new DataOutputStream(Files.newOutputStream(filePath)); + modem.sendByte(Modem.ACK); + break; + } catch (Modem.InvalidBlockException e) { + errorCount++; + if (errorCount == Modem.MAXERRORS) { + modem.interruptTransmission(); + throw new IOException("Transmission aborted, error count exceeded max"); + } + modem.sendByte(Modem.NAK); + } catch (Modem.RepeatedBlockException | Modem.SynchronizationLostException e) { + //fatal transmission error + modem.interruptTransmission(); + throw new IOException("Fatal transmission error", e); + } + } + + //receive data blocks + modem.receive(filePath, true); + } finally { + if (dataOutput != null) { + dataOutput.close(); + } + } + return filePath; + } +} diff --git a/src/main/java/zmodem/ZModem.java b/src/main/java/zmodem/ZModem.java new file mode 100644 index 0000000..49335cc --- /dev/null +++ b/src/main/java/zmodem/ZModem.java @@ -0,0 +1,46 @@ +package zmodem; + + +import org.apache.commons.net.io.CopyStreamListener; +import zmodem.util.FileAdapter; +import zmodem.xfer.zm.util.ZModemReceive; +import zmodem.xfer.zm.util.ZModemSend; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + + +public class ZModem { + + private final InputStream netIs; + private final OutputStream netOs; + private final AtomicBoolean isCancelled = new AtomicBoolean(false); + + + public ZModem(InputStream netin, OutputStream netout) { + netIs = netin; + netOs = netout; + } + + public void receive(Supplier destDir, CopyStreamListener listener) throws IOException { + ZModemReceive sender = new ZModemReceive(destDir, netIs, netOs); + sender.addCopyStreamListener(listener); + sender.receive(isCancelled::get); + netOs.flush(); + } + + public void send(Supplier> filesSupplier, CopyStreamListener listener) throws IOException { + ZModemSend sender = new ZModemSend(filesSupplier, netIs, netOs); + sender.addCopyStreamListener(listener); + sender.send(isCancelled::get); + netOs.flush(); + } + + public void cancel() { + isCancelled.compareAndSet(false, true); + } +} diff --git a/src/main/java/zmodem/package-info.java b/src/main/java/zmodem/package-info.java new file mode 100644 index 0000000..2ecbc76 --- /dev/null +++ b/src/main/java/zmodem/package-info.java @@ -0,0 +1,4 @@ +/** + * https://github.com/scraymer/Zmodem-in-Java + */ +package zmodem; \ No newline at end of file diff --git a/src/main/java/zmodem/util/CustomFile.java b/src/main/java/zmodem/util/CustomFile.java new file mode 100644 index 0000000..8cc7d0f --- /dev/null +++ b/src/main/java/zmodem/util/CustomFile.java @@ -0,0 +1,65 @@ +package zmodem.util; + +import org.apache.commons.io.input.RandomAccessFileInputStream; + +import java.io.*; + +public class CustomFile implements FileAdapter { + File file = null; + + public CustomFile(File file) { + super(); + this.file = file; + } + + @Override + public String getName() { + return file.getName(); + } + + @Override + public InputStream getInputStream() throws IOException { + return RandomAccessFileInputStream.builder() + .setCloseOnClose(true) + .setRandomAccessFile(new RandomAccessFile(file, "r")) + .setBufferSize(1024 * 8) + .get(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return getOutputStream(false); + } + + @Override + public OutputStream getOutputStream(boolean append) throws IOException { + return new BufferedOutputStream(new FileOutputStream(file, append)); + } + + @Override + public FileAdapter getChild(String name) { + if (name.equals(file.getName())) { + return this; + } else if (file.isDirectory()) { + return new CustomFile(new File(file.getAbsolutePath(), name)); + } + return null; + + } + + @Override + public long length() { + return file.length(); + } + + @Override + public boolean isDirectory() { + return file.isDirectory(); + } + + @Override + public boolean exists() { + return file.exists(); + } + +} diff --git a/src/main/java/zmodem/util/EmptyFileAdapter.kt b/src/main/java/zmodem/util/EmptyFileAdapter.kt new file mode 100644 index 0000000..3d76fb9 --- /dev/null +++ b/src/main/java/zmodem/util/EmptyFileAdapter.kt @@ -0,0 +1,42 @@ +package zmodem.util + +import java.io.InputStream +import java.io.OutputStream + +class EmptyFileAdapter private constructor() : FileAdapter { + companion object { + val instance by lazy { EmptyFileAdapter() } + } + + override fun getName(): String { + TODO("Not yet implemented") + } + + override fun getInputStream(): InputStream { + TODO("Not yet implemented") + } + + override fun getOutputStream(): OutputStream { + TODO("Not yet implemented") + } + + override fun getOutputStream(append: Boolean): OutputStream { + TODO("Not yet implemented") + } + + override fun getChild(name: String?): FileAdapter { + TODO("Not yet implemented") + } + + override fun length(): Long { + TODO("Not yet implemented") + } + + override fun isDirectory(): Boolean { + return true + } + + override fun exists(): Boolean { + return true + } +} \ No newline at end of file diff --git a/src/main/java/zmodem/util/FileAdapter.java b/src/main/java/zmodem/util/FileAdapter.java new file mode 100644 index 0000000..f00e60b --- /dev/null +++ b/src/main/java/zmodem/util/FileAdapter.java @@ -0,0 +1,25 @@ +package zmodem.util; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public interface FileAdapter { + public String getName(); + + public InputStream getInputStream() throws IOException; + + public OutputStream getOutputStream() throws IOException; + + public OutputStream getOutputStream(boolean append) throws IOException; + + public FileAdapter getChild(String name); + + public long length(); + + public boolean isDirectory(); + + public boolean exists(); + + public String toString(); +} diff --git a/src/main/java/zmodem/xfer/io/ObjectInputStream.java b/src/main/java/zmodem/xfer/io/ObjectInputStream.java new file mode 100644 index 0000000..374ab85 --- /dev/null +++ b/src/main/java/zmodem/xfer/io/ObjectInputStream.java @@ -0,0 +1,7 @@ +package zmodem.xfer.io; + +import java.io.IOException; + +public abstract class ObjectInputStream { + public abstract T read() throws IOException; +} diff --git a/src/main/java/zmodem/xfer/io/ObjectOutputStream.java b/src/main/java/zmodem/xfer/io/ObjectOutputStream.java new file mode 100644 index 0000000..5358fd9 --- /dev/null +++ b/src/main/java/zmodem/xfer/io/ObjectOutputStream.java @@ -0,0 +1,7 @@ +package zmodem.xfer.io; + +import java.io.IOException; + +public abstract class ObjectOutputStream { + public abstract void write(T o) throws IOException; +} diff --git a/src/main/java/zmodem/xfer/util/ASCII.java b/src/main/java/zmodem/xfer/util/ASCII.java new file mode 100644 index 0000000..aca91bd --- /dev/null +++ b/src/main/java/zmodem/xfer/util/ASCII.java @@ -0,0 +1,27 @@ +package zmodem.xfer.util; + +public enum ASCII { + + SOH((byte) 0x01), + STX((byte) 0x02), + EOT((byte) 0x04), + ENQ((byte) 0x05), + ACK((byte) 0x06), + BS((byte) 0x08), + LF((byte) 0x0a), + CR((byte) 0x0d), + XON((byte) 0x11), + XOFF((byte) 0x13), + NAK((byte) 0x15), + CAN((byte) 0x18); + + private byte value; + + private ASCII(byte b) { + value = b; + } + + public byte value() { + return value; + } +} diff --git a/src/main/java/zmodem/xfer/util/Arrays.java b/src/main/java/zmodem/xfer/util/Arrays.java new file mode 100644 index 0000000..e35b50a --- /dev/null +++ b/src/main/java/zmodem/xfer/util/Arrays.java @@ -0,0 +1,91 @@ +package zmodem.xfer.util; + +/** + * copyOf is not in java 5 java.util.Array + * + * @author justin + */ +public class Arrays { + public enum Endianness {Little, Big;} + + public static byte[] copyOf(byte[] original, int newLength) { + byte[] copy = new byte[newLength]; + System.arraycopy(original, 0, copy, 0, + Math.min(original.length, newLength)); + return copy; + } + + public static boolean equals(byte[] a, byte[] a2) { + return java.util.Arrays.equals(a, a2); + } + + public static long toInteger(byte[] array, int size, Endianness endian) { + long n = 0; + int offset = 0, increment = 1; + switch (endian) { + case Little: + increment = 1; + offset = 0; + break; + case Big: + increment = -1; + offset = size - 1; + break; + + } + + for (int i = 0; i < size; i++) { + n += (0xff & array[offset]) * (0x1 << i * 8); + offset += increment; + } + + return n; + } + + public static short toShort(byte[] array, Endianness endian) { + return (short) toInteger(array, 2, endian); + } + + public static int toInt(byte[] array, Endianness endian) { + return (int) toInteger(array, 4, endian); + } + + public static long toLong(byte[] array, Endianness endian) { + return toInteger(array, 8, endian); + } + + public static byte[] fromInteger(long n, int size, Endianness endian) { + byte[] ret = new byte[size]; + int offset = 0, increment = 1; + + switch (endian) { + case Big: + increment = -1; + offset = size - 1; + break; + case Little: + increment = 1; + offset = 0; + break; + } + + for (int i = 0; i < size; i++) { + ret[offset] = (byte) ((n >> (i * 8)) & 0xFF); + offset += increment; + } + + return ret; + } + + public static byte[] fromShort(short s, Endianness endian) { + return fromInteger(s, 2, endian); + } + + public static byte[] fromInt(int i, Endianness endian) { + return fromInteger(i, 4, endian); + } + + public static byte[] fromLong(long i, Endianness endian) { + return fromInteger(i, 8, endian); + } +} diff --git a/src/main/java/zmodem/xfer/util/Buffer.java b/src/main/java/zmodem/xfer/util/Buffer.java new file mode 100644 index 0000000..551504f --- /dev/null +++ b/src/main/java/zmodem/xfer/util/Buffer.java @@ -0,0 +1,38 @@ +package zmodem.xfer.util; + +public interface Buffer { + public byte get(); + + public Buffer get(byte[] dst, int offset, int len); + + public Buffer get(byte[] dst); + + public Buffer put(byte b); + + public Buffer put(byte[] dst, int offset, int len); + + public Buffer put(byte[] dst); + + public byte get(int index); + + public Buffer get(int index, byte[] dst, int offset, int len); + + public Buffer get(int index, byte[] dst); + + public Buffer put(int index, byte b); + + public Buffer put(int index, byte[] dst, int offset, int len); + + public Buffer put(int index, byte[] dst); + + public void flip(); + + public int remaining(); + + public boolean hasRemaining(); + + public HexBuffer asHexBuffer(); + + public ByteBuffer asByteBuffer(); + +} diff --git a/src/main/java/zmodem/xfer/util/ByteBuffer.java b/src/main/java/zmodem/xfer/util/ByteBuffer.java new file mode 100644 index 0000000..3af58d9 --- /dev/null +++ b/src/main/java/zmodem/xfer/util/ByteBuffer.java @@ -0,0 +1,193 @@ +package zmodem.xfer.util; + + +public class ByteBuffer implements Buffer { + + private java.nio.ByteBuffer _wrapped; + + public ByteBuffer(java.nio.ByteBuffer b) { + _wrapped = b; + } + + public static ByteBuffer allocate(int capacity) { + return new ByteBuffer(java.nio.ByteBuffer.allocate(capacity)); + } + + public static ByteBuffer allocateDirect(int capacity) { + return new ByteBuffer(java.nio.ByteBuffer.allocateDirect(capacity)); + } + + public Buffer slice() { + return new ByteBuffer(_wrapped.slice()); + } + + public Buffer duplicate() { + return new ByteBuffer(_wrapped.duplicate()); + } + + public Buffer asReadOnlyBuffer() { + return new ByteBuffer(_wrapped.asReadOnlyBuffer()); + } + + public byte get() { + return _wrapped.get(); + } + + public Buffer get(byte[] dst, int offset, int len) { + for (; offset < len; offset++) + dst[offset] = get(); + + return this; + } + + public Buffer get(byte[] dst) { + return get(dst, 0, dst.length); + } + + public Buffer put(byte b) { + _wrapped.put(b); + return this; + } + + public Buffer put(byte[] dst, int offset, int len) { + for (; offset < len; offset++) + put(dst[offset]); + + return this; + } + + public Buffer put(byte[] dst) { + return put(dst, 0, dst.length); + } + + public byte get(int index) { + return _wrapped.get(index); + } + + public Buffer get(int index, byte[] dst, int offset, int len) { + for (; offset < len; offset++) + dst[offset] = get(index++); + + return this; + } + + public Buffer get(int index, byte[] dst) { + return get(index, dst, 0, dst.length); + } + + public Buffer put(int index, byte b) { + _wrapped.put(index, b); + return this; + } + + public Buffer put(int index, byte[] dst, int offset, int len) { + for (; offset < len; offset++) + put(index++, dst[offset]); + + return this; + } + + public Buffer put(int index, byte[] dst) { + return put(index, dst, 0, dst.length); + } + + public Buffer compact() { + _wrapped.compact(); + return this; + } + + public boolean isDirect() { + return _wrapped.isDirect(); + } + + public char getChar() { + return (char) get(); + } + + public Buffer putChar(char value) { + return put((byte) value); + } + + public char getChar(int index) { + return (char) get(index); + } + + public Buffer putChar(int index, char value) { + return put(index, (byte) value); + } + + public HexBuffer asHexBuffer() { + return new HexBuffer(_wrapped); + } + + public short getShort() { + return Arrays.toShort(new byte[]{get(), get()}, Arrays.Endianness.Little); + } + + public Buffer putShort(short value) { + return put(Arrays.fromShort(value, Arrays.Endianness.Little)); + } + + public short getShort(int index) { + return Arrays.toShort(new byte[]{get(index), get(index + 1)}, Arrays.Endianness.Little); + } + + public Buffer putShort(int index, short value) { + return put(index, Arrays.fromShort(value, Arrays.Endianness.Little)); + } + + public int getInt() { + return Arrays.toInt(new byte[]{get(), get(), get(), get()}, Arrays.Endianness.Little); + } + + public Buffer putInt(int value) { + return put(Arrays.fromInt(value, Arrays.Endianness.Little)); + } + + public int getInt(int index) { + return Arrays.toInt(new byte[]{get(index), get(index + 1), get(index + 2), get(index + 3)}, Arrays.Endianness.Little); + } + + public Buffer putInt(int index, int value) { + return put(index, Arrays.fromInt(value, Arrays.Endianness.Little)); + } + + public long getLong() { + return Arrays.toLong(new byte[]{get(), get(), get(), get(), get(), get(), get(), get()}, Arrays.Endianness.Little); + } + + public Buffer putLong(long value) { + return put(Arrays.fromLong(value, Arrays.Endianness.Little)); + } + + public long getLong(int index) { + return Arrays.toLong(new byte[]{get(index), get(index + 1), get(index + 2), get(index + 3), get(index + 4), get(index + 5), get(index + 6), get(index + 7)}, Arrays.Endianness.Little); + } + + public Buffer putLong(int index, long value) { + return put(index, Arrays.fromLong(value, Arrays.Endianness.Little)); + } + + public boolean isReadOnly() { + return _wrapped.isReadOnly(); + } + + public void flip() { + _wrapped.flip(); + } + + public int remaining() { + return _wrapped.remaining(); + } + + public boolean hasRemaining() { + return remaining() > 0; + } + + public ByteBuffer asByteBuffer() { + return this; + } + +} + + diff --git a/src/main/java/zmodem/xfer/util/CRC.java b/src/main/java/zmodem/xfer/util/CRC.java new file mode 100644 index 0000000..99b7f2b --- /dev/null +++ b/src/main/java/zmodem/xfer/util/CRC.java @@ -0,0 +1,232 @@ +package zmodem.xfer.util; + + +public class CRC { + + public static enum Type { + CRC16(2, 0), + CRC32(4, 0xffffffff); + private int numbytes; + private int initial; + + private Type(int s, int i) { + numbytes = s; + initial = i; + } + + public int size() { + return numbytes; + } + + public int initial() { + return initial; + } + } + + /** + * Copied from the original zmodem source + */ + private static final int crctab[] = { + 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, + 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef, + 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6, + 0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de, + 0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485, + 0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d, + 0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4, + 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc, + 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823, + 0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b, + 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12, + 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a, + 0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41, + 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49, + 0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70, + 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78, + 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f, + 0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067, + 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e, + 0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256, + 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d, + 0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, + 0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c, + 0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634, + 0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab, + 0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3, + 0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a, + 0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92, + 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9, + 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1, + 0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8, + 0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0 + }; + /** + * Copied from the original zmodem source + */ + private static final int cr3tab[] = { /* CRC polynomial 0xedb88320 */ + 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, + 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, + 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, + 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, + 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, + 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, + 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, + 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, + 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, + 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, + 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, + 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, + 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, + 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, + 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, + 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, + 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, + 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, + 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, + 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, + 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, + 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, + 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, + 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, + 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, + 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, + 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, + 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, + 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, + 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, + 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, + 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d + }; + + private static int updcrc(int cp, int crc) { + //System.out.printf("updcrc: %08x / %08x\n",cp,crc); + return (crctab[(crc >> 8) & 0xff] ^ (crc << 8) ^ cp); + } + + private static int updcrc32(int b, int c) { + return (cr3tab[(c ^ b) & 0xff] ^ ((c >> 8) & 0x00FFFFFF)); + } + + + public static byte[] arrayCRC(Type t, byte[] bytes) { + switch (t) { + case CRC16: + return arrayCRC16(bytes); + case CRC32: + return arrayCRC32(bytes); + } + return new byte[0]; + } + + /** + * @param bytes + * @return + */ + public static byte[] arrayCRC16(byte[] bytes) { + byte[] bb = new byte[2]; + int value = CRC16(bytes); + bb[1] = (byte) (value & 0xFF); + bb[0] = (byte) ((value >> 8) & 0xFF); + return bb; + + } + + public static int CRC16(byte[] bytes) { + int crc = 0; + + for (byte b : bytes) + crc = updcrc(0xff & b, crc); + + crc = updcrc(0, updcrc(0, crc)); + + return crc; + } + + /** + * @param bytes + * @return + */ + public static byte[] arrayCRC32(byte[] bytes) { + byte[] bb = new byte[4]; + long value = CRC32(bytes); + bb[3] = (byte) (value & 0xFF); + bb[2] = (byte) ((value >> 8) & 0xFF); + bb[1] = (byte) ((value >> 16) & 0xFF); + bb[0] = (byte) ((value >> 24) & 0xFF); + return bb; + + } + + public static int CRC32(byte[] bytes) { + int crc = 0xFFFFFFFF; + + for (byte b : bytes) + crc = updcrc32(b, crc); + + return ~crc; + } + + private Type type; + private int crc; + + public CRC(Type t) { + type = t; + crc = type.initial(); + } + + public void update(byte b) { + switch (type) { + case CRC16: + crc = updcrc(0xff & b, crc); + break; + case CRC32: + crc = updcrc32((0xff & b), crc); + break; + } + } + + public void update(byte[] array) { + for (byte b : array) + update(b); + } + + public void finalized() { + switch (type) { + case CRC16: + crc = 0xffff & updcrc(0, updcrc(0, crc)); + break; + case CRC32: + crc = ~crc; + break; + } + //System.out.printf("crc: %08x\n",crc); + } + + public int size() { + return type.size(); + } + + public byte[] getBytes() { + byte[] bb; + switch (type) { + case CRC16: + bb = new byte[2]; + bb[1] = (byte) (crc & 0xFF); + bb[0] = (byte) ((crc >> 8) & 0xFF); + return bb; + case CRC32: + bb = new byte[4]; + bb[3] = (byte) (crc & 0xFF); + bb[2] = (byte) ((crc >> 8) & 0xFF); + bb[1] = (byte) ((crc >> 16) & 0xFF); + bb[0] = (byte) ((crc >> 24) & 0xFF); + return bb; + } + return new byte[0]; + } + + public Type type() { + return type; + } + +} diff --git a/src/main/java/zmodem/xfer/util/CRC16.java b/src/main/java/zmodem/xfer/util/CRC16.java new file mode 100644 index 0000000..bb2ffed --- /dev/null +++ b/src/main/java/zmodem/xfer/util/CRC16.java @@ -0,0 +1,58 @@ +package zmodem.xfer.util; + +/** + * Uses table for irreducible polynomial: 1 + x^2 + x^15 + x^16 + */ + +public class CRC16 implements XCRC { + + private static int[] table = { + 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7, + 0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF, + 0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6, + 0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE, + 0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485, + 0xA56A, 0xB54B, 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D, + 0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4, + 0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC, + 0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, 0x2802, 0x3823, + 0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B, + 0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12, + 0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A, + 0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41, + 0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49, + 0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70, + 0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78, + 0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F, + 0x1080, 0x00A1, 0x30C2, 0x20E3, 0x5004, 0x4025, 0x7046, 0x6067, + 0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E, + 0x02B1, 0x1290, 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256, + 0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D, + 0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, + 0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, 0xC71D, 0xD73C, + 0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634, + 0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB, + 0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3, + 0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A, + 0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92, + 0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9, + 0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1, + 0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8, + 0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0, + }; + + @Override + public int getCRCLength() { + return 2; + } + + @Override + public long calcCRC(byte[] block) { + int crc = 0x0000; + for (byte b : block) { + crc = ((crc << 8) ^ table[((crc >> 8) ^ (0xff & b))]) & 0xFFFF; + } + + return crc; + } +} diff --git a/src/main/java/zmodem/xfer/util/CRC8.java b/src/main/java/zmodem/xfer/util/CRC8.java new file mode 100644 index 0000000..a05b9f2 --- /dev/null +++ b/src/main/java/zmodem/xfer/util/CRC8.java @@ -0,0 +1,21 @@ +package zmodem.xfer.util; + +/** + * Created by asirotinkin on 11.11.2014. + */ +public class CRC8 implements XCRC { + @Override + public int getCRCLength() { + return 1; + } + + @Override + public long calcCRC(byte[] block) { + byte checkSumma = 0; + for (int i = 0; i < block.length; i++) { + checkSumma += block[i]; + } + return checkSumma; + } + +} diff --git a/src/main/java/zmodem/xfer/util/HexBuffer.java b/src/main/java/zmodem/xfer/util/HexBuffer.java new file mode 100644 index 0000000..6b17620 --- /dev/null +++ b/src/main/java/zmodem/xfer/util/HexBuffer.java @@ -0,0 +1,235 @@ +package zmodem.xfer.util; + + +import zmodem.xfer.util.Arrays.Endianness; + +public class HexBuffer implements Buffer { + + private static final byte[] hx = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; + + public static byte[] binToHex(byte[] bin) { + byte[] hex = new byte[bin.length * 2]; + for (int i = 0; i < bin.length; i++) + System.arraycopy(toHex(bin[i]), 0, hex, i * 2, 2); + + return hex; + } + + public static byte[] hexToBin(byte[] hex) { + byte[] bin = new byte[hex.length / 2]; + for (int i = 0; i < bin.length; i++) { + byte[] bn = new byte[2]; + System.arraycopy(hex, i * 2, bn, 0, 2); + bin[i] = toByte(bn); + } + + return bin; + } + + private static byte toByte(byte[] array) { + int d; + + d = java.util.Arrays.binarySearch(hx, array[0]) * 16; + d += java.util.Arrays.binarySearch(hx, array[1]); + + return (byte) d; + } + + private static byte[] toHex(byte b) { + byte[] array = new byte[2]; + + array[0] = hx[((b >> 4) & 0xF)]; + array[1] = hx[(b & 0xF)]; + + return array; + } + + private java.nio.ByteBuffer _wrapped; + + protected HexBuffer(java.nio.ByteBuffer b) { + _wrapped = b; + } + + public static HexBuffer allocate(int capacity) { + return new HexBuffer(java.nio.ByteBuffer.allocate(capacity * 2)); + } + + public static HexBuffer allocateDirect(int capacity) { + return new HexBuffer(java.nio.ByteBuffer.allocateDirect(capacity * 2)); + } + + public Buffer slice() { + return new HexBuffer(_wrapped.slice()); + } + + public Buffer duplicate() { + return new HexBuffer(_wrapped.duplicate()); + } + + public Buffer asReadOnlyBuffer() { + return new HexBuffer(_wrapped.asReadOnlyBuffer()); + } + + public byte get() { + return toByte(new byte[]{_wrapped.get(), _wrapped.get()}); + } + + public Buffer get(byte[] dst, int offset, int len) { + for (; offset < len; offset++) + dst[offset] = get(); + + return this; + } + + public Buffer get(byte[] dst) { + return get(dst, 0, dst.length); + } + + public Buffer put(byte b) { + _wrapped.put(toHex(b)); + return this; + } + + public Buffer put(byte[] dst, int offset, int len) { + for (; offset < len; offset++) + put(dst[offset]); + + return this; + } + + public Buffer put(byte[] dst) { + return put(dst, 0, dst.length); + } + + public byte get(int index) { + return toByte(new byte[]{_wrapped.get(index * 2), _wrapped.get(index * 2 + 1)}); + } + + public Buffer get(int index, byte[] dst, int offset, int len) { + for (; offset < len; offset++) + dst[offset] = get(index++); + + return this; + } + + public Buffer get(int index, byte[] dst) { + return get(index, dst, 0, dst.length); + } + + public Buffer put(int index, byte b) { + byte[] array = toHex(b); + _wrapped.put(index * 2, array[0]); + _wrapped.put(index * 2 + 1, array[1]); + return this; + } + + public Buffer put(int index, byte[] dst, int offset, int len) { + for (; offset < len; offset++) + put(index++, dst[offset]); + + return this; + } + + public Buffer put(int index, byte[] dst) { + return put(index, dst, 0, dst.length); + } + + public Buffer compact() { + _wrapped.compact(); + return this; + } + + public boolean isDirect() { + return _wrapped.isDirect(); + } + + public char getChar() { + return (char) get(); + } + + public Buffer putChar(char value) { + return put((byte) value); + } + + public char getChar(int index) { + return (char) get(index); + } + + public Buffer putChar(int index, char value) { + return put(index, (byte) value); + } + + public ByteBuffer asByteBuffer() { + return new ByteBuffer(_wrapped); + } + + public short getShort() { + return Arrays.toShort(new byte[]{get(), get()}, Endianness.Little); + } + + public Buffer putShort(short value) { + return put(Arrays.fromShort(value, Endianness.Little)); + } + + public short getShort(int index) { + return Arrays.toShort(new byte[]{get(index), get(index + 1)}, Endianness.Little); + } + + public Buffer putShort(int index, short value) { + return put(index, Arrays.fromShort(value, Endianness.Little)); + } + + public int getInt() { + return Arrays.toInt(new byte[]{get(), get(), get(), get()}, Endianness.Little); + } + + public Buffer putInt(int value) { + return put(Arrays.fromInt(value, Endianness.Little)); + } + + public int getInt(int index) { + return Arrays.toInt(new byte[]{get(index), get(index + 1), get(index + 2), get(index + 3)}, Endianness.Little); + } + + public Buffer putInt(int index, int value) { + return put(index, Arrays.fromInt(value, Endianness.Little)); + } + + public long getLong() { + return Arrays.toLong(new byte[]{get(), get(), get(), get(), get(), get(), get(), get()}, Endianness.Little); + } + + public Buffer putLong(long value) { + return put(Arrays.fromLong(value, Endianness.Little)); + } + + public long getLong(int index) { + return Arrays.toLong(new byte[]{get(index), get(index + 1), get(index + 2), get(index + 3), get(index + 4), get(index + 5), get(index + 6), get(index + 7)}, Endianness.Little); + } + + public Buffer putLong(int index, long value) { + return put(index, Arrays.fromLong(value, Endianness.Little)); + } + + public boolean isReadOnly() { + return _wrapped.isReadOnly(); + } + + public void flip() { + _wrapped.flip(); + } + + public int remaining() { + double rem = ((double) _wrapped.remaining() / 2.0d); + return (int) Math.floor(rem); + } + + public boolean hasRemaining() { + return (_wrapped.remaining() > 1); + } + + public HexBuffer asHexBuffer() { + return this; + } + +} diff --git a/src/main/java/zmodem/xfer/util/InvalidChecksumException.java b/src/main/java/zmodem/xfer/util/InvalidChecksumException.java new file mode 100644 index 0000000..d590bfe --- /dev/null +++ b/src/main/java/zmodem/xfer/util/InvalidChecksumException.java @@ -0,0 +1,6 @@ +package zmodem.xfer.util; + +public class InvalidChecksumException extends RuntimeException { + private static final long serialVersionUID = 3864874377147160043L; + +} diff --git a/src/main/java/zmodem/xfer/util/TimeoutException.java b/src/main/java/zmodem/xfer/util/TimeoutException.java new file mode 100644 index 0000000..7b0ccff --- /dev/null +++ b/src/main/java/zmodem/xfer/util/TimeoutException.java @@ -0,0 +1,7 @@ +package zmodem.xfer.util; + +/** + * Created by asirotinkin on 12.11.2014. + */ +class TimeoutException extends Exception { +} diff --git a/src/main/java/zmodem/xfer/util/XCRC.java b/src/main/java/zmodem/xfer/util/XCRC.java new file mode 100644 index 0000000..4120007 --- /dev/null +++ b/src/main/java/zmodem/xfer/util/XCRC.java @@ -0,0 +1,10 @@ +package zmodem.xfer.util; + +/** + * Created by Muzeffer on 2016/6/30. + */ +public interface XCRC { + int getCRCLength(); + + long calcCRC(byte[] block); +} diff --git a/src/main/java/zmodem/xfer/zm/packet/Cancel.java b/src/main/java/zmodem/xfer/zm/packet/Cancel.java new file mode 100644 index 0000000..e626312 --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/packet/Cancel.java @@ -0,0 +1,29 @@ +package zmodem.xfer.zm.packet; + + +import zmodem.xfer.util.ASCII; +import zmodem.xfer.util.Buffer; +import zmodem.xfer.util.ByteBuffer; +import zmodem.xfer.zm.util.ZMPacket; + +public class Cancel extends ZMPacket { + + @Override + public Buffer marshall() { + ByteBuffer buff = ByteBuffer.allocate(16); + + for (int i = 0; i < 8; i++) + buff.put(ASCII.CAN.value()); + for (int i = 0; i < 8; i++) + buff.put(ASCII.BS.value()); + + buff.flip(); + + return buff; + } + + @Override + public String toString() { + return "Cancel: CAN * 8 + BS * 8"; + } +} diff --git a/src/main/java/zmodem/xfer/zm/packet/DataPacket.java b/src/main/java/zmodem/xfer/zm/packet/DataPacket.java new file mode 100644 index 0000000..a307de9 --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/packet/DataPacket.java @@ -0,0 +1,92 @@ +package zmodem.xfer.zm.packet; + + +import zmodem.xfer.util.*; +import zmodem.xfer.zm.util.ZDLEEncoder; +import zmodem.xfer.zm.util.ZMPacket; +import zmodem.xfer.zm.util.ZModemCharacter; + +import java.io.ByteArrayOutputStream; + +public class DataPacket extends ZMPacket { + + public static DataPacket unmarshall(Buffer buff, CRC crc) { + byte[] data = new byte[buff.remaining() - crc.size() - 1]; + + buff.get(data); + + ZModemCharacter type; + type = ZModemCharacter.forbyte(buff.get()); + + + byte[] netCrc = new byte[crc.size()]; + buff.get(netCrc); + + if (!Arrays.equals(netCrc, crc.getBytes())) + throw new InvalidChecksumException(); + + return new DataPacket(type, data); + } + + + private final ZModemCharacter type; + private byte[] data = new byte[0]; + + public DataPacket(ZModemCharacter fe) { + type = fe; + } + + public DataPacket(ZModemCharacter fr, byte[] d) { + this(fr); + data = d; + } + + public ZModemCharacter type() { + return type; + } + + public byte[] data() { + return data; + } + + public void setData(byte[] d) { + data = d; + } + + public void copyData(byte[] d) { + data = Arrays.copyOf(d, d.length); + } + + @Override + public Buffer marshall() { + ZDLEEncoder encoder; + ByteBuffer buff = ByteBuffer.allocate(data.length * 2 + 64); + + CRC crc = new CRC(CRC.Type.CRC16); + + encoder = new ZDLEEncoder(data); + + crc.update(data); + buff.put(encoder.zdle(), 0, encoder.zdleLen()); + + buff.put(ZModemCharacter.ZDLE.value()); + + crc.update(type.value()); + buff.put(type.value()); + + crc.finalized(); + + encoder = new ZDLEEncoder(crc.getBytes()); + buff.put(encoder.zdle(), 0, encoder.zdleLen()); + + buff.flip(); + + return buff; + } + + + @Override + public String toString() { + return type + ":" + data.length + " bytes"; + } +} diff --git a/src/main/java/zmodem/xfer/zm/packet/Finish.java b/src/main/java/zmodem/xfer/zm/packet/Finish.java new file mode 100644 index 0000000..96c9df6 --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/packet/Finish.java @@ -0,0 +1,27 @@ +package zmodem.xfer.zm.packet; + + +import zmodem.xfer.util.Buffer; +import zmodem.xfer.util.ByteBuffer; +import zmodem.xfer.zm.util.ZMPacket; + +public class Finish extends ZMPacket { + + @Override + public Buffer marshall() { + ByteBuffer buff = ByteBuffer.allocate(16); + + for (int i = 0; i < 2; i++) + buff.put((byte) 'O'); + + buff.flip(); + + return buff; + } + + @Override + public String toString() { + return "Finish: OO"; + } + +} diff --git a/src/main/java/zmodem/xfer/zm/packet/Format.java b/src/main/java/zmodem/xfer/zm/packet/Format.java new file mode 100644 index 0000000..8e35a81 --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/packet/Format.java @@ -0,0 +1,48 @@ +package zmodem.xfer.zm.packet; + + +import zmodem.xfer.util.CRC; +import zmodem.xfer.zm.util.ZModemCharacter; + +public enum Format { + + BIN32(1, CRC.Type.CRC32, ZModemCharacter.ZBIN32), + BIN(1, CRC.Type.CRC16, ZModemCharacter.ZBIN), + HEX(2, CRC.Type.CRC16, ZModemCharacter.ZHEX); + + + public static Format fromByte(byte b) { + for (Format ft : values()) { + if (ft.character() == b) + return ft; + } + return null; + } + + private int width; + private CRC.Type crc; + private ZModemCharacter character; + + private Format(int bw, CRC.Type crct, ZModemCharacter fmt) { + width = bw; + crc = crct; + character = fmt; + } + + public CRC.Type crc() { + return crc; + } + + public byte character() { + return character.value(); + } + + public int width() { + return width; + } + + public boolean hex() { + return (this == HEX); + } + +} \ No newline at end of file diff --git a/src/main/java/zmodem/xfer/zm/packet/Header.java b/src/main/java/zmodem/xfer/zm/packet/Header.java new file mode 100644 index 0000000..105875c --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/packet/Header.java @@ -0,0 +1,130 @@ +package zmodem.xfer.zm.packet; + + +import zmodem.xfer.util.*; +import zmodem.xfer.zm.util.ZDLEEncoder; +import zmodem.xfer.zm.util.ZMPacket; +import zmodem.xfer.zm.util.ZModemCharacter; + +public class Header extends ZMPacket { + + public static Header unmarshall(Buffer buff) { + + Format fmt = null; + + while (fmt == null) + fmt = Format.fromByte(buff.get()); + + if (fmt.hex()) + buff = buff.asHexBuffer(); + + CRC crc = new CRC(fmt.crc()); + byte b; + + b = buff.get(); + crc.update(b); + ZModemCharacter type = ZModemCharacter.forbyte(b); + + byte[] data = new byte[4]; + for (int i = 0; i < data.length; i++) { + b = buff.get(); + crc.update(b); + data[i] = b; + } + crc.finalized(); + + byte[] netCrc = new byte[crc.size()]; + buff.get(netCrc); + + if (!Arrays.equals(netCrc, crc.getBytes())) + throw new InvalidChecksumException(); + + return new Header(fmt, type, data); + } + + + private Format format; + private ZModemCharacter type; + private byte[] data = {0, 0, 0, 0}; + + private Header(Format fFmt) { + format = fFmt; + } + + public Header(Format fFmt, ZModemCharacter fType) { + this(fFmt); + type = fType; + } + + public Header(Format fFmt, ZModemCharacter fType, byte[] flags) { + this(fFmt, fType); + setFlags(flags); + } + + public Header(Format fFmt, ZModemCharacter fType, int pos) { + this(fFmt, fType); + setPos(pos); + } + + + public ZModemCharacter type() { + return type; + } + + public Format format() { + return format; + } + + public void setFlags(byte[] flags) { + data = Arrays.copyOf(flags, flags.length); + } + + public byte[] getFlags() { + return data; + } + + public void setPos(int num) { + data = Arrays.fromInt(num, Arrays.Endianness.Little); + } + + public int getPos() { + return Arrays.toInt(data, Arrays.Endianness.Little); + } + + @Override + public Buffer marshall() { + ZDLEEncoder encoder; + + Buffer buff; + if (format.hex()) + buff = HexBuffer.allocate(16); + else + buff = ByteBuffer.allocate(32); + + CRC crc = new CRC(format.crc()); + + crc.update(type.value()); + buff.put(type.value()); + + crc.update(data); + encoder = new ZDLEEncoder(data, format); + buff.put(encoder.zdle(), 0, encoder.zdleLen()); + + + crc.finalized(); + + encoder = new ZDLEEncoder(crc.getBytes(), format); + buff.put(encoder.zdle(), 0, encoder.zdleLen()); + + buff.flip(); + + return buff.asByteBuffer(); + + } + + @Override + public String toString() { + return type + ", " + format + ", " + "{" + data[0] + "," + data[1] + "," + data[2] + "," + data[3] + "}"; + } + +} diff --git a/src/main/java/zmodem/xfer/zm/packet/InvalidPacketException.java b/src/main/java/zmodem/xfer/zm/packet/InvalidPacketException.java new file mode 100644 index 0000000..87d1075 --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/packet/InvalidPacketException.java @@ -0,0 +1,9 @@ +package zmodem.xfer.zm.packet; + +import java.io.IOException; + +public class InvalidPacketException extends IOException { + + private static final long serialVersionUID = 6436104259898858243L; + +} diff --git a/src/main/java/zmodem/xfer/zm/proto/Action.java b/src/main/java/zmodem/xfer/zm/proto/Action.java new file mode 100644 index 0000000..10bdb7f --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/proto/Action.java @@ -0,0 +1,5 @@ +package zmodem.xfer.zm.proto; + +public enum Action { + ESCAPE, DATA, HEADER, CANCEL, FINISH; +} diff --git a/src/main/java/zmodem/xfer/zm/proto/Escape.java b/src/main/java/zmodem/xfer/zm/proto/Escape.java new file mode 100644 index 0000000..55a30df --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/proto/Escape.java @@ -0,0 +1,99 @@ +package zmodem.xfer.zm.proto; + +import zmodem.xfer.zm.util.ZModemCharacter; + +import java.util.HashMap; +import java.util.Map; + + +public class Escape { + + private int len = 0; + private Action action = Action.ESCAPE; + + + public Escape(Action a) { + this(a, 0); + } + + + public Escape(Action a, int l) { + len = l; + action = a; + } + + public Action action() { + return action; + } + + + public int len() { + return len; + } + + + private static Map _specials = new HashMap(); + + static { + _specials.put(ZModemCharacter.ZBIN.value(), new Escape(Action.HEADER, 7)); + _specials.put(ZModemCharacter.ZHEX.value(), new Escape(Action.HEADER, 16)); + _specials.put(ZModemCharacter.ZBIN32.value(), new Escape(Action.HEADER, 9)); + _specials.put(ZModemCharacter.ZCRCE.value(), new Escape(Action.DATA, 2)); + _specials.put(ZModemCharacter.ZCRCG.value(), new Escape(Action.DATA, 2)); + _specials.put(ZModemCharacter.ZCRCQ.value(), new Escape(Action.DATA, 2)); + _specials.put(ZModemCharacter.ZCRCW.value(), new Escape(Action.DATA, 2)); + } + + + public static Escape detect(byte b, boolean acceptsHeader) { + Escape r = _specials.get(b); + + + if (r == null || ((!acceptsHeader) && r.action() == Action.HEADER)) + return new Escape(Action.ESCAPE); + + return r; + } + + public static boolean mustEscape(byte b, byte previous, boolean escapeCtl) { + switch (b) { + case 0xd: + case (byte) 0x8d: + if (escapeCtl && previous == '@') + return true; + break; + case 0x18: + case 0x10: + case 0x11: + case 0x13: + case (byte) 0x7f: + case (byte) 0x90: + case (byte) 0x91: + case (byte) 0x93: + case (byte) 0xff: + return true; + default: + if (escapeCtl && ((b & 0x60) == 0)) + return true; + } + return false; + } + + public static byte escapeIt(byte b) { + if (b == (byte) 0x7f) + return ZModemCharacter.ZRUB0.value(); + if (b == (byte) 0xff) + return ZModemCharacter.ZRUB1.value(); + if (b == (byte) ZModemCharacter.ZRUB0.value()) + return 0x7f; + if (b == (byte) ZModemCharacter.ZRUB1.value()) + return (byte) 0xff; + + return (byte) (b ^ 0x40); + } + + @Override + public String toString() { + return "Action=" + action + ", len=" + len; + } +} diff --git a/src/main/java/zmodem/xfer/zm/util/Modem.java b/src/main/java/zmodem/xfer/zm/util/Modem.java new file mode 100644 index 0000000..dc996fa --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/util/Modem.java @@ -0,0 +1,401 @@ +package zmodem.xfer.zm.util; + +import zmodem.xfer.util.CRC16; +import zmodem.xfer.util.CRC8; +import zmodem.xfer.util.XCRC; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * This is core Modem class supporting XModem (and some extensions XModem-1K, XModem-CRC), and YModem.
+ * YModem support is limited (currently block 0 is ignored).
+ *
+ * Created by Anton Sirotinkin (aesirot@mail.ru), Moscow 2014
+ * I hope you will find this program useful.
+ * You are free to use/modify the code for any purpose, but please leave a reference to me.
+ */ +public class Modem { + + public static final byte SOH = 0x01; /* Start Of Header */ + public static final byte STX = 0x02; /* Start Of Text (used like SOH but means 1024 block size) */ + public static final byte EOT = 0x04; /* End Of Transmission */ + public static final byte ACK = 0x06; /* ACKnowlege */ + public static final byte NAK = 0x15; /* Negative AcKnowlege */ + public static final byte CAN = 0x18; /* CANcel character */ + + public static final byte CPMEOF = 0x1A; + public static final byte ST_C = 'C'; + + public static final int MAXERRORS = 10; + + public static final int BLOCK_TIMEOUT = 1000; + public static final int REQUEST_TIMEOUT = 3000; + public static final int WAIT_FOR_RECEIVER_TIMEOUT = 60_000; + public static final int SEND_BLOCK_TIMEOUT = 10_000; + + private final InputStream inputStream; + private final OutputStream outputStream; + + private final byte[] shortBlockBuffer; + private final byte[] longBlockBuffer; + + /** + * Constructor + * + * @param inputStream stream for reading received data from other side + * @param outputStream stream for writing data to other side + */ + public Modem(InputStream inputStream, OutputStream outputStream) { + this.inputStream = inputStream; + this.outputStream = outputStream; + shortBlockBuffer = new byte[128]; + longBlockBuffer = new byte[1024]; + } + + /** + * Wait for receiver request for transmission + * + * @return TRUE if receiver requested CRC-16 checksum, FALSE if 8bit checksum + * @throws IOException + */ + public boolean waitReceiverRequest() throws IOException { + int character; + while (true) { + character = readByte(); + if (character == NAK) + return false; + if (character == ST_C) { + return true; + } + } + } + + /** + * Send a file.
+ *

+ * This method support correct thread interruption, when thread is interrupted "cancel of transmission" will be send. + * So you can move long transmission to other thread and interrupt it according to your algorithm. + * + * @param file + * @param useBlock1K + * @throws IOException + */ + public void send(Path file, boolean useBlock1K) throws IOException { + //open file + try (DataInputStream dataStream = new DataInputStream(Files.newInputStream(file))) { + + boolean useCRC16 = waitReceiverRequest(); + XCRC crc; + if (useCRC16) + crc = new CRC16(); + else + crc = new CRC8(); + + byte[] block; + if (useBlock1K) + block = new byte[1024]; + else + block = new byte[128]; + sendDataBlocks(dataStream, 1, crc, block); + + sendEOT(); + } + } + + public void sendDataBlocks(DataInputStream dataStream, int blockNumber, XCRC crc, byte[] block) throws IOException { + int dataLength; + while ((dataLength = dataStream.read(block)) != -1) { + sendBlock(blockNumber++, block, dataLength, crc); + } + } + + public void sendEOT() throws IOException { + int errorCount = 0; + int character; + while (errorCount < 10) { + sendByte(EOT); + character = readByte(); + + if (character == ACK) { + return; + } else if (character == CAN) { + throw new IOException("Transmission terminated"); + } + errorCount++; + } + } + + public void sendBlock(int blockNumber, byte[] block, int dataLength, XCRC crc) throws IOException { + int errorCount; + int character; + + if (dataLength < block.length) { + block[dataLength] = CPMEOF; + } + errorCount = 0; + + while (errorCount < MAXERRORS) { + + if (block.length == 1024) + outputStream.write(STX); + else //128 + outputStream.write(SOH); + outputStream.write(blockNumber); + outputStream.write(~blockNumber); + + outputStream.write(block); + writeCRC(block, crc); + outputStream.flush(); + + while (true) { + character = readByte(); + if (character == ACK) { + return; + } else if (character == NAK) { + errorCount++; + break; + } else if (character == CAN) { + throw new IOException("Transmission terminated"); + } + } + + } + + throw new IOException("Too many errors caught, abandoning transfer"); + } + + private void writeCRC(byte[] block, XCRC crc) throws IOException { + byte[] crcBytes = new byte[crc.getCRCLength()]; + long crcValue = crc.calcCRC(block); + for (int i = 0; i < crc.getCRCLength(); i++) { + crcBytes[crc.getCRCLength() - i - 1] = (byte) ((crcValue >> (8 * i)) & 0xFF); + } + outputStream.write(crcBytes); + } + + /** + * Receive file
+ *

+ * This method support correct thread interruption, when thread is interrupted "cancel of transmission" will be send. + * So you can move long transmission to other thread and interrupt it according to your algorithm. + * + * @param file file path for storing + * @throws IOException + */ + public void receive(Path file, boolean useCRC16) throws IOException { + try (DataOutputStream dataOutput = new DataOutputStream(Files.newOutputStream(file))) { + int available; + // clean input stream + if ((available = inputStream.available()) > 0) { + inputStream.skip(available); + } + + int character = requestTransmissionStart(useCRC16); + + XCRC crc; + if (useCRC16) + crc = new CRC16(); + else + crc = new CRC8(); + + + processDataBlocks(crc, 1, character, dataOutput); + } + } + + public void processDataBlocks(XCRC crc, int blockNumber, int blockInitialCharacter, DataOutputStream dataOutput) throws IOException { + // read blocks until EOT + boolean result = false; + boolean shortBlock; + byte[] block; + while (true) { + int errorCount = 0; + if (blockInitialCharacter == EOT) { + // end of transmission + sendByte(ACK); + return; + } + + //read and process block + shortBlock = (blockInitialCharacter == SOH); + try { + block = readBlock(blockNumber, shortBlock, crc); + dataOutput.write(block); + blockNumber++; + errorCount = 0; + result = true; + sendByte(ACK); + } catch (InvalidBlockException e) { + errorCount++; + if (errorCount == MAXERRORS) { + interruptTransmission(); + throw new IOException("Transmission aborted, error count exceeded max"); + } + sendByte(NAK); + result = false; + } catch (RepeatedBlockException e) { + //thats ok, accept and wait for next block + sendByte(ACK); + } catch (SynchronizationLostException e) { + //fatal transmission error + interruptTransmission(); + throw new IOException("Fatal transmission error", e); + } + + //wait for next block + blockInitialCharacter = readNextBlockStart(result); + } + } + + public void sendByte(byte b) throws IOException { + outputStream.write(b); + outputStream.flush(); + } + + /** + * Request transmission start and return first byte of "first" block from sender (block 1 for XModem, block 0 for YModem) + * + * @param useCRC16 + * @return + * @throws IOException + */ + public int requestTransmissionStart(boolean useCRC16) throws IOException { + int character; + int errorCount = 0; + byte requestStartByte; + if (!useCRC16) { + requestStartByte = NAK; + } else { + requestStartByte = ST_C; + } + + // wait for first block start + // request transmission start (will be repeated after 10 second timeout for 10 times) + sendByte(requestStartByte); + while (true) { + character = readByte(); + + if (character == SOH || character == STX) { + return character; + } + } + } + + public int readNextBlockStart(boolean lastBlockResult) throws IOException { + int character; + int errorCount = 0; + while (true) { + while (true) { + character = readByte(); + if (character == SOH || character == STX || character == EOT) { + return character; + } + } + // repeat last block result and wait for next block one more time +// if (++errorCount < MAXERRORS) { +// sendByte(lastBlockResult ? ACK : NAK); +// } else { +// interruptTransmission(); +// throw new RuntimeException("Timeout, no data received from transmitter"); +// } + } + } + + private void shortSleep() { + try { + Thread.sleep(10); + } catch (InterruptedException e) { + try { + interruptTransmission(); + } catch (IOException ignore) { + } + throw new RuntimeException("Transmission was interrupted", e); + } + } + + /** + * send CAN to interrupt seance + * + * @throws IOException + */ + public void interruptTransmission() throws IOException { + sendByte(CAN); + sendByte(CAN); + } + + public byte[] readBlock(int blockNumber, boolean shortBlock, XCRC crc) throws IOException, RepeatedBlockException, SynchronizationLostException, InvalidBlockException { + byte[] block; + + if (shortBlock) { + block = shortBlockBuffer; + } else { + block = longBlockBuffer; + } + byte character; + + character = readByte(); + + if (character == blockNumber - 1) { + // this is repeating of last block, possible ACK lost + throw new RepeatedBlockException(); + } + if (character != blockNumber) { + // wrong block - fatal loss of synchronization + throw new SynchronizationLostException(); + } + + character = readByte(); + + if (character != ~blockNumber) { + throw new InvalidBlockException(); + } + + // data + for (int i = 0; i < block.length; i++) { + block[i] = readByte(); + } + + while (true) { + if (inputStream.available() >= crc.getCRCLength()) { + if (crc.calcCRC(block) != readCRC(crc)) { + throw new InvalidBlockException(); + } + break; + } + + shortSleep(); + + } + + return block; + } + + private long readCRC(XCRC crc) throws IOException { + long checkSumma = 0; + for (int j = 0; j < crc.getCRCLength(); j++) { + checkSumma = (checkSumma << 8) + inputStream.read(); + } + return checkSumma; + } + + private byte readByte() throws IOException { + while (true) { + if (inputStream.available() > 0) { + int b = inputStream.read(); + return (byte) b; + } + shortSleep(); + } + } + + public class RepeatedBlockException extends Exception { + } + + public class SynchronizationLostException extends Exception { + } + + public class InvalidBlockException extends Exception { + } +} diff --git a/src/main/java/zmodem/xfer/zm/util/ZDLEEncoder.java b/src/main/java/zmodem/xfer/zm/util/ZDLEEncoder.java new file mode 100644 index 0000000..e352793 --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/util/ZDLEEncoder.java @@ -0,0 +1,58 @@ +package zmodem.xfer.zm.util; + + +import zmodem.xfer.zm.packet.Format; +import zmodem.xfer.zm.proto.Escape; + +public class ZDLEEncoder { + + private byte[] raw; + private byte[] zdle; + private int zdleLen; + private Format format; + + + public ZDLEEncoder(byte[] data) { + this(data, Format.BIN); + } + + public ZDLEEncoder(byte[] data, Format fmt) { + raw = data; + format = fmt; + zdle = new byte[raw.length * 2]; + encode(); + } + + private void putZdle(byte b) { + zdle[zdleLen] = b; + zdleLen++; + } + + private void encode() { + byte previous = 0; + for (byte b : raw) { + + if ((!format.hex()) && Escape.mustEscape(b, previous, false)) { + putZdle(ZModemCharacter.ZDLE.value()); + b = Escape.escapeIt(b); + } + + putZdle(b); + previous = b; + } + } + + public byte[] raw() { + return raw; + } + + public int zdleLen() { + return zdleLen; + } + + public byte[] zdle() { + return zdle; + + } + +} diff --git a/src/main/java/zmodem/xfer/zm/util/ZMOptions.java b/src/main/java/zmodem/xfer/zm/util/ZMOptions.java new file mode 100644 index 0000000..cf9476a --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/util/ZMOptions.java @@ -0,0 +1,49 @@ +package zmodem.xfer.zm.util; + +public enum ZMOptions { + + CANFDX(0x01), /* Rx can send and receive true FDX */ + CANOVIO(0x02), /* Rx can receive data during disk I/O */ + CANBRK(0x04), /* Rx can send a break signal */ + CANCRY(0x08), /* Receiver can decrypt */ + CANLZW(0x10), /* Receiver can uncompress */ + CANFC32(0x20), /* Receiver can use 32 bit Frame Check */ + ESCCTL(0x40), /* Receiver expects ctl chars to be escaped */ + ESC8(0x80), /* Receiver expects 8th bit to be escaped */ + ZCBIN(0x01); + + private byte value; + + private ZMOptions(char b) { + value = (byte) b; + } + + private ZMOptions(int b) { + value = (byte) b; + } + + private ZMOptions(byte b) { + value = b; + } + + + public byte value() { + return value; + } + + public static byte with(ZMOptions... oo) { + byte r = 0; + for (ZMOptions o : oo) + r = (byte) (r | o.value()); + return r; + } + + public static ZMOptions forbyte(byte b) { + for (ZMOptions zb : values()) { + if (zb.value() == b) + return zb; + } + return null; + } + +} diff --git a/src/main/java/zmodem/xfer/zm/util/ZMPacket.java b/src/main/java/zmodem/xfer/zm/util/ZMPacket.java new file mode 100644 index 0000000..c236eda --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/util/ZMPacket.java @@ -0,0 +1,9 @@ +package zmodem.xfer.zm.util; + + +import zmodem.xfer.util.Buffer; + +public abstract class ZMPacket { + public abstract Buffer marshall(); + +} diff --git a/src/main/java/zmodem/xfer/zm/util/ZMPacketFactory.java b/src/main/java/zmodem/xfer/zm/util/ZMPacketFactory.java new file mode 100644 index 0000000..0401785 --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/util/ZMPacketFactory.java @@ -0,0 +1,39 @@ +package zmodem.xfer.zm.util; + + +import zmodem.xfer.zm.packet.DataPacket; + +public class ZMPacketFactory { + + public ZMPacketFactory() { + } + + public DataPacket createZFilePacket(String pathname, long flen) { + return createZFilePacket(pathname, flen, 0, "0", 0, 0); + } + + public DataPacket createZFilePacket(String pathname, long flen, long ts, String mode/*octal*/ + , int remainingfiles, long remainingBytes) { + + StringBuilder builder = new StringBuilder(); + + builder.append(pathname); + builder.append('\0'); + builder.append(flen); + builder.append(' '); + builder.append(ts); + builder.append(' '); + builder.append(mode); + builder.append(' '); + builder.append('0'); + builder.append(' '); + builder.append(remainingfiles); + builder.append(' '); + builder.append(remainingBytes); + builder.append('0'); + + return new DataPacket(ZModemCharacter.ZCRCW, builder.toString().getBytes()); + } + + +} diff --git a/src/main/java/zmodem/xfer/zm/util/ZModemCharacter.java b/src/main/java/zmodem/xfer/zm/util/ZModemCharacter.java new file mode 100644 index 0000000..8beb22b --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/util/ZModemCharacter.java @@ -0,0 +1,64 @@ +package zmodem.xfer.zm.util; + +public enum ZModemCharacter { + ZPAD('*'), + ZDLE(0x18), + ZDLEE(ZDLE.value() ^ 0x40), + ZBIN('A'), + ZHEX('B'), + ZBIN32('C'), + ZCRCE('h'), + ZCRCG('i'), + ZCRCQ('j'), + ZCRCW('k'), + ZRUB0('l'), + ZRUB1('m'), + ZRQINIT(0), + ZRINIT(1), + ZSINIT(2), + ZACK(3), + ZFILE(4), + ZSKIP(5), + ZNAK(6), + ZABORT(7), + ZFIN(8), + ZRPOS(9), + ZDATA(10), + ZEOF(11), + ZFERR(12), + ZCRC(13), + ZCHALLENGE(14), + ZCOMPL(15), + ZCAN(16), + ZFREECNT(17), + ZCOMMAND(18), + ZSTDERR(19); + + private byte value; + + private ZModemCharacter(char b) { + value = (byte) b; + } + + private ZModemCharacter(int b) { + value = (byte) b; + } + + private ZModemCharacter(byte b) { + value = b; + } + + + public byte value() { + return value; + } + + public static ZModemCharacter forbyte(byte b) { + for (ZModemCharacter zb : values()) { + if (zb.value() == b) + return zb; + } + return null; + } + +} diff --git a/src/main/java/zmodem/xfer/zm/util/ZModemReceive.java b/src/main/java/zmodem/xfer/zm/util/ZModemReceive.java new file mode 100644 index 0000000..0131449 --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/util/ZModemReceive.java @@ -0,0 +1,259 @@ +package zmodem.xfer.zm.util; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.apache.commons.net.io.CopyStreamAdapter; +import org.apache.commons.net.io.CopyStreamListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import zmodem.FileCopyStreamEvent; +import zmodem.util.EmptyFileAdapter; +import zmodem.util.FileAdapter; +import zmodem.xfer.util.InvalidChecksumException; +import zmodem.xfer.zm.packet.*; +import zmodem.zm.io.ZMPacketInputStream; +import zmodem.zm.io.ZMPacketOutputStream; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.function.Supplier; + + +public class ZModemReceive { + + private static final Logger log = LoggerFactory.getLogger(ZModemReceive.class); + + private final CopyStreamAdapter adapter = new CopyStreamAdapter(); + private final Supplier destinationSupplier; + private FileAdapter destination; + + private FileAdapter file; + private int fOffset = 0; + private Long filesize; + private int remaining = 0; + private int index = 0; + + private OutputStream fileOs = null; + + private final InputStream netIs; + private final OutputStream netOs; + + private enum Expect { + FILENAME, DATA, NOTHING; + } + + + public ZModemReceive(Supplier destDir, InputStream netin, OutputStream netout) throws IOException { + destinationSupplier = destDir; + netIs = netin; + netOs = netout; + } + + private void open(int offset) throws IOException { + boolean append = false; + + if (offset != 0) { + if (file.exists() && file.length() == offset) + append = true; + else + offset = 0; + } + + IOUtils.closeQuietly(fileOs); + + fileOs = file.getOutputStream(append); + fOffset = offset; + + } + + private void decodeFileNameData(DataPacket p) { + ByteArrayOutputStream filename = new ByteArrayOutputStream(); + StringBuilder extract = new StringBuilder(); + byte[] data = p.data(); + for (int i = 0; i < data.length; i++) { + byte b = data[i]; + if (b == 0) { + for (int j = i + 1; j < data.length; j++) { + b = data[j]; + if (b == 0) { + break; + } + extract.append((char) b); + } + break; + } + filename.write(b); + } + + final String[] segments = extract.toString().split(StringUtils.SPACE); + if (ArrayUtils.isNotEmpty(segments)) { + // filesize + if (segments.length >= 1) { + this.filesize = NumberUtils.toLong(segments[0]); + } + // remaining + if (segments.length >= 5) { + this.remaining = NumberUtils.toInt(segments[4]); + } + } + + file = destination.getChild(filename.toString()); + fOffset = 0; + + index++; + + adapter.bytesTransferred(new FileCopyStreamEvent(this, file.getName(), remaining - index, index, + this.filesize, fOffset, 0, false)); + } + + public void addCopyStreamListener(CopyStreamListener listener) { + adapter.addCopyStreamListener(listener); + } + + public void removeCopyStreamListener(CopyStreamListener listener) { + adapter.removeCopyStreamListener(listener); + } + + private void writeData(DataPacket p) throws IOException { + final byte[] data = p.data(); + + fileOs.write(data); + fOffset += data.length; + + // 开始传输 + adapter.bytesTransferred(new FileCopyStreamEvent(this, file.getName(), remaining, index, + this.filesize, fOffset, 0, false)); + } + + private boolean initDestination() { + if (destination != null) { + return true; + } + destination = destinationSupplier.get(); + return !(destination instanceof EmptyFileAdapter); + } + + public void receive(Supplier isCancelled) { + ZMPacketInputStream is = new ZMPacketInputStream(netIs); + ZMPacketOutputStream os = new ZMPacketOutputStream(netOs); + + Expect expect = Expect.NOTHING; + + byte[] recvOpt = {0, 4, 0, ZMOptions.with(ZMOptions.ESCCTL, ZMOptions.ESC8)}; + + try { + + boolean end = false; + int errorCount = 0; + ZMPacket packet = null; + while (!end) { + try { + packet = is.read(); + } catch (InvalidChecksumException ice) { + if (log.isErrorEnabled()) { + log.error(ice.getMessage(), ice); + } + ++errorCount; + if (errorCount >= 3) { + os.write(new Cancel()); + end = true; + } + } + + if (packet instanceof Cancel) { + end = true; + } else if (packet instanceof Finish) { + end = true; + } + + if (isCancelled.get()) { + break; + } + + // 如果重定向为空,则终止传输 + if (destination instanceof EmptyFileAdapter) { + os.write(new Cancel()); + break; + } + + if (packet instanceof Header header) { + switch (header.type()) { + case ZRQINIT: + os.write(new Header(Format.HEX, ZModemCharacter.ZRINIT, recvOpt)); + break; + case ZFILE: + expect = Expect.FILENAME; + break; + case ZEOF: + os.write(new Header(Format.HEX, ZModemCharacter.ZRINIT, recvOpt)); + expect = Expect.NOTHING; + file = null; + fileOs.flush(); + IOUtils.closeQuietly(fileOs); + fileOs = null; + break; + case ZDATA: + open(header.getPos()); + expect = Expect.DATA; + break; + case ZFIN: + os.write(new Header(Format.HEX, ZModemCharacter.ZFIN)); + end = true; + break; + default: + end = true; + os.write(new Cancel()); + break; + } + } + + if (packet instanceof DataPacket data) { + switch (expect) { + case NOTHING: + os.write(new Header(Format.HEX, ZModemCharacter.ZRINIT, recvOpt)); + break; + case FILENAME: + if (!initDestination()) { + end = true; + os.write(new Cancel()); + break; + } + decodeFileNameData(data); + if (file.length() == filesize) { + os.write(new Header(Format.HEX, ZModemCharacter.ZSKIP)); + adapter.bytesTransferred(new FileCopyStreamEvent(this, file.getName(), remaining, index, + this.filesize, fOffset, 0, true)); + } else { + os.write(new Header(Format.HEX, ZModemCharacter.ZRPOS, (int) file.length())); + } + expect = Expect.NOTHING; + break; + case DATA: + writeData(data); + switch (data.type()) { + case ZCRCW: + expect = Expect.NOTHING; + case ZCRCQ: + os.write(new Header(Format.HEX, ZModemCharacter.ZACK, fOffset)); + break; + case ZCRCE: + expect = Expect.NOTHING; + break; + } + } + } + } + } catch (IOException e) { + if (log.isErrorEnabled()) { + log.error(e.getMessage(), e); + } + } finally { + IOUtils.closeQuietly(fileOs); + } + + } +} diff --git a/src/main/java/zmodem/xfer/zm/util/ZModemSend.java b/src/main/java/zmodem/xfer/zm/util/ZModemSend.java new file mode 100644 index 0000000..4f5d71f --- /dev/null +++ b/src/main/java/zmodem/xfer/zm/util/ZModemSend.java @@ -0,0 +1,213 @@ +package zmodem.xfer.zm.util; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.net.io.CopyStreamAdapter; +import org.apache.commons.net.io.CopyStreamListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import zmodem.FileCopyStreamEvent; +import zmodem.util.FileAdapter; +import zmodem.xfer.util.InvalidChecksumException; +import zmodem.xfer.zm.packet.*; +import zmodem.zm.io.ZMPacketInputStream; +import zmodem.zm.io.ZMPacketOutputStream; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Iterator; +import java.util.List; +import java.util.function.Supplier; + + +public class ZModemSend { + + private static final int packLen = 1024 * 8; + private static final Logger log = LoggerFactory.getLogger(ZModemSend.class); + + private final byte[] data = new byte[packLen]; + private final CopyStreamAdapter adapter = new CopyStreamAdapter(); + private final Supplier> destinationSupplier; + private final InputStream netIs; + private final OutputStream netOs; + + private List files; + private Iterator iter; + + private FileAdapter file; + private int fOffset = 0; + private int index = 0; + private int filesize = 0; + private boolean atEof = false; + private InputStream fileIs; + + + public ZModemSend(Supplier> destinationSupplier, InputStream netin, OutputStream netout) throws IOException { + this.destinationSupplier = destinationSupplier; + netIs = netin; + netOs = netout; + } + + public boolean nextFile() throws IOException { + + IOUtils.closeQuietly(fileIs); + + if (files == null) { + files = destinationSupplier.get(); + iter = files.iterator(); + } + + if (!iter.hasNext()) + return false; + + + file = iter.next(); + fileIs = file.getInputStream(); + filesize = fileIs.available(); + fOffset = 0; + atEof = false; + index++; + + return true; + } + + + public void addCopyStreamListener(CopyStreamListener listener) { + adapter.addCopyStreamListener(listener); + } + + public void removeCopyStreamListener(CopyStreamListener listener) { + adapter.removeCopyStreamListener(listener); + } + + + private void position(int offset) throws IOException { + if (offset != fOffset) { + fileIs.skipNBytes(offset); + fOffset = offset; + } + } + + private byte[] getNextBlock() throws IOException { + final int len = fileIs.read(data); + + /* we know it is a file: all the data is locally available.*/ + if (len < data.length) + atEof = true; + else if (fileIs.available() == 0) + atEof = true; + + if (len == -1) { + return null; + } + + fOffset += len; + + if (len != data.length) + return ArrayUtils.subarray(data, 0, len); + else + return data; + } + + private DataPacket getNextDataPacket() throws IOException { + byte[] data = getNextBlock(); + + ZModemCharacter fe = ZModemCharacter.ZCRCW; + if (atEof) { + fe = ZModemCharacter.ZCRCE; + fileIs.close(); + } + + if (data == null) { + return new DataPacket(fe); + } + + return new DataPacket(fe, data); + } + + public void send(Supplier isCancelled) { + ZMPacketFactory factory = new ZMPacketFactory(); + + ZMPacketInputStream is = new ZMPacketInputStream(netIs); + ZMPacketOutputStream os = new ZMPacketOutputStream(netOs); + + + try { + + boolean end = false; + int errorCount = 0; + ZMPacket packet = null; + + while (!end) { + try { + packet = is.read(); + } catch (InvalidChecksumException ice) { + ++errorCount; + if (errorCount > 20) { + os.write(new Cancel()); + end = true; + } + } + + if (packet instanceof Cancel) { + end = true; + } else if (isCancelled.get()) { + os.write(new Cancel()); + continue; + } + + if (packet instanceof Header header) { + switch (header.type()) { + case ZSKIP: + fireBytesTransferred(true); + case ZRINIT: + if (!nextFile()) { + os.write(new Header(Format.BIN, ZModemCharacter.ZFIN)); + } else { + os.write(new Header(Format.BIN, ZModemCharacter.ZFILE, new byte[]{0, 0, 0, ZMOptions.with(ZMOptions.ZCBIN)})); + os.write(factory.createZFilePacket(file.getName(), filesize)); + fireBytesTransferred(false); + } + break; + case ZRPOS: + if (!atEof) + position(header.getPos()); + case ZACK: + os.write(new Header(Format.BIN, ZModemCharacter.ZDATA, fOffset)); + os.write(getNextDataPacket()); + if (atEof) { + os.write(new Header(Format.HEX, ZModemCharacter.ZEOF, fOffset)); + } + fireBytesTransferred(false); + break; + case ZFIN: + end = true; + os.write(new Finish()); + break; + default: + end = true; + os.write(new Cancel()); + break; + } + + } + } + } catch (IOException e) { + if (log.isErrorEnabled()) { + log.error(e.getMessage(), e); + } + } finally { + IOUtils.closeQuietly(fileIs); + } + + } + + private void fireBytesTransferred(boolean skip) { + if (this.filesize == fOffset) { + System.out.println(); + } + adapter.bytesTransferred(new FileCopyStreamEvent(this, file.getName(), files.size() - index + 1, index, + this.filesize, fOffset, 0, skip)); + } +} diff --git a/src/main/java/zmodem/zm/io/ZMPacketInputStream.java b/src/main/java/zmodem/zm/io/ZMPacketInputStream.java new file mode 100644 index 0000000..78dccae --- /dev/null +++ b/src/main/java/zmodem/zm/io/ZMPacketInputStream.java @@ -0,0 +1,145 @@ +package zmodem.zm.io; + +import zmodem.xfer.io.ObjectInputStream; +import zmodem.xfer.util.ByteBuffer; +import zmodem.xfer.util.CRC; +import zmodem.xfer.zm.packet.*; +import zmodem.xfer.zm.proto.Action; +import zmodem.xfer.zm.proto.Escape; +import zmodem.xfer.zm.util.ZMPacket; +import zmodem.xfer.zm.util.ZModemCharacter; + +import java.io.IOException; +import java.io.InputStream; + + +public class ZMPacketInputStream extends ObjectInputStream { + + private final InputStream netIs; + private CRC dataCRC = new CRC(CRC.Type.CRC16); + private boolean gotFIN = false; + private boolean acceptsHeader = true; + + public ZMPacketInputStream(InputStream is) { + netIs = is; + } + + private boolean ignored(int b) { + return b == 0x11 || b == 0x13 || b == 0x91 || b == 0x93; + } + + private byte implRead() throws IOException { + int n; + do { + n = netIs.read(); + } while (ignored(n)); + + if (n == -1) { + throw new IOException("Closed"); + } + + return (byte) n; + } + + @Override + public ZMPacket read() throws IOException { + ByteBuffer zbuff = ByteBuffer.allocate(1024 * 10); + boolean doread = true; + Action action = Action.ESCAPE; + + int beforeStop = -1; + int countCan = 0; + + while (doread) { + byte n = implRead(); + + if (gotFIN && n == 'O') { + n = implRead(); + if (n == 'O') { + return new Finish(); + } + } + + if (n == ZModemCharacter.ZDLE.value()) { + n = (byte) netIs.read(); + + if (n == ZModemCharacter.ZDLE.value()) + countCan += 2; + else + countCan = 0; + + Escape escape = Escape.detect(n, acceptsHeader); + + if (escape.action() != Action.ESCAPE && beforeStop < 0) { + action = escape.action(); + + if (escape.action() == Action.DATA) + beforeStop = dataCRC.size(); + else + beforeStop = escape.len(); + + dataCRC.update(n); + } else { + n = Escape.escapeIt(n); + } + + } + zbuff.put(n); + + if (beforeStop < 0) + dataCRC.update(n); + + if (beforeStop == 0) + doread = false; + + if (beforeStop > 0) + beforeStop--; + + if (countCan >= 5) { + doread = false; + action = Action.CANCEL; + } + + } + zbuff.flip(); + + ZMPacket r = null; + switch (action) { + case HEADER: + r = Header.unmarshall(zbuff); + + + if (((Header) r).format() == Format.BIN32) + dataCRC = new CRC(CRC.Type.CRC32); + else + dataCRC = new CRC(CRC.Type.CRC16); + + if (((Header) r).type() == ZModemCharacter.ZFIN) + gotFIN = true; + if (((Header) r).type() == ZModemCharacter.ZDATA || ((Header) r).type() == ZModemCharacter.ZFILE) + acceptsHeader = false; + + break; + case DATA: + dataCRC.finalized(); + + r = DataPacket.unmarshall(zbuff, dataCRC); + + dataCRC = new CRC(dataCRC.type()); + + if (((DataPacket) r).type() == ZModemCharacter.ZCRCG) + acceptsHeader = false; + else + acceptsHeader = true; + + break; + case CANCEL: + r = new Cancel(); + dataCRC = new CRC(dataCRC.type()); + break; + } + + return r; + } + +} diff --git a/src/main/java/zmodem/zm/io/ZMPacketOutputStream.java b/src/main/java/zmodem/zm/io/ZMPacketOutputStream.java new file mode 100644 index 0000000..cbc70c4 --- /dev/null +++ b/src/main/java/zmodem/zm/io/ZMPacketOutputStream.java @@ -0,0 +1,67 @@ +package zmodem.zm.io; + +import zmodem.xfer.io.ObjectOutputStream; +import zmodem.xfer.util.ASCII; +import zmodem.xfer.util.Buffer; +import zmodem.xfer.zm.packet.DataPacket; +import zmodem.xfer.zm.packet.Format; +import zmodem.xfer.zm.packet.Header; +import zmodem.xfer.zm.util.ZMPacket; +import zmodem.xfer.zm.util.ZModemCharacter; + +import java.io.IOException; +import java.io.OutputStream; + + +public class ZMPacketOutputStream extends ObjectOutputStream { + + private final OutputStream os; + + public ZMPacketOutputStream(OutputStream netOs) { + os = netOs; + } + + public void implWrite(byte b) throws IOException { + //System.out.printf("%02x",b); + os.write(b); + } + + @Override + public void write(ZMPacket o) throws IOException { + Buffer buff = o.marshall(); + Format fmt = null; + + if (o instanceof Header) + fmt = ((Header) o).format(); + + + if (fmt != null) { + for (int i = 0; i < fmt.width(); i++) + implWrite(ZModemCharacter.ZPAD.value()); + + implWrite(ZModemCharacter.ZDLE.value()); + implWrite(fmt.character()); + } + + + if (buff.hasRemaining()) { + byte[] buf = new byte[buff.remaining()]; + buff.get(buf); + os.write(buf); + } + + if (fmt != null) if (fmt.hex()) { + implWrite(ASCII.CR.value()); + implWrite(ASCII.LF.value()); + implWrite(ASCII.XON.value()); + } + + if (o instanceof DataPacket) if (((DataPacket) o).type() == ZModemCharacter.ZCRCW) + implWrite(ASCII.XON.value()); + + + os.flush(); + + } + +} diff --git a/src/main/kotlin/app/termora/Actions.kt b/src/main/kotlin/app/termora/Actions.kt new file mode 100644 index 0000000..962268f --- /dev/null +++ b/src/main/kotlin/app/termora/Actions.kt @@ -0,0 +1,50 @@ +package app.termora + +object Actions { + + /** + * 打开设置 + */ + const val SETTING = "SettingAction" + + /** + * 将命令发送到多个会话 + */ + const val MULTIPLE = "MultipleAction" + + /** + * 查找 + */ + const val FIND_EVERYWHERE = "FindEverywhereAction" + + /** + * 关键词高亮 + */ + const val KEYWORD_HIGHLIGHT_EVERYWHERE = "KeywordHighlightAction" + + /** + * Key manager + */ + const val KEY_MANAGER = "KeyManagerAction" + + /** + * 更新 + */ + const val APP_UPDATE = "AppUpdateAction" + + + /** + * 宏 + */ + const val MACRO = "MacroAction" + + /** + * 添加主机对话框 + */ + const val ADD_HOST = "AddHostAction" + + /** + * 打开一个主机 + */ + const val OPEN_HOST = "OpenHostAction" +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/AnAction.kt b/src/main/kotlin/app/termora/AnAction.kt new file mode 100644 index 0000000..6f05563 --- /dev/null +++ b/src/main/kotlin/app/termora/AnAction.kt @@ -0,0 +1,16 @@ +package app.termora + +import org.jdesktop.swingx.action.BoundAction +import javax.swing.Icon + +abstract class AnAction : BoundAction { + + constructor() : super() + constructor(icon: Icon) : super() { + super.putValue(SMALL_ICON, icon) + } + + constructor(name: String?) : super(name) + constructor(name: String?, icon: Icon?) : super(name, icon) + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Application.kt b/src/main/kotlin/app/termora/Application.kt new file mode 100644 index 0000000..2042d37 --- /dev/null +++ b/src/main/kotlin/app/termora/Application.kt @@ -0,0 +1,146 @@ +package app.termora + +import com.formdev.flatlaf.util.SystemInfo +import com.jthemedetecor.util.OsInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.apache.commons.io.FileUtils +import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.SystemUtils +import org.slf4j.LoggerFactory +import java.awt.Desktop +import java.io.File +import java.net.URI +import java.time.Duration +import java.util.* +import kotlin.reflect.KClass + +object Application { + private val services = Collections.synchronizedMap(mutableMapOf, Any>()) + private lateinit var baseDataDir: File + + + val ohMyJson = Json { + ignoreUnknownKeys = true + // 默认值不输出 + encodeDefaults = false + } + + + val httpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(Duration.ofSeconds(10)) + .callTimeout(Duration.ofSeconds(60)) + .writeTimeout(Duration.ofSeconds(60)) + .readTimeout(Duration.ofSeconds(60)) + .addInterceptor( + HttpLoggingInterceptor(object : HttpLoggingInterceptor.Logger { + private val log = LoggerFactory.getLogger(HttpLoggingInterceptor::class.java) + override fun log(message: String) { + if (log.isDebugEnabled) log.debug(message) + } + }).setLevel(HttpLoggingInterceptor.Level.BASIC) + ) + .build() + } + + fun getDefaultShell(): String { + if (SystemInfo.isWindows) { + return "cmd.exe" + } else { + val shell = System.getenv("SHELL") + if (shell != null && shell.isNotBlank()) { + return shell + } + } + return "/bin/bash" + } + + fun getBaseDataDir(): File { + if (::baseDataDir.isInitialized) { + return baseDataDir + } + + // 从启动参数取 + var baseDataDir = System.getProperty("${getName()}.base-data-dir".lowercase()) + // 取不到从环境取 + if (StringUtils.isBlank(baseDataDir)) { + baseDataDir = System.getenv("${getName()}-BASE-DATA-DIR".uppercase()) + } + + var dir = File(SystemUtils.getUserHome(), ".${getName()}".lowercase()) + if (StringUtils.isNotBlank(baseDataDir)) { + dir = File(baseDataDir) + } + + + FileUtils.forceMkdir(dir) + Application.baseDataDir = dir + + return dir + } + + fun getDatabaseFile(): File { + return FileUtils.getFile(getBaseDataDir(), "storage") + } + + fun getVersion(): String { + var version = System.getProperty("jpackage.app-version") + if (version.isNullOrBlank()) { + version = System.getProperty("app-version") + } + if (version.isNullOrBlank()) { + version = "unknown" + } + return version + } + + fun getAppPath(): String { + return StringUtils.defaultString(System.getProperty("jpackage.app-path")) + } + + fun getName(): String { + return "Termora" + } + + fun browse(uri: URI, async: Boolean = true) { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(uri) + } else if (async) { + @Suppress("OPT_IN_USAGE") + GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) } + } else { + tryBrowse(uri) + } + } + + @Suppress("UNCHECKED_CAST") + fun getService(clazz: KClass): T { + if (services.containsKey(clazz)) { + return services[clazz] as T + } + throw IllegalStateException("$clazz does not exist") + } + + @Synchronized + fun registerService(clazz: KClass<*>, service: Any) { + if (services.containsKey(clazz)) { + throw IllegalStateException("$clazz already registered") + } + services[clazz] = service + } + + private fun tryBrowse(uri: URI) { + if (SystemInfo.isWindows) { + ProcessBuilder("explorer", uri.toString()).start() + } else if (SystemInfo.isMacOS) { + ProcessBuilder("open", uri.toString()).start() + } else if (SystemInfo.isLinux && OsInfo.isGnome()) { + ProcessBuilder("xdg-open", uri.toString()).start() + } + } +} diff --git a/src/main/kotlin/app/termora/ApplicationDisposable.kt b/src/main/kotlin/app/termora/ApplicationDisposable.kt new file mode 100644 index 0000000..7de6de4 --- /dev/null +++ b/src/main/kotlin/app/termora/ApplicationDisposable.kt @@ -0,0 +1,10 @@ +package app.termora + +/** + * 将在 JVM 进程退出时释放 + */ +class ApplicationDisposable : Disposable { + companion object { + val instance by lazy { ApplicationDisposable() } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/ApplicationRunner.kt b/src/main/kotlin/app/termora/ApplicationRunner.kt new file mode 100644 index 0000000..945da7c --- /dev/null +++ b/src/main/kotlin/app/termora/ApplicationRunner.kt @@ -0,0 +1,254 @@ +package app.termora + +import app.termora.db.Database +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.FlatSystemProperties +import com.formdev.flatlaf.extras.FlatInspector +import com.formdev.flatlaf.util.SystemInfo +import com.jthemedetecor.OsThemeDetector +import com.sun.jna.platform.WindowUtils +import com.sun.jna.platform.win32.User32 +import com.sun.jna.ptr.IntByReference +import org.apache.commons.io.FileUtils +import org.apache.commons.lang3.LocaleUtils +import org.apache.commons.lang3.SystemUtils +import org.apache.commons.lang3.math.NumberUtils +import org.slf4j.LoggerFactory +import org.tinylog.configuration.Configuration +import java.io.File +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.nio.file.StandardOpenOption +import java.util.* +import javax.swing.* +import javax.swing.WindowConstants.DISPOSE_ON_CLOSE +import kotlin.system.exitProcess + +class ApplicationRunner { + private lateinit var singletonLock: FileLock + private val log by lazy { + if (!::singletonLock.isInitialized) { + throw UnsupportedOperationException("Singleton lock is not initialized") + } + LoggerFactory.getLogger("Main") + } + + fun run() { + // 覆盖 tinylog 配置 + setupTinylog() + + // 是否单例 + checkSingleton() + + // 打印系统信息 + printSystemInfo() + + SwingUtilities.invokeAndWait { + // 打开数据库 + openDatabase() + + // 加载设置 + loadSettings() + + // 设置 LAF + setupLaf() + + // 解密数据 + openDoor() + + // 启动主窗口 + startMainFrame() + } + } + + + private fun openDoor() { + if (Doorman.instance.isWorking()) { + if (!DoormanDialog(null).open()) { + exitProcess(1) + } + } + } + + private fun startMainFrame() { + val frame = TermoraFrame() + frame.title = if (SystemInfo.isLinux) null else Application.getName() + frame.defaultCloseOperation = DISPOSE_ON_CLOSE + frame.setSize(1280, 800) + frame.setLocationRelativeTo(null) + frame.isVisible = true + } + + + private fun loadSettings() { + val language = Database.instance.appearance.language + val locale = runCatching { LocaleUtils.toLocale(language) }.getOrElse { Locale.getDefault() } + if (log.isInfoEnabled) { + log.info("Language: {} , Locale: {}", language, locale) + } + Locale.setDefault(locale) + } + + + private fun setupLaf() { + + System.setProperty(FlatSystemProperties.USE_WINDOW_DECORATIONS, "${SystemInfo.isLinux}") + System.setProperty(FlatSystemProperties.USE_ROUNDED_POPUP_BORDER, "false") + + if (SystemInfo.isLinux) { + JFrame.setDefaultLookAndFeelDecorated(true) + JDialog.setDefaultLookAndFeelDecorated(true) + } + + val themeManager = ThemeManager.instance + val settings = Database.instance + var theme = settings.appearance.theme + + // 如果是跟随系统或者不存在样式,那么使用默认的 + if (settings.appearance.followSystem || !themeManager.themes.containsKey(theme)) { + theme = if (OsThemeDetector.getDetector().isDark) { + "Dark" + } else { + "Light" + } + } + + themeManager.change(theme, true) + + FlatInspector.install("ctrl shift alt X"); + + UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true) + UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false) + UIManager.put("TitlePane.useWindowDecorations", false) + + UIManager.put("Component.arc", 5) + UIManager.put("TextComponent.arc", UIManager.getInt("Component.arc")) + UIManager.put("Component.hideMnemonics", false) + + UIManager.put("TitleBar.height", 36) + + UIManager.put("Dialog.width", 650) + UIManager.put("Dialog.height", 550) + + + if (SystemInfo.isMacOS) { + UIManager.put("TabbedPane.tabHeight", UIManager.getInt("TitleBar.height")) + } else if (SystemInfo.isLinux) { + UIManager.put("TabbedPane.tabHeight", UIManager.getInt("TitleBar.height") - 4) + } else { + UIManager.put("TabbedPane.tabHeight", UIManager.getInt("TitleBar.height") - 6) + } + + if (SystemInfo.isLinux) { + UIManager.put("TitlePane.centerTitle", true) + UIManager.put("TitlePane.showIcon", false) + UIManager.put("TitlePane.showIconInDialogs", false) + } + + UIManager.put("Table.rowHeight", 24) + UIManager.put("Table.cellNoFocusBorder", BorderFactory.createEmptyBorder()) + UIManager.put("Table.focusCellHighlightBorder", BorderFactory.createEmptyBorder()) + UIManager.put("Table.focusSelectedCellHighlightBorder", BorderFactory.createEmptyBorder()) + UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc")) + + UIManager.put("Tree.rowHeight", 24) + UIManager.put("Tree.background", DynamicColor("window")) + UIManager.put("Tree.selectionArc", UIManager.getInt("Component.arc")) + UIManager.put("Tree.showCellFocusIndicator", false) + UIManager.put("Tree.repaintWholeRow", true) + + UIManager.put("List.selectionArc", UIManager.getInt("Component.arc")) + + + } + + private fun printSystemInfo() { + if (log.isInfoEnabled) { + log.info("Welcome to ${Application.getName()} ${Application.getVersion()}!") + log.info( + "JVM name: {} , vendor: {} , version: {}", + SystemUtils.JAVA_VM_NAME, + SystemUtils.JAVA_VM_VENDOR, + SystemUtils.JAVA_VM_VERSION, + ) + log.info( + "OS name: {} , version: {} , arch: {}", + SystemUtils.OS_NAME, + SystemUtils.OS_VERSION, + SystemUtils.OS_ARCH + ) + log.info("Base config dir: ${Application.getBaseDataDir().absolutePath}") + } + } + + + /** + * Windows 情况覆盖 + */ + private fun setupTinylog() { + if (SystemInfo.isWindows) { + val dir = File(Application.getBaseDataDir(), "logs") + FileUtils.forceMkdir(dir) + Configuration.set("writer_file.latest", "${dir.absolutePath}/${Application.getName().lowercase()}.log") + Configuration.set("writer_file.file", "${dir.absolutePath}/{date:yyyy}-{date:MM}-{date:dd}.log") + } + } + + + private fun checkSingleton() { + val file = File(Application.getBaseDataDir(), "lock") + val pidFile = File(Application.getBaseDataDir(), "pid") + + + 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 + } + } + + System.err.println("Program is already running") + exitProcess(1) + } + + singletonLock = lock + } + + + private fun openDatabase() { + val dir = Application.getDatabaseFile() + try { + Database.open(dir) + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + JOptionPane.showMessageDialog( + null, "Unable to open database", + I18n.getString("termora.title"), JOptionPane.ERROR_MESSAGE + ) + exitProcess(1) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/BannerPanel.kt b/src/main/kotlin/app/termora/BannerPanel.kt new file mode 100644 index 0000000..627f413 --- /dev/null +++ b/src/main/kotlin/app/termora/BannerPanel.kt @@ -0,0 +1,68 @@ +package app.termora + +import com.formdev.flatlaf.FlatLaf +import org.apache.commons.lang3.RandomUtils +import java.awt.* +import javax.swing.JComponent +import javax.swing.UIManager + +class BannerPanel(fontSize: Int = 11, val beautiful: Boolean = false) : JComponent() { + private val banner = """ + ______ + /_ __/__ _________ ___ ____ _________ _ + / / / _ \/ ___/ __ `__ \/ __ \/ ___/ __ `/ + / / / __/ / / / / / / / /_/ / / / /_/ / +/_/ \___/_/ /_/ /_/ /_/\____/_/ \__,_/ +""".trimIndent().lines() + + private val colors = mutableListOf() + + init { + font = Font("JetBrains Mono", Font.PLAIN, fontSize) + preferredSize = Dimension(width, getFontMetrics(font).height * banner.size) + size = preferredSize + } + + override fun paintComponent(g: Graphics) { + if (g is Graphics2D) { + g.setRenderingHints( + RenderingHints( + RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON + ) + ) + } + + g.font = font + g.color = UIManager.getColor("TextField.placeholderForeground") + + val height = g.fontMetrics.height + val descent = g.fontMetrics.descent + val offset = width / 2 - g.fontMetrics.stringWidth(banner.maxBy { it.length }) / 2 + val insecure = RandomUtils.insecure() + var index = 0 + + for (i in banner.indices) { + var x = offset + val y = height * (i + 1) - descent + val chars = banner[i].toCharArray() + for (j in chars.indices) { + if (beautiful) { + if (colors.size <= index) { + colors.add( + Color( + insecure.randomInt(0, 255), + insecure.randomInt(0, 255), + insecure.randomInt(0, 255) + ) + ) + } + val color = colors[index++] + g.color = if (FlatLaf.isLafDark()) color.brighter() else color.darker() + } + g.drawChars(chars, j, 1, x, y) + x += g.fontMetrics.charWidth(chars[j]) + } + } + + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/ChannelShellPtyConnector.kt b/src/main/kotlin/app/termora/ChannelShellPtyConnector.kt new file mode 100644 index 0000000..ddd11a7 --- /dev/null +++ b/src/main/kotlin/app/termora/ChannelShellPtyConnector.kt @@ -0,0 +1,41 @@ +package app.termora + +import app.termora.terminal.StreamPtyConnector +import org.apache.sshd.client.channel.ChannelShell +import org.apache.sshd.client.channel.ClientChannelEvent +import java.io.InputStreamReader +import java.nio.charset.Charset + +class ChannelShellPtyConnector( + private val channel: ChannelShell, + private val charset: Charset = Charsets.UTF_8 +) : StreamPtyConnector(channel.invertedOut, channel.invertedIn) { + + private val reader = InputStreamReader(input, charset) + + override fun read(buffer: CharArray): Int { + return reader.read(buffer) + } + + override fun write(buffer: ByteArray, offset: Int, len: Int) { + output.write(buffer, offset, len) + output.flush() + } + + override fun write(buffer: String) { + write(buffer.toByteArray(charset)) + } + + override fun resize(rows: Int, cols: Int) { + channel.sendWindowChange(cols, rows) + } + + override fun waitFor(): Int { + channel.waitFor(listOf(ClientChannelEvent.CLOSED), Long.MAX_VALUE) + return channel.exitStatus + } + + override fun close() { + channel.close(true) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Crypto.kt b/src/main/kotlin/app/termora/Crypto.kt new file mode 100644 index 0000000..f419e9b --- /dev/null +++ b/src/main/kotlin/app/termora/Crypto.kt @@ -0,0 +1,155 @@ +package app.termora + +import org.apache.commons.codec.binary.Base64 +import org.apache.commons.lang3.RandomUtils +import org.slf4j.LoggerFactory +import java.security.* +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec +import kotlin.time.measureTime + +object AES { + private const val ALGORITHM = "AES" + + /** + * ECB 没有 IV + */ + object ECB { + private const val TRANSFORMATION = "AES/ECB/PKCS5Padding" + + fun encrypt(key: ByteArray, data: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, ALGORITHM)) + return cipher.doFinal(data) + } + + fun decrypt(key: ByteArray, data: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, ALGORITHM)) + return cipher.doFinal(data) + } + + } + + /** + * 携带 IV + */ + object CBC { + private const val TRANSFORMATION = "AES/CBC/PKCS5Padding" + + fun encrypt(key: ByteArray, iv: ByteArray, data: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, ALGORITHM), IvParameterSpec(iv)) + return cipher.doFinal(data) + } + + fun decrypt(key: ByteArray, iv: ByteArray, data: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, ALGORITHM), IvParameterSpec(iv)) + return cipher.doFinal(data) + } + + + fun String.aesCBCEncrypt(key: ByteArray, iv: ByteArray): ByteArray { + return encrypt(key, iv, toByteArray()) + } + + fun ByteArray.aesCBCEncrypt(key: ByteArray, iv: ByteArray): ByteArray { + return encrypt(key, iv, this) + } + + fun ByteArray.aesCBCDecrypt(key: ByteArray, iv: ByteArray): ByteArray { + return decrypt(key, iv, this) + } + + } + + fun randomBytes(size: Int = 32): ByteArray { + return RandomUtils.secureStrong().randomBytes(size) + } + + fun ByteArray.encodeBase64String(): String { + return Base64.encodeBase64String(this) + } + + fun String.decodeBase64(): ByteArray { + return Base64.decodeBase64(this) + } +} + + +object PBKDF2 { + + private const val ALGORITHM = "PBKDF2WithHmacSHA512" + private val log = LoggerFactory.getLogger(PBKDF2::class.java) + + fun generateSecret( + password: CharArray, + salt: ByteArray, + iterationCount: Int = 150000, + keyLength: Int = 256 + ): ByteArray { + val bytes: ByteArray + val time = measureTime { + bytes = SecretKeyFactory.getInstance(ALGORITHM) + .generateSecret(PBEKeySpec(password, salt, iterationCount, keyLength)) + .encoded + } + if (log.isDebugEnabled) { + log.debug("Secret generated $time") + } + return bytes + } + +} + + +object RSA { + + private const val TRANSFORMATION = "RSA" + + fun encrypt(publicKey: PublicKey, data: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + return cipher.doFinal(data) + } + + fun decrypt(privateKey: PrivateKey, data: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, privateKey) + return cipher.doFinal(data) + } + + fun encrypt(privateKey: PrivateKey, data: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, privateKey) + return cipher.doFinal(data) + } + + fun decrypt(publicKey: PublicKey, data: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, publicKey) + return cipher.doFinal(data) + } + + fun generatePublic(publicKey: ByteArray): PublicKey { + return KeyFactory.getInstance(TRANSFORMATION) + .generatePublic(X509EncodedKeySpec(publicKey)) + } + + fun generatePrivate(privateKey: ByteArray): PrivateKey { + return KeyFactory.getInstance(TRANSFORMATION) + .generatePrivate(PKCS8EncodedKeySpec(privateKey)) + } + + fun generateKeyPair(keySize: Int = 2048): KeyPair { + val generator = KeyPairGenerator.getInstance(TRANSFORMATION) + generator.initialize(keySize) + return generator.generateKeyPair() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/DialogWrapper.kt b/src/main/kotlin/app/termora/DialogWrapper.kt new file mode 100644 index 0000000..f674aa8 --- /dev/null +++ b/src/main/kotlin/app/termora/DialogWrapper.kt @@ -0,0 +1,200 @@ +package app.termora + +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.FlatLaf +import com.formdev.flatlaf.util.SystemInfo +import com.jetbrains.JBR +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.Window +import java.awt.event.ActionEvent +import java.awt.event.KeyEvent +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import javax.swing.* + +abstract class DialogWrapper(owner: Window?) : JDialog(owner) { + private val rootPanel = JPanel(BorderLayout()) + private val titleLabel = JLabel() + private val titleBar by lazy { LogicCustomTitleBar.createCustomTitleBar(this) } + val disposable = Disposer.newDisposable() + + companion object { + const val DEFAULT_ACTION = "DEFAULT_ACTION" + } + + + protected var controlsVisible = true + set(value) { + field = value + titleBar.putProperty("controls.visible", value) + } + + protected var titleBarHeight = UIManager.getInt("TabbedPane.tabHeight").toFloat() + set(value) { + titleBar.height = value + field = value + } + + protected var lostFocusDispose = false + protected var escapeDispose = true + + protected fun init() { + + defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE + + initTitleBar() + initEvents() + + if (JBR.isWindowDecorationsSupported()) { + if (rootPane.getClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE) != false) { + val titlePanel = createTitlePanel() + if (titlePanel != null) { + rootPanel.add(titlePanel, BorderLayout.NORTH) + } + } + } + + rootPanel.add(createCenterPanel(), BorderLayout.CENTER) + + val southPanel = createSouthPanel() + if (southPanel != null) { + rootPanel.add(southPanel, BorderLayout.SOUTH) + } + + rootPane.contentPane = rootPanel + } + + protected open fun createSouthPanel(): JComponent? { + val box = Box.createHorizontalBox() + box.border = BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor), + BorderFactory.createEmptyBorder(8, 12, 8, 12) + ) + + val okButton = createJButtonForAction(createOkAction()) + box.add(Box.createHorizontalGlue()) + box.add(createJButtonForAction(CancelAction())) + box.add(Box.createHorizontalStrut(8)) + box.add(okButton) + + + return box + } + + protected open fun createOkAction(): AbstractAction { + return OkAction() + } + + protected open fun createJButtonForAction(action: Action): JButton { + val button = JButton(action) + val value = action.getValue(DEFAULT_ACTION) + if (value is Boolean && value) { + rootPane.defaultButton = button + } + return button + } + + protected open fun createTitlePanel(): JPanel? { + titleLabel.horizontalAlignment = SwingConstants.CENTER + titleLabel.verticalAlignment = SwingConstants.CENTER + titleLabel.text = title + titleLabel.putClientProperty("FlatLaf.style", "font: bold") + + val panel = JPanel(BorderLayout()) + panel.add(titleLabel, BorderLayout.CENTER) + panel.preferredSize = Dimension(-1, titleBar.height.toInt()) + + + return panel + } + + override fun setTitle(title: String?) { + super.setTitle(title) + titleLabel.text = title + } + + protected abstract fun createCenterPanel(): JComponent + + private fun initEvents() { + + val inputMap = rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) + if (escapeDispose) { + inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "close") + } + + inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_W, toolkit.menuShortcutKeyMaskEx), "close") + + rootPane.actionMap.put("close", object : AnAction() { + override fun actionPerformed(e: ActionEvent) { + doCancelAction() + } + }) + + addWindowFocusListener(object : WindowAdapter() { + override fun windowLostFocus(e: WindowEvent) { + if (lostFocusDispose) { + SwingUtilities.invokeLater { doCancelAction() } + } + } + }) + + addWindowListener(object : WindowAdapter() { + override fun windowClosed(e: WindowEvent) { + Disposer.dispose(disposable) + } + }) + + if (SystemInfo.isWindows) { + addWindowListener(object : WindowAdapter(), ThemeChangeListener { + override fun windowClosed(e: WindowEvent) { + ThemeManager.instance.removeThemeChangeListener(this) + } + + override fun windowOpened(e: WindowEvent) { + onChanged() + ThemeManager.instance.addThemeChangeListener(this) + } + + override fun onChanged() { + titleBar.putProperty("controls.dark", FlatLaf.isLafDark()) + } + }) + } + } + + private fun initTitleBar() { + titleBar.height = titleBarHeight + titleBar.putProperty("controls.visible", controlsVisible) + if (JBR.isWindowDecorationsSupported()) { + JBR.getWindowDecorations().setCustomTitleBar(this, titleBar) + } + } + + protected open fun doOKAction() { + dispose() + } + + protected open fun doCancelAction() { + dispose() + } + + protected inner class OkAction(text: String = I18n.getString("termora.confirm")) : AnAction(text) { + init { + putValue(DEFAULT_ACTION, true) + } + + override fun actionPerformed(e: ActionEvent) { + doOKAction() + } + + } + + protected inner class CancelAction : AnAction(I18n.getString("termora.cancel")) { + + override fun actionPerformed(e: ActionEvent) { + doCancelAction() + } + + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/DocumentAdaptor.kt b/src/main/kotlin/app/termora/DocumentAdaptor.kt new file mode 100644 index 0000000..2dcf61f --- /dev/null +++ b/src/main/kotlin/app/termora/DocumentAdaptor.kt @@ -0,0 +1,18 @@ +package app.termora + +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener + +abstract class DocumentAdaptor : DocumentListener { + override fun insertUpdate(e: DocumentEvent) { + changedUpdate(e) + } + + override fun removeUpdate(e: DocumentEvent) { + changedUpdate(e) + } + + override fun changedUpdate(e: DocumentEvent) { + + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Doorman.kt b/src/main/kotlin/app/termora/Doorman.kt new file mode 100644 index 0000000..89314da --- /dev/null +++ b/src/main/kotlin/app/termora/Doorman.kt @@ -0,0 +1,85 @@ +package app.termora + +import app.termora.AES.decodeBase64 +import app.termora.AES.encodeBase64String +import app.termora.db.Database + +class PasswordWrongException : RuntimeException() + +class Doorman private constructor() { + private val properties get() = Database.instance.properties + private var key = byteArrayOf() + + companion object { + val instance by lazy { Doorman() } + } + + fun isWorking(): Boolean { + return properties.getString("doorman", "false").toBoolean() + } + + fun encrypt(text: String): String { + checkIsWorking() + return AES.ECB.encrypt(key, text.toByteArray()).encodeBase64String() + } + + + fun decrypt(text: String): String { + checkIsWorking() + return AES.ECB.decrypt(key, text.decodeBase64()).decodeToString() + } + + /** + * @return 返回钥匙 + */ + fun work(password: CharArray): ByteArray { + if (key.isNotEmpty()) { + throw IllegalStateException("Working") + } + return work(convertKey(password)) + } + + fun work(key: ByteArray): ByteArray { + val verify = properties.getString("doorman-verify") + if (verify == null) { + properties.putString( + "doorman-verify", + AES.ECB.encrypt(key, factor()).encodeBase64String() + ) + } else { + try { + if (!AES.ECB.decrypt(key, verify.decodeBase64()).contentEquals(factor())) { + throw PasswordWrongException() + } + } catch (e: Exception) { + throw PasswordWrongException() + } + } + + this.key = key + properties.putString("doorman", "true") + + return this.key + } + + + private fun convertKey(password: CharArray): ByteArray { + return PBKDF2.generateSecret(password, factor()) + } + + + private fun checkIsWorking() { + if (key.isEmpty() || !isWorking()) { + throw UnsupportedOperationException("Doorman is not working") + } + } + + private fun factor(): ByteArray { + return Application.getName().toByteArray() + } + + fun test(password: CharArray): Boolean { + checkIsWorking() + return key.contentEquals(convertKey(password)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/DoormanDialog.kt b/src/main/kotlin/app/termora/DoormanDialog.kt new file mode 100644 index 0000000..7795dd7 --- /dev/null +++ b/src/main/kotlin/app/termora/DoormanDialog.kt @@ -0,0 +1,309 @@ +package app.termora + +import app.termora.AES.decodeBase64 +import app.termora.db.Database +import app.termora.terminal.ControlCharacters +import cash.z.ecc.android.bip39.Mnemonics +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.extras.FlatSVGIcon +import com.formdev.flatlaf.extras.components.FlatButton +import com.formdev.flatlaf.extras.components.FlatLabel +import com.formdev.flatlaf.util.SystemInfo +import com.jgoodies.forms.builder.FormBuilder +import com.jgoodies.forms.layout.FormLayout +import org.apache.commons.lang3.StringUtils +import org.jdesktop.swingx.JXHyperlink +import org.slf4j.LoggerFactory +import java.awt.Dimension +import java.awt.Window +import java.awt.datatransfer.DataFlavor +import java.awt.event.ActionEvent +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import javax.imageio.ImageIO +import javax.swing.* +import kotlin.math.max + +class DoormanDialog(owner: Window?) : DialogWrapper(owner) { + companion object { + private val log = LoggerFactory.getLogger(DoormanDialog::class.java) + } + + private val formMargin = "7dlu" + private val label = FlatLabel() + private val icon = JLabel() + private val passwordTextField = OutlinePasswordField() + private val tip = FlatLabel() + private val safeBtn = FlatButton() + + var isOpened = false + + init { + size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150) + isModal = true + isResizable = false + controlsVisible = false + + if (SystemInfo.isWindows || SystemInfo.isLinux) { + title = I18n.getString("termora.doorman.safe") + rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE, false) + } + + + if (SystemInfo.isWindows || SystemInfo.isLinux) { + val sizes = listOf(16, 20, 24, 28, 32, 48, 64) + val loader = TermoraFrame::class.java.classLoader + val images = sizes.mapNotNull { e -> + loader.getResourceAsStream("icons/termora_${e}x${e}.png")?.use { ImageIO.read(it) } + } + iconImages = images + } + + setLocationRelativeTo(null) + init() + } + + override fun createCenterPanel(): JComponent { + label.text = I18n.getString("termora.doorman.safe") + tip.text = I18n.getString("termora.doorman.unlock-data") + icon.icon = FlatSVGIcon(Icons.role.name, 80, 80) + safeBtn.icon = Icons.unlocked + + + label.labelType = FlatLabel.LabelType.h2 + label.horizontalAlignment = SwingConstants.CENTER + safeBtn.isFocusable = false + tip.foreground = UIManager.getColor("TextField.placeholderForeground") + icon.horizontalAlignment = SwingConstants.CENTER + + + safeBtn.addActionListener { doOKAction() } + passwordTextField.addActionListener { doOKAction() } + + var rows = 2 + val step = 2 + return FormBuilder.create().debug(false) + .layout( + FormLayout( + "$formMargin, default:grow, 4dlu, pref, $formMargin", + "${if (SystemInfo.isWindows) "20dlu" else "0dlu"}, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin" + ) + ) + .add(icon).xyw(2, rows, 4).apply { rows += step } + .add(label).xyw(2, rows, 4).apply { rows += step } + .add(passwordTextField).xy(2, rows) + .add(safeBtn).xy(4, rows).apply { rows += step } + .add(tip).xyw(2, rows, 4, "center, fill").apply { rows += step } + .add(JXHyperlink(object : AnAction(I18n.getString("termora.doorman.forget-password")) { + override fun actionPerformed(e: ActionEvent) { + val option = OptionPane.showConfirmDialog( + this@DoormanDialog, I18n.getString("termora.doorman.forget-password-message"), + options = arrayOf( + I18n.getString("termora.doorman.have-a-mnemonic"), + I18n.getString("termora.doorman.dont-have-a-mnemonic"), + ), + optionType = JOptionPane.YES_NO_OPTION, + messageType = JOptionPane.INFORMATION_MESSAGE, + initialValue = I18n.getString("termora.doorman.have-a-mnemonic") + ) + if (option == JOptionPane.YES_OPTION) { + showMnemonicsDialog() + } else if (option == JOptionPane.NO_OPTION) { + OptionPane.showMessageDialog( + this@DoormanDialog, + I18n.getString("termora.doorman.delete-data"), + messageType = JOptionPane.WARNING_MESSAGE + ) + Application.browse(Application.getDatabaseFile().toURI()) + } + } + }).apply { isFocusable = false }).xyw(2, rows, 4, "center, fill") + .build() + } + + private fun showMnemonicsDialog() { + val dialog = MnemonicsDialog(this@DoormanDialog) + dialog.isVisible = true + val entropy = dialog.entropy + if (entropy.isEmpty()) { + return + } + + try { + val keyBackup = Database.instance.properties.getString("doorman-key-backup") + ?: throw IllegalStateException("doorman-key-backup is null") + val key = AES.ECB.decrypt(entropy, keyBackup.decodeBase64()) + Doorman.instance.work(key) + } catch (e: Exception) { + OptionPane.showMessageDialog( + this, I18n.getString("termora.doorman.mnemonic-data-corrupted"), + messageType = JOptionPane.ERROR_MESSAGE + ) + passwordTextField.outline = "error" + passwordTextField.requestFocus() + return + } + + isOpened = true + super.doOKAction() + + } + + override fun doOKAction() { + if (passwordTextField.password.isEmpty()) { + passwordTextField.outline = "error" + passwordTextField.requestFocus() + return + } + + try { + Doorman.instance.work(passwordTextField.password) + } catch (e: Exception) { + if (e is PasswordWrongException) { + OptionPane.showMessageDialog( + this, I18n.getString("termora.doorman.password-wrong"), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + passwordTextField.outline = "error" + passwordTextField.requestFocus() + return + } + + isOpened = true + + super.doOKAction() + } + + fun open(): Boolean { + isModal = true + isVisible = true + return isOpened + } + + + private class MnemonicsDialog(owner: Window) : DialogWrapper(owner) { + + private val textFields = (1..12).map { PasteTextField(it) } + var entropy = byteArrayOf() + private set + + init { + isModal = true + isResizable = true + controlsVisible = false + title = I18n.getString("termora.doorman.mnemonic.title") + init() + pack() + size = Dimension(max(size.width, UIManager.getInt("Dialog.width") - 250), size.height) + setLocationRelativeTo(null) + } + + fun getWords(): List { + val words = mutableListOf() + for (e in textFields) { + if (e.text.isBlank()) { + return emptyList() + } + words.add(e.text) + } + return words + } + + override fun createCenterPanel(): JComponent { + val formMargin = "4dlu" + val layout = FormLayout( + "default:grow, $formMargin, default:grow, $formMargin, default:grow, $formMargin, default:grow", + "pref, $formMargin, pref, $formMargin, pref" + ) + + val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin") + .layout(layout).debug(true) + val iterator = textFields.iterator() + for (i in 1..5 step 2) { + for (j in 1..7 step 2) { + builder.add(iterator.next()).xy(j, i) + } + } + + return builder.build() + } + + override fun doOKAction() { + for (textField in textFields) { + if (textField.text.isBlank()) { + textField.outline = "error" + textField.requestFocusInWindow() + return + } + } + + try { + Mnemonics.MnemonicCode(getWords().joinToString(StringUtils.SPACE)).use { + it.validate() + entropy = it.toEntropy() + } + } catch (e: Exception) { + OptionPane.showMessageDialog( + this, + I18n.getString("termora.doorman.mnemonic.incorrect"), + messageType = JOptionPane.ERROR_MESSAGE + ) + return + } + + + super.doOKAction() + } + + override fun doCancelAction() { + entropy = byteArrayOf() + super.doCancelAction() + } + + private inner class PasteTextField(private val index: Int) : OutlineTextField() { + init { + addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_BACK_SPACE) { + if (text.isEmpty() && index != 1) { + textFields[index - 2].requestFocusInWindow() + } + } + } + }) + } + + override fun paste() { + if (!toolkit.systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) { + return + } + + val text = toolkit.systemClipboard.getData(DataFlavor.stringFlavor)?.toString() ?: return + if (text.isBlank()) { + return + } + val words = mutableListOf() + if (text.count { it == ControlCharacters.SP } > text.count { it == ControlCharacters.LF }) { + words.addAll(text.split(StringUtils.SPACE)) + } else { + words.addAll(text.split(ControlCharacters.LF)) + } + val iterator = words.iterator() + for (i in index..textFields.size) { + if (iterator.hasNext()) { + textFields[i - 1].text = iterator.next() + textFields[i - 1].requestFocusInWindow() + } else { + break + } + } + } + } + } + + + override fun createSouthPanel(): JComponent? { + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/DynamicColor.kt b/src/main/kotlin/app/termora/DynamicColor.kt new file mode 100644 index 0000000..31faa2a --- /dev/null +++ b/src/main/kotlin/app/termora/DynamicColor.kt @@ -0,0 +1,63 @@ +package app.termora + +import com.formdev.flatlaf.FlatLaf +import java.awt.Color +import javax.swing.UIManager + +open class DynamicColor : Color { + private var regular: Color? + private val dark: Color? + private var colorKey: String? = null + private val color: Color + get() { + val r = regular + val d = dark + if (r == null || d == null) { + return UIManager.getColor(colorKey) + } + return if (FlatLaf.isLafDark()) d else r + } + + constructor(regular: Color, dark: Color) : super(regular.rgb, regular.alpha != 255) { + this.regular = regular + this.dark = dark + } + + companion object { + val BorderColor = DynamicColor("Component.borderColor") + } + + constructor(key: String) : super(0) { + this.regular = null + this.dark = null + this.colorKey = key + } + + override fun getRed(): Int { + return color.red + } + + override fun getGreen(): Int { + return color.green + } + + override fun getBlue(): Int { + return color.blue + } + + override fun getAlpha(): Int { + return color.alpha + } + + override fun getRGB(): Int { + return color.rgb + } + + override fun brighter(): Color { + return color.brighter() + } + + override fun darker(): Color { + return color.darker() + } +} diff --git a/src/main/kotlin/app/termora/DynamicIcon.kt b/src/main/kotlin/app/termora/DynamicIcon.kt new file mode 100644 index 0000000..2ef2cb7 --- /dev/null +++ b/src/main/kotlin/app/termora/DynamicIcon.kt @@ -0,0 +1,10 @@ +package app.termora + +import com.formdev.flatlaf.extras.FlatSVGIcon + +open class DynamicIcon(name: String, private val darkName: String) : FlatSVGIcon(name) { + constructor(name: String) : this(name, name) + + val dark by lazy { DynamicIcon(darkName, name) } + +} diff --git a/src/main/kotlin/app/termora/EditHostOptionsPane.kt b/src/main/kotlin/app/termora/EditHostOptionsPane.kt new file mode 100644 index 0000000..8a41fbb --- /dev/null +++ b/src/main/kotlin/app/termora/EditHostOptionsPane.kt @@ -0,0 +1,54 @@ +package app.termora + +import app.termora.keymgr.KeyManager +import app.termora.keymgr.OhKeyPair + +class EditHostOptionsPane(private val host: Host) : HostOptionsPane() { + init { + generalOption.portTextField.value = host.port + generalOption.nameTextField.text = host.name + generalOption.protocolTypeComboBox.selectedItem = host.protocol + generalOption.usernameTextField.text = host.username + generalOption.hostTextField.text = host.host + generalOption.passwordTextField.text = host.authentication.password + generalOption.remarkTextArea.text = host.remark + generalOption.authenticationTypeComboBox.selectedItem = host.authentication.type + if (host.authentication.type == AuthenticationType.PublicKey) { + val ohKeyPair = KeyManager.instance.getOhKeyPair(host.authentication.password) + if (ohKeyPair != null) { + generalOption.publicKeyTextField.text = ohKeyPair.name + generalOption.publicKeyTextField.putClientProperty(OhKeyPair::class, ohKeyPair) + } + } + + proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type + proxyOption.proxyHostTextField.text = host.proxy.host + proxyOption.proxyPasswordTextField.text = host.proxy.password + proxyOption.proxyUsernameTextField.text = host.proxy.username + proxyOption.proxyPortTextField.value = host.proxy.port + proxyOption.proxyAuthenticationTypeComboBox.selectedItem = host.proxy.authenticationType + + terminalOption.charsetComboBox.selectedItem = host.options.encoding + terminalOption.environmentTextArea.text = host.options.env + terminalOption.startupCommandTextField.text = host.options.startupCommand + + tunnelingOption.tunnelings.addAll(host.tunnelings) + } + + override fun getHost(): Host { + val newHost = super.getHost() + return host.copy( + name = newHost.name, + protocol = newHost.protocol, + host = newHost.host, + port = newHost.port, + username = newHost.username, + authentication = newHost.authentication, + proxy = newHost.proxy, + remark = newHost.remark, + updateDate = System.currentTimeMillis(), + options = newHost.options, + tunnelings = newHost.tunnelings, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Host.kt b/src/main/kotlin/app/termora/Host.kt new file mode 100644 index 0000000..54911c4 --- /dev/null +++ b/src/main/kotlin/app/termora/Host.kt @@ -0,0 +1,236 @@ +package app.termora + +import kotlinx.serialization.Serializable +import org.apache.commons.lang3.StringUtils +import java.util.* + + +fun UUID.toSimpleString(): String { + return toString().replace("-", StringUtils.EMPTY) +} + +enum class Protocol { + Folder, + SSH, + Local, +} + + +enum class AuthenticationType { + No, + Password, + PublicKey, + KeyboardInteractive, +} + +enum class ProxyType { + No, + HTTP, + SOCKS5, +} + +@Serializable +data class Authentication( + val type: AuthenticationType, + val password: String, +) { + companion object { + val No = Authentication(AuthenticationType.No, String()) + } +} + + +@Serializable +data class Options( + /** + * 跳板机 + */ + val jumpHosts: List = mutableListOf(), + /** + * 编码 + */ + val encoding: String = "UTF-8", + /** + * 环境变量 + */ + val env: String = StringUtils.EMPTY, + /** + * 连接成功后立即发送命令 + */ + val startupCommand: String = StringUtils.EMPTY, +) { + companion object { + val Default = Options() + } + + fun envs(): Map { + if (env.isBlank()) return emptyMap() + val envs = mutableMapOf() + for (line in env.lines()) { + if (line.isBlank()) continue + val vars = line.split("=", limit = 2) + if (vars.size != 2) continue + envs[vars[0]] = vars[1] + } + return envs + } +} + +@Serializable +data class Proxy( + val type: ProxyType, + val host: String, + val port: Int, + val authenticationType: AuthenticationType = AuthenticationType.No, + val username: String, + val password: String, +) { + companion object { + val No = Proxy( + ProxyType.No, + host = StringUtils.EMPTY, + port = 7890, + username = StringUtils.EMPTY, + password = StringUtils.EMPTY + ) + } +} + +enum class TunnelingType { + Local, + Remote, + Dynamic +} + +@Serializable +data class Tunneling( + val name: String = StringUtils.EMPTY, + val type: TunnelingType = TunnelingType.Local, + val sourceHost: String = StringUtils.EMPTY, + val sourcePort: Int = 0, + val destinationHost: String = StringUtils.EMPTY, + val destinationPort: Int = 0, +) + + +@Serializable +data class EncryptedHost( + var id: String = StringUtils.EMPTY, + var name: String = StringUtils.EMPTY, + var protocol: String = StringUtils.EMPTY, + var host: String = StringUtils.EMPTY, + var port: String = StringUtils.EMPTY, + var username: String = StringUtils.EMPTY, + var remark: String = StringUtils.EMPTY, + var authentication: String = StringUtils.EMPTY, + var proxy: String = StringUtils.EMPTY, + var options: String = StringUtils.EMPTY, + var tunnelings: String = StringUtils.EMPTY, + var sort: Long = 0L, + var deleted: Boolean = false, + var parentId: String = StringUtils.EMPTY, + var ownerId: String = StringUtils.EMPTY, + var creatorId: String = StringUtils.EMPTY, + var createDate: Long = 0L, + var updateDate: Long = 0L, +) + + +@Serializable +data class Host( + /** + * 唯一ID + */ + val id: String = UUID.randomUUID().toSimpleString(), + /** + * 名称 + */ + val name: String, + /** + * 协议 + */ + val protocol: Protocol, + /** + * 主机 + */ + val host: String = StringUtils.EMPTY, + /** + * 端口 + */ + val port: Int = 0, + /** + * 用户名 + */ + val username: String = StringUtils.EMPTY, + /** + * 备注 + */ + val remark: String = StringUtils.EMPTY, + /** + * 认证信息 + */ + val authentication: Authentication = Authentication.No, + /** + * 代理 + */ + val proxy: Proxy = Proxy.No, + + /** + * 选项,备用字段 + */ + val options: Options = Options.Default, + + /** + * 隧道 + */ + val tunnelings: List = emptyList(), + + /** + * 排序 + */ + val sort: Long = 0, + /** + * 父ID + */ + val parentId: String = "0", + /** + * 所属者 + */ + val ownerId: String = "0", + /** + * 创建者 + */ + val creatorId: String = "0", + /** + * 创建时间 + */ + val createDate: Long = System.currentTimeMillis(), + /** + * 更新时间 + */ + val updateDate: Long = System.currentTimeMillis(), + + /** + * 是否已经删除 + */ + val deleted: Boolean = false +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Host + + if (id != other.id) return false + if (ownerId != other.ownerId) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + ownerId.hashCode() + return result + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostDialog.kt b/src/main/kotlin/app/termora/HostDialog.kt new file mode 100644 index 0000000..5c2989c --- /dev/null +++ b/src/main/kotlin/app/termora/HostDialog.kt @@ -0,0 +1,46 @@ +package app.termora + +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.Window +import javax.swing.BorderFactory +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.UIManager + +class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) { + private val pane = if (host != null) EditHostOptionsPane(host) else HostOptionsPane() + var host: Host? = host + private set + + init { + size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height")) + isModal = true + title = I18n.getString("termora.new-host.title") + setLocationRelativeTo(null) + + init() + } + + override fun createCenterPanel(): JComponent { + pane.background = UIManager.getColor("window") + + val panel = JPanel(BorderLayout()) + panel.add(pane, BorderLayout.CENTER) + panel.background = UIManager.getColor("window") + panel.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor) + + return panel + } + + + override fun doOKAction() { + if (!pane.validateFields()) { + return + } + host = pane.getHost() + super.doOKAction() + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostManager.kt b/src/main/kotlin/app/termora/HostManager.kt new file mode 100644 index 0000000..4abd1d6 --- /dev/null +++ b/src/main/kotlin/app/termora/HostManager.kt @@ -0,0 +1,54 @@ +package app.termora + +import app.termora.db.Database +import java.util.* + +interface HostListener : EventListener { + fun hostAdded(host: Host) {} + fun hostRemoved(id: String) {} + fun hostsChanged() {} +} + + +class HostManager private constructor() { + companion object { + val instance by lazy { HostManager() } + } + + private val database get() = Database.instance + private val listeners = mutableListOf() + + fun addHost(host: Host, notify: Boolean = true) { + assertEventDispatchThread() + database.addHost(host) + if (notify) listeners.forEach { it.hostAdded(host) } + } + + fun removeHost(id: String) { + assertEventDispatchThread() + database.removeHost(id) + listeners.forEach { it.hostRemoved(id) } + + } + + fun hosts(): List { + return database.getHosts() + .sortedWith(compareBy { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort }) + } + + fun removeAll() { + assertEventDispatchThread() + database.removeAllHost() + listeners.forEach { it.hostsChanged() } + } + + fun addHostListener(listener: HostListener) { + listeners.add(listener) + } + + fun removeHostListener(listener: HostListener) { + listeners.remove(listener) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostOptionsPane.kt b/src/main/kotlin/app/termora/HostOptionsPane.kt new file mode 100644 index 0000000..7238c29 --- /dev/null +++ b/src/main/kotlin/app/termora/HostOptionsPane.kt @@ -0,0 +1,842 @@ +package app.termora + +import app.termora.keymgr.KeyManagerDialog +import app.termora.keymgr.OhKeyPair +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.extras.components.FlatComboBox +import com.formdev.flatlaf.ui.FlatTextBorder +import com.jgoodies.forms.builder.FormBuilder +import com.jgoodies.forms.layout.FormLayout +import org.apache.commons.lang3.StringUtils +import java.awt.* +import java.awt.event.* +import java.nio.charset.Charset +import javax.swing.* +import javax.swing.table.DefaultTableModel + + +open class HostOptionsPane : OptionsPane() { + protected val tunnelingOption = TunnelingOption() + protected val generalOption = GeneralOption() + protected val proxyOption = ProxyOption() + protected val terminalOption = TerminalOption() + protected val owner: Window? get() = SwingUtilities.getWindowAncestor(this) + + init { + addOption(generalOption) + addOption(proxyOption) + addOption(tunnelingOption) + addOption(terminalOption) + + setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8)) + } + + + open fun getHost(): Host { + val name = generalOption.nameTextField.text + val protocol = generalOption.protocolTypeComboBox.selectedItem as Protocol + val host = generalOption.hostTextField.text + val port = (generalOption.portTextField.value ?: 22) as Int + var authentication = Authentication.No + var proxy = Proxy.No + + if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) { + authentication = authentication.copy( + type = AuthenticationType.Password, + password = String(generalOption.passwordTextField.password) + ) + } else if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) { + val keyPair = generalOption.publicKeyTextField.getClientProperty(OhKeyPair::class) as OhKeyPair? + authentication = authentication.copy( + type = AuthenticationType.PublicKey, + password = keyPair?.id ?: StringUtils.EMPTY + ) + } + + if (proxyOption.proxyTypeComboBox.selectedItem != ProxyType.No) { + proxy = proxy.copy( + type = proxyOption.proxyTypeComboBox.selectedItem as ProxyType, + host = proxyOption.proxyHostTextField.text, + username = proxyOption.proxyUsernameTextField.text, + password = String(proxyOption.proxyPasswordTextField.password), + port = proxyOption.proxyPortTextField.value as Int, + authenticationType = proxyOption.proxyAuthenticationTypeComboBox.selectedItem as AuthenticationType, + ) + } + + val options = Options.Default.copy( + encoding = terminalOption.charsetComboBox.selectedItem as String, + env = terminalOption.environmentTextArea.text, + startupCommand = terminalOption.startupCommandTextField.text + ) + + return Host( + name = name, + protocol = protocol, + host = host, + port = port, + username = generalOption.usernameTextField.text, + authentication = authentication, + proxy = proxy, + sort = System.currentTimeMillis(), + remark = generalOption.remarkTextArea.text, + options = options, + tunnelings = tunnelingOption.tunnelings + ) + } + + fun validateFields(): Boolean { + val host = getHost() + + // general + if (validateField(generalOption.nameTextField) + || validateField(generalOption.hostTextField) + ) { + return false + } + + if (host.protocol == Protocol.SSH) { + if (validateField(generalOption.usernameTextField)) { + return false + } + } + + if (host.authentication.type == AuthenticationType.Password) { + if (validateField(generalOption.passwordTextField)) { + return false + } + } else if (host.authentication.type == AuthenticationType.PublicKey) { + if (validateField(generalOption.publicKeyTextField)) { + return false + } + } + + // proxy + if (host.proxy.type != ProxyType.No) { + if (validateField(proxyOption.proxyHostTextField) + ) { + return false + } + + if (host.proxy.authenticationType != AuthenticationType.No) { + if (validateField(proxyOption.proxyUsernameTextField) + || validateField(proxyOption.proxyPasswordTextField) + ) { + return false + } + } + } + + + return true + } + + /** + * 返回 true 表示有错误 + */ + private fun validateField(textField: JTextField): Boolean { + if (textField.isEnabled && textField.text.isBlank()) { + selectOptionJComponent(textField) + textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR) + textField.requestFocusInWindow() + return true + } + return false + } + + protected inner class GeneralOption : JPanel(BorderLayout()), Option { + val portTextField = PortSpinner() + val nameTextField = OutlineTextField(128) + val protocolTypeComboBox = FlatComboBox() + val usernameTextField = OutlineTextField(128) + val hostTextField = OutlineTextField(255) + private val passwordPanel = JPanel(BorderLayout()) + private val chooseKeyBtn = JButton(Icons.greyKey) + val passwordTextField = OutlinePasswordField(255) + val publicKeyTextField = OutlineTextField() + val remarkTextArea = FixedLengthTextArea(512) + val authenticationTypeComboBox = FlatComboBox() + + init { + initView() + initEvents() + } + + private fun initView() { + add(getCenterComponent(), BorderLayout.CENTER) + + publicKeyTextField.isEditable = false + chooseKeyBtn.isFocusable = false + + protocolTypeComboBox.renderer = object : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + return super.getListCellRendererComponent( + list, + value.toString().uppercase(), + index, + isSelected, + cellHasFocus + ) + } + } + + authenticationTypeComboBox.renderer = object : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + var text = value?.toString() ?: "" + when (value) { + AuthenticationType.Password -> { + text = "Password" + } + + AuthenticationType.PublicKey -> { + text = "Public Key" + } + + AuthenticationType.KeyboardInteractive -> { + text = "Keyboard Interactive" + } + } + return super.getListCellRendererComponent( + list, + text, + index, + isSelected, + cellHasFocus + ) + } + } + + protocolTypeComboBox.addItem(Protocol.SSH) + protocolTypeComboBox.addItem(Protocol.Local) + + authenticationTypeComboBox.addItem(AuthenticationType.No) + authenticationTypeComboBox.addItem(AuthenticationType.Password) + authenticationTypeComboBox.addItem(AuthenticationType.PublicKey) + + authenticationTypeComboBox.selectedItem = AuthenticationType.Password + + refreshStates() + } + + private fun initEvents() { + protocolTypeComboBox.addItemListener { + if (it.stateChange == ItemEvent.SELECTED) { + refreshStates() + } + } + + authenticationTypeComboBox.addItemListener { + if (it.stateChange == ItemEvent.SELECTED) { + refreshStates() + switchPasswordComponent() + } + } + + chooseKeyBtn.addActionListener { + chooseKeyPair() + } + + addComponentListener(object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent) { + SwingUtilities.invokeLater { nameTextField.requestFocusInWindow() } + removeComponentListener(this) + } + }) + } + + private fun chooseKeyPair() { + val dialog = KeyManagerDialog( + SwingUtilities.getWindowAncestor(this), + selectMode = true, + ) + dialog.pack() + dialog.setLocationRelativeTo(null) + dialog.isVisible = true + if (dialog.ok) { + val lastKeyPair = dialog.getLasOhKeyPair() + if (lastKeyPair != null) { + publicKeyTextField.putClientProperty(OhKeyPair::class, lastKeyPair) + publicKeyTextField.text = lastKeyPair.name + publicKeyTextField.outline = null + } + } + } + + private fun refreshStates() { + hostTextField.isEnabled = true + portTextField.isEnabled = true + usernameTextField.isEnabled = true + authenticationTypeComboBox.isEnabled = true + passwordTextField.isEnabled = true + chooseKeyBtn.isEnabled = true + + if (protocolTypeComboBox.selectedItem == Protocol.Local) { + hostTextField.isEnabled = false + portTextField.isEnabled = false + usernameTextField.isEnabled = false + authenticationTypeComboBox.isEnabled = false + passwordTextField.isEnabled = false + chooseKeyBtn.isEnabled = false + } + + } + + override fun getIcon(isSelected: Boolean): Icon { + return Icons.settings + } + + override fun getTitle(): String { + return I18n.getString("termora.new-host.general") + } + + override fun getJComponent(): JComponent { + return this + } + + private fun getCenterComponent(): JComponent { + val layout = FormLayout( + "left:pref, $formMargin, default:grow, $formMargin, pref, $formMargin, default, default:grow", + "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" + ) + remarkTextArea.setFocusTraversalKeys( + KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS) + ) + remarkTextArea.setFocusTraversalKeys( + KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS, + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS) + ) + + remarkTextArea.rows = 8 + remarkTextArea.lineWrap = true + remarkTextArea.border = BorderFactory.createEmptyBorder(4, 4, 4, 4) + + switchPasswordComponent() + + var rows = 1 + val step = 2 + val panel = FormBuilder.create().layout(layout) + .add("${I18n.getString("termora.new-host.general.name")}:").xy(1, rows) + .add(nameTextField).xyw(3, rows, 5).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.protocol")}:").xy(1, rows) + .add(protocolTypeComboBox).xyw(3, rows, 5).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.host")}:").xy(1, rows) + .add(hostTextField).xy(3, rows) + .add("${I18n.getString("termora.new-host.general.port")}:").xy(5, rows) + .add(portTextField).xy(7, rows).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.username")}:").xy(1, rows) + .add(usernameTextField).xyw(3, rows, 5).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, rows) + .add(authenticationTypeComboBox).xyw(3, rows, 5).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.password")}:").xy(1, rows) + .add(passwordPanel).xyw(3, rows, 5).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.remark")}:").xy(1, rows) + .add(JScrollPane(remarkTextArea).apply { border = FlatTextBorder() }) + .xyw(3, rows, 5).apply { rows += step } + + .build() + + + return panel + } + + private fun switchPasswordComponent() { + passwordPanel.removeAll() + + if (authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) { + passwordPanel.add( + FormBuilder.create() + .layout(FormLayout("default:grow, 4dlu, left:pref", "pref")) + .add(publicKeyTextField).xy(1, 1) + .add(chooseKeyBtn).xy(3, 1) + .build(), BorderLayout.CENTER + ) + } else { + passwordPanel.add(passwordTextField, BorderLayout.CENTER) + } + passwordPanel.revalidate() + passwordPanel.repaint() + } + } + + protected inner class ProxyOption : JPanel(BorderLayout()), Option { + val proxyTypeComboBox = FlatComboBox() + val proxyHostTextField = OutlineTextField() + val proxyPasswordTextField = OutlinePasswordField() + val proxyUsernameTextField = OutlineTextField() + val proxyPortTextField = PortSpinner(1080) + val proxyAuthenticationTypeComboBox = FlatComboBox() + + + init { + initView() + initEvents() + } + + private fun initView() { + add(getCenterComponent(), BorderLayout.CENTER) + proxyAuthenticationTypeComboBox.renderer = object : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + var text = value?.toString() ?: "" + when (value) { + AuthenticationType.Password -> { + text = "Password" + } + + AuthenticationType.PublicKey -> { + text = "Public Key" + } + + AuthenticationType.KeyboardInteractive -> { + text = "Keyboard Interactive" + } + } + return super.getListCellRendererComponent( + list, + text, + index, + isSelected, + cellHasFocus + ) + } + } + + proxyTypeComboBox.addItem(ProxyType.No) + proxyTypeComboBox.addItem(ProxyType.HTTP) + proxyTypeComboBox.addItem(ProxyType.SOCKS5) + + proxyAuthenticationTypeComboBox.addItem(AuthenticationType.No) + proxyAuthenticationTypeComboBox.addItem(AuthenticationType.Password) + + proxyUsernameTextField.text = "root" + + refreshStates() + } + + private fun initEvents() { + proxyTypeComboBox.addItemListener { + if (it.stateChange == ItemEvent.SELECTED) { + refreshStates() + } + } + proxyAuthenticationTypeComboBox.addItemListener { + if (it.stateChange == ItemEvent.SELECTED) { + refreshStates() + } + } + } + + private fun refreshStates() { + proxyHostTextField.isEnabled = proxyTypeComboBox.selectedItem != ProxyType.No + proxyPortTextField.isEnabled = proxyHostTextField.isEnabled + + proxyAuthenticationTypeComboBox.isEnabled = proxyHostTextField.isEnabled + proxyUsernameTextField.isEnabled = proxyAuthenticationTypeComboBox.selectedItem != AuthenticationType.No + proxyPasswordTextField.isEnabled = proxyUsernameTextField.isEnabled + } + + override fun getIcon(isSelected: Boolean): Icon { + return Icons.network + } + + override fun getTitle(): String { + return I18n.getString("termora.new-host.proxy") + } + + override fun getJComponent(): JComponent { + return this + } + + private fun getCenterComponent(): JComponent { + val layout = FormLayout( + "left:pref, $formMargin, default:grow, $formMargin, pref, $formMargin, default, default:grow", + "pref, $formMargin, pref, $formMargin, pref, $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.general.protocol")}:").xy(1, rows) + .add(proxyTypeComboBox).xyw(3, rows, 5).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.host")}:").xy(1, rows) + .add(proxyHostTextField).xy(3, rows) + .add("${I18n.getString("termora.new-host.general.port")}:").xy(5, rows) + .add(proxyPortTextField).xy(7, rows).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, rows) + .add(proxyAuthenticationTypeComboBox).xyw(3, rows, 5).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.username")}:").xy(1, rows) + .add(proxyUsernameTextField).xyw(3, rows, 5).apply { rows += step } + .add("${I18n.getString("termora.new-host.general.password")}:").xy(1, rows) + .add(proxyPasswordTextField).xyw(3, rows, 5).apply { rows += step } + + .build() + + + return panel + } + } + + protected inner class TerminalOption : JPanel(BorderLayout()), Option { + val charsetComboBox = JComboBox() + val startupCommandTextField = OutlineTextField() + val environmentTextArea = FixedLengthTextArea(2048) + + + init { + initView() + initEvents() + } + + private fun initView() { + add(getCenterComponent(), BorderLayout.CENTER) + + + environmentTextArea.setFocusTraversalKeys( + KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS) + ) + environmentTextArea.setFocusTraversalKeys( + KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS, + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS) + ) + + environmentTextArea.rows = 8 + environmentTextArea.lineWrap = true + environmentTextArea.border = BorderFactory.createEmptyBorder(4, 4, 4, 4) + + for (e in Charset.availableCharsets()) { + charsetComboBox.addItem(e.key) + } + + charsetComboBox.selectedItem = "UTF-8" + + } + + private fun initEvents() { + + } + + + override fun getIcon(isSelected: Boolean): Icon { + return Icons.terminal + } + + override fun getTitle(): String { + return I18n.getString("termora.new-host.terminal") + } + + override fun getJComponent(): JComponent { + return this + } + + private fun getCenterComponent(): JComponent { + val layout = FormLayout( + "left:pref, $formMargin, default:grow, $formMargin, default:grow", + "pref, $formMargin, pref, $formMargin, pref, $formMargin" + ) + + var rows = 1 + val step = 2 + val panel = FormBuilder.create().layout(layout) + .add("${I18n.getString("termora.new-host.terminal.encoding")}:").xy(1, rows) + .add(charsetComboBox).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.new-host.terminal.startup-commands")}:").xy(1, rows) + .add(startupCommandTextField).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.new-host.terminal.env")}:").xy(1, rows) + .add(JScrollPane(environmentTextArea).apply { border = FlatTextBorder() }).xy(3, rows) + .apply { rows += step } + .build() + + + return panel + } + } + + protected inner class TunnelingOption : JPanel(BorderLayout()), Option { + val tunnelings = mutableListOf() + + private val model = object : DefaultTableModel() { + override fun getRowCount(): Int { + return tunnelings.size + } + + override fun isCellEditable(row: Int, column: Int): Boolean { + return false + } + + fun addRow(tunneling: Tunneling) { + val rowCount = super.getRowCount() + tunnelings.add(tunneling) + super.fireTableRowsInserted(rowCount, rowCount + 1) + } + + override fun getValueAt(row: Int, column: Int): Any { + val tunneling = tunnelings[row] + return when (column) { + 0 -> tunneling.name + 1 -> tunneling.type + 2 -> "${tunneling.sourceHost}:${tunneling.sourcePort}" + 3 -> "${tunneling.destinationHost}:${tunneling.destinationPort}" + else -> super.getValueAt(row, column) + } + } + } + private val table = JTable(model) + private val addBtn = JButton(I18n.getString("termora.new-host.tunneling.add")) + private val editBtn = JButton(I18n.getString("termora.new-host.tunneling.edit")) + private val deleteBtn = JButton(I18n.getString("termora.new-host.tunneling.delete")) + + init { + initView() + initEvents() + } + + private fun initView() { + val scrollPane = JScrollPane(table) + + model.addColumn(I18n.getString("termora.new-host.tunneling.table.name")) + model.addColumn(I18n.getString("termora.new-host.tunneling.table.type")) + model.addColumn(I18n.getString("termora.new-host.tunneling.table.source")) + model.addColumn(I18n.getString("termora.new-host.tunneling.table.destination")) + + + table.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS + table.border = BorderFactory.createEmptyBorder() + table.fillsViewportHeight = true + scrollPane.border = BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(4, 0, 4, 0), + BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor) + ) + + deleteBtn.isFocusable = false + addBtn.isFocusable = false + editBtn.isFocusable = false + + editBtn.isEnabled = false + deleteBtn.isEnabled = false + + val box = Box.createHorizontalBox() + box.add(addBtn) + box.add(Box.createHorizontalStrut(4)) + box.add(editBtn) + box.add(Box.createHorizontalStrut(4)) + box.add(deleteBtn) + + add(JLabel("TCP/IP Forwarding:"), BorderLayout.NORTH) + add(scrollPane, BorderLayout.CENTER) + add(box, BorderLayout.SOUTH) + + } + + private fun initEvents() { + addBtn.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent?) { + val dialog = PortForwardingDialog(SwingUtilities.getWindowAncestor(this@HostOptionsPane)) + dialog.isVisible = true + val tunneling = dialog.tunneling ?: return + model.addRow(tunneling) + } + }) + + + editBtn.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent?) { + val row = table.selectedRow + if (row < 0) { + return + } + val dialog = PortForwardingDialog( + SwingUtilities.getWindowAncestor(this@HostOptionsPane), + tunnelings[row] + ) + dialog.isVisible = true + tunnelings[row] = dialog.tunneling ?: return + model.fireTableRowsUpdated(row, row) + } + }) + + deleteBtn.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val rows = table.selectedRows + if (rows.isEmpty()) return + rows.sortDescending() + for (row in rows) { + tunnelings.removeAt(row) + model.fireTableRowsDeleted(row, row) + } + } + }) + + table.selectionModel.addListSelectionListener { + editBtn.isEnabled = table.selectedRowCount > 0 + deleteBtn.isEnabled = editBtn.isEnabled + } + + table.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount % 2 == 0 && SwingUtilities.isLeftMouseButton(e)) { + editBtn.actionListeners.forEach { + it.actionPerformed( + ActionEvent( + editBtn, + ActionEvent.ACTION_PERFORMED, + StringUtils.EMPTY + ) + ) + } + } + } + }) + } + + override fun getIcon(isSelected: Boolean): Icon { + return Icons.showWriteAccess + } + + override fun getTitle(): String { + return I18n.getString("termora.new-host.tunneling") + } + + override fun getJComponent(): JComponent { + return this + } + + private inner class PortForwardingDialog( + owner: Window, + var tunneling: Tunneling? = null + ) : DialogWrapper(owner) { + private val formMargin = "4dlu" + private val typeComboBox = FlatComboBox() + private val nameTextField = OutlineTextField(32) + private val localHostTextField = OutlineTextField() + private val localPortSpinner = PortSpinner() + private val remoteHostTextField = OutlineTextField() + private val remotePortSpinner = PortSpinner() + + init { + isModal = true + title = I18n.getString("termora.new-host.tunneling") + controlsVisible = false + + typeComboBox.addItem(TunnelingType.Local) + typeComboBox.addItem(TunnelingType.Remote) + typeComboBox.addItem(TunnelingType.Dynamic) + + localHostTextField.text = "127.0.0.1" + localPortSpinner.value = 1080 + + remoteHostTextField.text = "127.0.0.1" + + typeComboBox.addItemListener { + if (it.stateChange == ItemEvent.SELECTED) { + remoteHostTextField.isEnabled = typeComboBox.selectedItem != TunnelingType.Dynamic + remotePortSpinner.isEnabled = remoteHostTextField.isEnabled + } + } + + tunneling?.let { + localHostTextField.text = it.sourceHost + localPortSpinner.value = it.sourcePort + remoteHostTextField.text = it.destinationHost + remotePortSpinner.value = it.destinationPort + nameTextField.text = it.name + typeComboBox.selectedItem = it.type + } + + init() + pack() + size = Dimension(UIManager.getInt("Dialog.width") - 300, size.height) + setLocationRelativeTo(null) + + } + + override fun doOKAction() { + if (nameTextField.text.isBlank()) { + nameTextField.outline = "error" + nameTextField.requestFocusInWindow() + return + } else if (localHostTextField.text.isBlank()) { + localHostTextField.outline = "error" + localHostTextField.requestFocusInWindow() + return + } else if (remoteHostTextField.text.isBlank()) { + remoteHostTextField.outline = "error" + remoteHostTextField.requestFocusInWindow() + return + } + + tunneling = Tunneling( + name = nameTextField.text, + type = typeComboBox.selectedItem as TunnelingType, + sourceHost = localHostTextField.text, + sourcePort = localPortSpinner.value as Int, + destinationHost = remoteHostTextField.text, + destinationPort = remotePortSpinner.value as Int, + ) + + super.doOKAction() + } + + override fun doCancelAction() { + tunneling = null + super.doCancelAction() + } + + override fun createCenterPanel(): JComponent { + val layout = FormLayout( + "left:pref, $formMargin, default:grow, $formMargin, pref", + "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" + ) + + var rows = 1 + val step = 2 + return FormBuilder.create().layout(layout).padding("0dlu, $formMargin, $formMargin, $formMargin") + .add("${I18n.getString("termora.new-host.tunneling.table.name")}:").xy(1, rows) + .add(nameTextField).xyw(3, rows, 3).apply { rows += step } + .add("${I18n.getString("termora.new-host.tunneling.table.type")}:").xy(1, rows) + .add(typeComboBox).xyw(3, rows, 3).apply { rows += step } + .add("${I18n.getString("termora.new-host.tunneling.table.source")}:").xy(1, rows) + .add(localHostTextField).xy(3, rows) + .add(localPortSpinner).xy(5, rows).apply { rows += step } + .add("${I18n.getString("termora.new-host.tunneling.table.destination")}:").xy(1, rows) + .add(remoteHostTextField).xy(3, rows) + .add(remotePortSpinner).xy(5, rows).apply { rows += step } + .build() + } + + + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostTerminalTab.kt b/src/main/kotlin/app/termora/HostTerminalTab.kt new file mode 100644 index 0000000..02525dd --- /dev/null +++ b/src/main/kotlin/app/termora/HostTerminalTab.kt @@ -0,0 +1,63 @@ +package app.termora + +import app.termora.terminal.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.swing.Swing +import java.beans.PropertyChangeEvent +import javax.swing.Icon + +abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() { + protected val coroutineScope by lazy { CoroutineScope(Dispatchers.Swing) } + protected val terminal = TerminalFactory.instance.createTerminal() + protected val terminalModel get() = terminal.getTerminalModel() + protected var unread = false + set(value) { + field = value + firePropertyChange(PropertyChangeEvent(this, "icon", null, null)) + } + + + /* visualTerminal */ + protected fun Terminal.clearScreen() { + this.write("${ControlCharacters.ESC}[3J") + } + + init { + terminal.getTerminalModel().addDataListener(object : DataListener { + override fun onChanged(key: DataKey<*>, data: Any) { + if (key == VisualTerminal.Written) { + if (hasFocus || unread) { + return + } + unread = true + } + } + }) + } + + open fun start() {} + + override fun getTitle(): String { + return host.name + } + + override fun getIcon(): Icon { + if (host.protocol == Protocol.Local || host.protocol == Protocol.SSH) { + return if (unread) Icons.terminalUnread else Icons.terminal + } + return Icons.terminal + } + + override fun dispose() { + coroutineScope.cancel() + } + + override fun onGrabFocus() { + super.onGrabFocus() + if (!unread) return + unread = false + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostTree.kt b/src/main/kotlin/app/termora/HostTree.kt new file mode 100644 index 0000000..519a41d --- /dev/null +++ b/src/main/kotlin/app/termora/HostTree.kt @@ -0,0 +1,583 @@ +package app.termora + +import app.termora.db.Database +import com.formdev.flatlaf.extras.components.FlatPopupMenu +import com.formdev.flatlaf.icons.FlatTreeClosedIcon +import com.formdev.flatlaf.icons.FlatTreeOpenIcon +import org.jdesktop.swingx.action.ActionManager +import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer +import java.awt.Component +import java.awt.Dimension +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable +import java.awt.event.ActionEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.util.* +import javax.swing.* +import javax.swing.event.CellEditorListener +import javax.swing.event.ChangeEvent +import javax.swing.event.PopupMenuEvent +import javax.swing.event.PopupMenuListener +import javax.swing.tree.TreePath +import javax.swing.tree.TreeSelectionModel + + +class HostTree : JTree(), Disposable { + private val hostManager get() = HostManager.instance + private val editor = OutlineTextField(64) + + val model = HostTreeModel() + val searchableModel = SearchableHostTreeModel(model) + + init { + initView() + initEvents() + } + + + private fun initView() { + setModel(model) + isEditable = true + dropMode = DropMode.ON_OR_INSERT + dragEnabled = true + selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION + editor.preferredSize = Dimension(220, 0) + + setCellRenderer(object : DefaultXTreeCellRenderer() { + override fun getTreeCellRendererComponent( + tree: JTree, + value: Any, + sel: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean + ): Component { + val host = value as Host + val c = super.getTreeCellRendererComponent(tree, host, sel, expanded, leaf, row, hasFocus) + if (host.protocol == Protocol.Folder) { + icon = if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon() + } else if (host.protocol == Protocol.SSH || host.protocol == Protocol.Local) { + icon = if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal + } + return c + } + }) + + setCellEditor(object : DefaultCellEditor(editor) { + override fun isCellEditable(e: EventObject?): Boolean { + if (e is MouseEvent) { + return false + } + return super.isCellEditable(e) + } + + }) + + + val state = Database.instance.properties.getString("HostTreeExpansionState") + if (state != null) { + TreeUtils.loadExpansionState(this@HostTree, state) + } + } + + override fun convertValueToText( + value: Any?, + selected: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean + ): String { + if (value is Host) { + return value.name + } + return super.convertValueToText(value, selected, expanded, leaf, row, hasFocus) + } + + private fun initEvents() { + // 右键选中 + addMouseListener(object : MouseAdapter() { + override fun mousePressed(e: MouseEvent) { + if (!SwingUtilities.isRightMouseButton(e)) { + return + } + + requestFocusInWindow() + + val selectionRows = selectionModel.selectionRows + + val selRow = getClosestRowForLocation(e.x, e.y) + if (selRow < 0) { + selectionModel.clearSelection() + return + } else if (selectionRows != null && selectionRows.contains(selRow)) { + return + } + + selectionPath = getPathForLocation(e.x, e.y) + + setSelectionRow(selRow) + } + + override fun mouseClicked(e: MouseEvent) { + if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { + val host = lastSelectedPathComponent + if (host is Host && host.protocol != Protocol.Folder) { + ActionManager.getInstance().getAction(Actions.OPEN_HOST) + ?.actionPerformed(OpenHostActionEvent(this, host)) + } + } + } + }) + + + // contextmenu + addMouseListener(object : MouseAdapter() { + override fun mousePressed(e: MouseEvent) { + if (!(SwingUtilities.isRightMouseButton(e))) { + return + } + + if (Objects.isNull(lastSelectedPathComponent)) { + return + } + + SwingUtilities.invokeLater { showContextMenu(e) } + } + }) + + + // rename + getCellEditor().addCellEditorListener(object : CellEditorListener { + override fun editingStopped(e: ChangeEvent) { + val lastHost = lastSelectedPathComponent + if (lastHost !is Host || editor.text.isBlank() || editor.text == lastHost.name) { + return + } + runCatchingHost(lastHost.copy(name = editor.text)) + } + + override fun editingCanceled(e: ChangeEvent) { + } + + }) + + // drag + transferHandler = object : TransferHandler() { + + override fun createTransferable(c: JComponent): Transferable { + val nodes = selectionModel.selectionPaths + .map { it.lastPathComponent } + .filterIsInstance() + .toMutableList() + + val iterator = nodes.iterator() + while (iterator.hasNext()) { + val node = iterator.next() + val parents = model.getPathToRoot(node).filter { it != node } + if (parents.any { nodes.contains(it) }) { + iterator.remove() + } + } + + return MoveHostTransferable(nodes) + } + + override fun getSourceActions(c: JComponent?): Int { + return MOVE + } + + override fun canImport(support: TransferSupport): Boolean { + if (!support.isDrop) { + return false + } + val dropLocation = support.dropLocation + if (dropLocation !is JTree.DropLocation || support.component != this@HostTree + || dropLocation.childIndex != -1 + ) { + return false + } + + val lastNode = dropLocation.path.lastPathComponent + if (lastNode !is Host || lastNode.protocol != Protocol.Folder) { + return false + } + + if (support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) { + val nodes = support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*> + if (nodes.any { it == lastNode }) { + return false + } + for (parent in model.getPathToRoot(lastNode).filter { it != lastNode }) { + if (nodes.any { it == parent }) { + return false + } + } + } + support.setShowDropLocation(true) + return support.isDataFlavorSupported(MoveHostTransferable.dataFlavor) + } + + override fun importData(support: TransferSupport): Boolean { + if (!support.isDrop) { + return false + } + + val dropLocation = support.dropLocation + if (dropLocation !is JTree.DropLocation) { + return false + } + + val lastNode = dropLocation.path.lastPathComponent + if (lastNode !is Host || lastNode.protocol != Protocol.Folder) { + return false + } + + if (!support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) { + return false + } + + val hosts = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*>) + .filterIsInstance().toMutableList() + if (hosts.isEmpty()) { + return false + } + + // 记录展开的节点 + val expandedHosts = mutableListOf() + for (host in hosts) { + model.visit(host) { + if (it.protocol == Protocol.Folder) { + if (isExpanded(TreePath(model.getPathToRoot(it)))) { + expandedHosts.addFirst(it.id) + } + } + } + } + + var now = System.currentTimeMillis() + for (host in hosts) { + model.removeNodeFromParent(host) + val newHost = host.copy( + parentId = lastNode.id, + sort = ++now, + updateDate = now + ) + runCatchingHost(newHost) + } + + expandNode(lastNode) + + // 展开 + for (id in expandedHosts) { + model.getHost(id)?.let { expandNode(it) } + } + + return true + } + } + + } + + override fun isPathEditable(path: TreePath?): Boolean { + if (path == null) return false + if (path.lastPathComponent == model.root) return false + return super.isPathEditable(path) + } + + override fun getLastSelectedPathComponent(): Any? { + val last = super.getLastSelectedPathComponent() ?: return null + if (last is Host) { + return model.getHost(last.id) ?: last + } + return last + } + + private fun showContextMenu(event: MouseEvent) { + val lastHost = lastSelectedPathComponent + if (lastHost !is Host) { + return + } + + val popupMenu = FlatPopupMenu() + val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new")) + val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder")) + val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host")) + + val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open")) + popupMenu.addSeparator() + val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy")) + val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove")) + val rename = popupMenu.add(I18n.getString("termora.welcome.contextmenu.rename")) + popupMenu.addSeparator() + val expandAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.expand-all")) + val colspanAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.collapse-all")) + popupMenu.addSeparator() + popupMenu.add(newMenu) + popupMenu.addSeparator() + val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property")) + + open.addActionListener { + getSelectionNodes() + .filter { it.protocol != Protocol.Folder } + .forEach { + ActionManager.getInstance() + .getAction(Actions.OPEN_HOST) + ?.actionPerformed(OpenHostActionEvent(this, it)) + } + } + + rename.addActionListener { + startEditingAtPath(TreePath(model.getPathToRoot(lastHost))) + } + + expandAll.addActionListener { + getSelectionNodes().forEach { expandNode(it, true) } + } + + + colspanAll.addActionListener { + selectionModel.selectionPaths.map { it.lastPathComponent } + .filterIsInstance() + .filter { it.protocol == Protocol.Folder } + .forEach { collapseNode(it) } + } + + copy.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val parent = model.getParent(lastHost) ?: return + val node = copyNode(parent, lastHost) + selectionPath = TreePath(model.getPathToRoot(node)) + } + }) + + remove.addActionListener { + if (OptionPane.showConfirmDialog( + SwingUtilities.getWindowAncestor(this), + "删除后无法恢复,你确定要删除吗?", + I18n.getString("termora.remove"), + JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE + ) == JOptionPane.YES_OPTION + ) { + var lastParent: Host? = null + while (!selectionModel.isSelectionEmpty) { + val host = lastSelectedPathComponent ?: break + if (host !is Host) { + break + } else { + lastParent = model.getParent(host) + } + model.visit(host) { hostManager.removeHost(it.id) } + } + if (lastParent != null) { + selectionPath = TreePath(model.getPathToRoot(lastParent)) + } + } + } + + newFolder.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + if (lastHost.protocol != Protocol.Folder) { + return + } + + val host = Host( + id = UUID.randomUUID().toSimpleString(), + protocol = Protocol.Folder, + name = I18n.getString("termora.welcome.contextmenu.new.folder.name"), + sort = System.currentTimeMillis(), + parentId = lastHost.id + ) + + runCatchingHost(host) + + expandNode(lastHost) + selectionPath = TreePath(model.getPathToRoot(host)) + startEditingAtPath(selectionPath) + + } + }) + + newHost.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + showAddHostDialog() + } + }) + + property.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val dialog = HostDialog(SwingUtilities.getWindowAncestor(this@HostTree), lastHost) + dialog.isVisible = true + val host = dialog.host ?: return + runCatchingHost(host) + } + }) + + // 初始化状态 + newFolder.isEnabled = lastHost.protocol == Protocol.Folder + newHost.isEnabled = newFolder.isEnabled + remove.isEnabled = !getSelectionNodes().any { it == model.root } + copy.isEnabled = remove.isEnabled + rename.isEnabled = remove.isEnabled + property.isEnabled = lastHost.protocol != Protocol.Folder + + popupMenu.addPopupMenuListener(object : PopupMenuListener { + override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) { + this@HostTree.grabFocus() + } + + override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) { + this@HostTree.requestFocusInWindow() + } + + override fun popupMenuCanceled(e: PopupMenuEvent) { + } + + }) + + + popupMenu.show(this, event.x, event.y) + } + + fun showAddHostDialog() { + var lastHost = lastSelectedPathComponent + if (lastHost !is Host) { + return + } + + if (lastHost.protocol != Protocol.Folder) { + val p = model.getParent(lastHost) ?: return + lastHost = p + } + + val dialog = HostDialog(SwingUtilities.getWindowAncestor(this)) + dialog.isVisible = true + val host = (dialog.host ?: return).copy(parentId = lastHost.id) + + runCatchingHost(host) + + expandNode(lastHost) + selectionPath = TreePath(model.getPathToRoot(host)) + + } + + + private fun expandNode(node: Host, including: Boolean = false) { + expandPath(TreePath(model.getPathToRoot(node))) + if (including) { + model.getChildren(node).forEach { expandNode(it, true) } + } + } + + + private fun copyNode( + parent: Host, + host: Host, + idGenerator: () -> String = { UUID.randomUUID().toSimpleString() } + ): Host { + val now = System.currentTimeMillis() + val newHost = host.copy( + name = "${host.name} ${I18n.getString("termora.welcome.contextmenu.copy")}", + id = idGenerator.invoke(), + parentId = parent.id, + updateDate = now, + createDate = now, + sort = now + ) + + runCatchingHost(newHost) + + if (host.protocol == Protocol.Folder) { + for (child in model.getChildren(host)) { + copyNode(newHost, child, idGenerator) + } + if (isExpanded(TreePath(model.getPathToRoot(host)))) { + expandNode(newHost) + } + } + + return newHost + + } + + private fun runCatchingHost(host: Host) { + hostManager.addHost(host) + } + + private fun collapseNode(node: Host) { + model.getChildren(node).forEach { collapseNode(it) } + collapsePath(TreePath(model.getPathToRoot(node))) + } + + private fun getSelectionNodes(): List { + val selectionNodes = selectionModel.selectionPaths.map { it.lastPathComponent } + .filterIsInstance() + + if (selectionNodes.isEmpty()) { + return emptyList() + } + + val nodes = mutableListOf() + val parents = mutableListOf() + + for (node in selectionNodes) { + if (node.protocol == Protocol.Folder) { + parents.add(node) + } + nodes.add(node) + } + + while (parents.isNotEmpty()) { + val p = parents.removeFirst() + for (i in 0 until model.getChildCount(p)) { + val child = model.getChild(p, i) as Host + nodes.add(child) + parents.add(child) + } + } + + return nodes + } + + override fun dispose() { + Database.instance.properties.putString( + "HostTreeExpansionState", + TreeUtils.saveExpansionState(this) + ) + } + + private abstract class HostTreeNodeTransferable(val hosts: List) : + Transferable { + + override fun getTransferDataFlavors(): Array { + return arrayOf(getDataFlavor()) + } + + override fun isDataFlavorSupported(flavor: DataFlavor): Boolean { + return getDataFlavor() == flavor + } + + override fun getTransferData(flavor: DataFlavor): Any { + return hosts + } + + abstract fun getDataFlavor(): DataFlavor + } + + private class MoveHostTransferable(hosts: List) : HostTreeNodeTransferable(hosts) { + companion object { + val dataFlavor = + DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveHostTransferable::class.java.name}") + } + + override fun getDataFlavor(): DataFlavor { + return dataFlavor + } + + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostTreeModel.kt b/src/main/kotlin/app/termora/HostTreeModel.kt new file mode 100644 index 0000000..163e221 --- /dev/null +++ b/src/main/kotlin/app/termora/HostTreeModel.kt @@ -0,0 +1,159 @@ +package app.termora + +import org.apache.commons.lang3.StringUtils +import javax.swing.event.TreeModelEvent +import javax.swing.event.TreeModelListener +import javax.swing.tree.TreeModel +import javax.swing.tree.TreePath + +class HostTreeModel : TreeModel { + + val listeners = mutableListOf() + + private val hostManager get() = HostManager.instance + private val hosts = mutableMapOf() + private val myRoot by lazy { + Host( + id = "0", + protocol = Protocol.Folder, + name = I18n.getString("termora.welcome.my-hosts"), + host = StringUtils.EMPTY, + port = 0, + remark = StringUtils.EMPTY, + username = StringUtils.EMPTY + ) + } + + init { + + for (host in hostManager.hosts()) { + hosts[host.id] = host + } + + hostManager.addHostListener(object : HostListener { + override fun hostRemoved(id: String) { + val host = hosts[id] ?: return + removeNodeFromParent(host) + } + + override fun hostAdded(host: Host) { + // 如果已经存在,那么是修改 + if (hosts.containsKey(host.id)) { + val oldHost = hosts.getValue(host.id) + // 父级结构变了 + if (oldHost.parentId != host.parentId) { + hostRemoved(host.id) + hostAdded(host) + } else { + hosts[host.id] = host + val event = TreeModelEvent(this, getPathToRoot(host)) + listeners.forEach { it.treeStructureChanged(event) } + } + + } else { + hosts[host.id] = host + val parent = getParent(host) ?: return + val path = TreePath(getPathToRoot(parent)) + val event = TreeModelEvent(this, path, intArrayOf(getIndexOfChild(parent, host)), arrayOf(host)) + listeners.forEach { it.treeNodesInserted(event) } + } + } + + override fun hostsChanged() { + hosts.clear() + for (host in hostManager.hosts()) { + hosts[host.id] = host + } + val event = TreeModelEvent(this, getPathToRoot(root), null, null) + listeners.forEach { it.treeStructureChanged(event) } + } + + }) + } + + override fun getRoot(): Host { + return myRoot + } + + override fun getChild(parent: Any?, index: Int): Any { + return getChildren(parent)[index] + } + + override fun getChildCount(parent: Any?): Int { + return getChildren(parent).size + } + + override fun isLeaf(node: Any?): Boolean { + return getChildCount(node) == 0 + } + + fun getParent(node: Host): Host? { + if (node.parentId == root.id || root.id == node.id) { + return root + } + return hosts.values.firstOrNull { it.id == node.parentId } + } + + override fun valueForPathChanged(path: TreePath?, newValue: Any?) { + + } + + override fun getIndexOfChild(parent: Any?, child: Any?): Int { + return getChildren(parent).indexOf(child) + } + + override fun addTreeModelListener(listener: TreeModelListener) { + listeners.add(listener) + } + + override fun removeTreeModelListener(listener: TreeModelListener) { + listeners.remove(listener) + } + + /** + * 仅从结构中删除 + */ + fun removeNodeFromParent(host: Host) { + val parent = getParent(host) ?: return + val index = getIndexOfChild(parent, host) + val event = TreeModelEvent(this, TreePath(getPathToRoot(parent)), intArrayOf(index), arrayOf(host)) + hosts.remove(host.id) + listeners.forEach { it.treeNodesRemoved(event) } + } + + fun visit(host: Host, visitor: (host: Host) -> Unit) { + if (host.protocol == Protocol.Folder) { + getChildren(host).forEach { visit(it, visitor) } + visitor.invoke(host) + } else { + visitor.invoke(host) + } + } + + fun getHost(id: String): Host? { + return hosts[id] + } + + fun getPathToRoot(host: Host): Array { + + if (host.id == root.id) { + return arrayOf(root) + } + + val parents = mutableListOf(host) + var pId = host.parentId + while (pId != root.id) { + val e = hosts[(pId)] ?: break + parents.addFirst(e) + pId = e.parentId + } + parents.addFirst(root) + return parents.toTypedArray() + } + + fun getChildren(parent: Any?): List { + val pId = if (parent is Host) parent.id else root.id + return hosts.values.filter { it.parentId == pId } + .sortedWith(compareBy { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort }) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Hyperlink.kt b/src/main/kotlin/app/termora/Hyperlink.kt new file mode 100644 index 0000000..0146711 --- /dev/null +++ b/src/main/kotlin/app/termora/Hyperlink.kt @@ -0,0 +1,22 @@ +package app.termora + +import com.formdev.flatlaf.extras.FlatSVGIcon +import com.formdev.flatlaf.extras.FlatSVGIcon.ColorFilter +import org.jdesktop.swingx.JXHyperlink +import java.awt.Color +import javax.swing.SwingConstants +import javax.swing.UIManager + +class Hyperlink(action: AnAction, focusable: Boolean = true) : JXHyperlink(action) { + init { + val myIcon = FlatSVGIcon(Icons.externalLink.name) + myIcon.colorFilter = object : ColorFilter() { + override fun filter(color: Color?): Color { + return UIManager.getColor("Hyperlink.linkColor") + } + } + isFocusable = focusable + icon = myIcon + horizontalTextPosition = SwingConstants.LEFT + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/I18n.kt b/src/main/kotlin/app/termora/I18n.kt new file mode 100644 index 0000000..0720912 --- /dev/null +++ b/src/main/kotlin/app/termora/I18n.kt @@ -0,0 +1,57 @@ +package app.termora + +import org.apache.commons.lang3.LocaleUtils +import org.apache.commons.text.StringSubstitutor +import org.slf4j.LoggerFactory +import java.text.MessageFormat +import java.util.* + +object I18n { + private val log = LoggerFactory.getLogger(I18n::class.java) + private val bundle by lazy { + val bundle = ResourceBundle.getBundle("i18n/messages", Locale.getDefault()) + if (log.isInfoEnabled) { + log.info("I18n: {}", bundle.baseBundleName ?: "null") + } + return@lazy bundle + } + + private val substitutor by lazy { StringSubstitutor { key -> getString(key) } } + private val supportedLanguages = sortedMapOf( + "en_US" to "English", + "zh_CN" to "简体中文", + "zh_TW" to "繁體中文", + ) + + fun containsLanguage(locale: Locale): String? { + for (key in supportedLanguages.keys) { + val e = LocaleUtils.toLocale(key) + if (LocaleUtils.toLocale(key) == locale || + (e.language.equals(locale.language, true) && e.country.equals(locale.country, true)) + ) { + return key + } + } + return null + } + + fun getLanguages(): Map { + return supportedLanguages + } + + fun getString(key: String, vararg args: Any): String { + try { + val text = substitutor.replace(bundle.getString(key)) + if (args.isNotEmpty()) { + return MessageFormat.format(text, *args) + } + return text + } catch (e: MissingResourceException) { + if (log.isWarnEnabled) { + log.warn(e.message, e) + } + return key + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Icons.kt b/src/main/kotlin/app/termora/Icons.kt new file mode 100644 index 0000000..042653c --- /dev/null +++ b/src/main/kotlin/app/termora/Icons.kt @@ -0,0 +1,83 @@ +package app.termora + +object Icons { + val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") } + val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") } + val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") } + val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") } + val matchCase by lazy { DynamicIcon("icons/matchCase.svg", "icons/matchCase_dark.svg") } + val regex by lazy { DynamicIcon("icons/regex.svg", "icons/regex_dark.svg") } + val vcs by lazy { DynamicIcon("icons/vcs.svg", "icons/vcs_dark.svg") } + val dumpThreads by lazy { DynamicIcon("icons/dumpThreads.svg", "icons/dumpThreads_dark.svg") } + val supertypes by lazy { DynamicIcon("icons/supertypes.svg", "icons/supertypes_dark.svg") } + val settings by lazy { DynamicIcon("icons/settings.svg", "icons/settings_dark.svg") } + val pin by lazy { DynamicIcon("icons/pin.svg", "icons/pin_dark.svg") } + val empty by lazy { DynamicIcon("icons/empty.svg") } + val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") } + val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") } + val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") } + val daemonSets by lazy { DynamicIcon("icons/daemonSets.svg", "icons/daemonSets_dark.svg") } + val role by lazy { DynamicIcon("icons/role.svg", "icons/role_dark.svg") } + val locked by lazy { DynamicIcon("icons/locked.svg", "icons/locked_dark.svg") } + val warning by lazy { DynamicIcon("icons/warning.svg", "icons/warning_dark.svg") } + val warningDialog by lazy { DynamicIcon("icons/warningDialog.svg", "icons/warningDialog_dark.svg") } + val unlocked by lazy { DynamicIcon("icons/unlocked.svg", "icons/unlocked_dark.svg") } + val i18n by lazy { DynamicIcon("icons/i18n.svg", "icons/i18n_dark.svg") } + val rec by lazy { DynamicIcon("icons/rec.svg", "icons/rec_dark.svg") } + val stop by lazy { DynamicIcon("icons/stop.svg", "icons/stop_dark.svg") } + val find by lazy { DynamicIcon("icons/find.svg", "icons/find_dark.svg") } + val keyboard by lazy { DynamicIcon("icons/keyboard.svg", "icons/keyboard_dark.svg") } + val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") } + val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") } + val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") } + val homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") } + val openNewTab by lazy { DynamicIcon("icons/openNewTab.svg", "icons/openNewTab_dark.svg") } + val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") } + val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") } + val terminal by lazy { DynamicIcon("icons/terminal.svg", "icons/terminal_dark.svg") } + val azure by lazy { DynamicIcon("icons/azure.svg", "icons/azure_dark.svg") } + val revert by lazy { DynamicIcon("icons/revert.svg", "icons/revert_dark.svg") } + val edit by lazy { DynamicIcon("icons/edit.svg", "icons/edit_dark.svg") } + val microsoft by lazy { DynamicIcon("icons/microsoft.svg", "icons/microsoft_dark.svg") } + val tencent by lazy { DynamicIcon("icons/tencent.svg") } + val google by lazy { DynamicIcon("icons/google-small.svg") } + val aliyun by lazy { DynamicIcon("icons/aliyun.svg") } + val yandexCloud by lazy { DynamicIcon("icons/yandexCloud.svg") } + val aws by lazy { DynamicIcon("icons/aws.svg","icons/aws_dark.svg") } + val huawei by lazy { DynamicIcon("icons/huawei.svg") } + val baidu by lazy { DynamicIcon("icons/baiduyun.svg") } + val tianyi by lazy { DynamicIcon("icons/tianyiyun.svg") } + val digitalocean by lazy { DynamicIcon("icons/digitalocean.svg","icons/digitalocean_dark.svg") } + val terminalUnread by lazy { DynamicIcon("icons/terminalUnread.svg", "icons/terminalUnread_dark.svg") } + val dbPrimitive by lazy { DynamicIcon("icons/dbPrimitive.svg", "icons/dbPrimitive_dark.svg") } + val linux by lazy { DynamicIcon("icons/linux.svg", "icons/linux_dark.svg") } + val success by lazy { DynamicIcon("icons/success.svg", "icons/success_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 runAnything by lazy { DynamicIcon("icons/runAnything.svg", "icons/runAnything_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 externalLink by lazy { DynamicIcon("icons/externalLink.svg", "icons/externalLink_dark.svg") } + val user by lazy { DynamicIcon("icons/user.svg", "icons/user_dark.svg") } + val infoOutline by lazy { DynamicIcon("icons/infoOutline.svg", "icons/infoOutline_dark.svg") } + val lightning by lazy { DynamicIcon("icons/lightning.svg", "icons/lightning_dark.svg") } + val split by lazy { DynamicIcon("icons/split.svg", "icons/split_dark.svg") } + val setKey by lazy { DynamicIcon("icons/setKey.svg", "icons/setKey_dark.svg") } + val greyKey by lazy { DynamicIcon("icons/greyKey.svg", "icons/greyKey_dark.svg") } + val sortedSet by lazy { DynamicIcon("icons/sortedSet.svg", "icons/sortedSet_dark.svg") } + val colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_dark.svg") } + val folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") } + val help by lazy { DynamicIcon("icons/help.svg", "icons/help_dark.svg") } + val expand by lazy { DynamicIcon("icons/expand.svg", "icons/expand_dark.svg") } + val collapse by lazy { DynamicIcon("icons/collapse.svg", "icons/collapse_dark.svg") } + val expandAll by lazy { DynamicIcon("icons/expandAll.svg", "icons/expandAll_dark.svg") } + val collapseAll by lazy { DynamicIcon("icons/collapseAll.svg", "icons/collapseAll_dark.svg") } + val web by lazy { DynamicIcon("icons/web.svg", "icons/web_dark.svg") } + val download by lazy { DynamicIcon("icons/download.svg", "icons/download_dark.svg") } + val upload by lazy { DynamicIcon("icons/upload.svg", "icons/upload_dark.svg") } + val ideUpdate by lazy { DynamicIcon("icons/ideUpdate.svg", "icons/ideUpdate_dark.svg") } + val listKey by lazy { DynamicIcon("icons/listKey.svg", "icons/listKey_dark.svg") } + val forwardPorts by lazy { DynamicIcon("icons/forwardPorts.svg", "icons/forwardPorts_dark.svg") } + val showWriteAccess by lazy { DynamicIcon("icons/showWriteAccess.svg", "icons/showWriteAccess_dark.svg") } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/InputDialog.kt b/src/main/kotlin/app/termora/InputDialog.kt new file mode 100644 index 0000000..56f7503 --- /dev/null +++ b/src/main/kotlin/app/termora/InputDialog.kt @@ -0,0 +1,74 @@ +package app.termora + +import com.formdev.flatlaf.extras.components.FlatTextField +import org.apache.commons.lang3.StringUtils +import java.awt.Window +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import javax.swing.BorderFactory +import javax.swing.JComponent +import javax.swing.UIManager + +class InputDialog( + owner: Window, + title: String, + text: String = StringUtils.EMPTY, + placeholderText: String = StringUtils.EMPTY +) : DialogWrapper(owner) { + private val textField = FlatTextField() + private var text: String? = null + + init { + setSize(340, 60) + setLocationRelativeTo(owner) + + super.setTitle(title) + + isResizable = false + isModal = true + controlsVisible = false + titleBarHeight = UIManager.getInt("TabbedPane.tabHeight") * 0.8f + + + textField.placeholderText = placeholderText + textField.text = text + textField.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_ENTER) { + if (textField.text.isBlank()) { + return + } + doOKAction() + } + } + }) + + init() + } + + override fun createCenterPanel(): JComponent { + textField.background = UIManager.getColor("window") + textField.border = BorderFactory.createEmptyBorder(0, 13, 0, 13) + + return textField + } + + fun getText(): String? { + isVisible = true + return text + } + + override fun doCancelAction() { + text = null + super.doCancelAction() + } + + override fun doOKAction() { + text = textField.text + super.doOKAction() + } + + override fun createSouthPanel(): JComponent? { + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Laf.kt b/src/main/kotlin/app/termora/Laf.kt new file mode 100644 index 0000000..64e2e2d --- /dev/null +++ b/src/main/kotlin/app/termora/Laf.kt @@ -0,0 +1,951 @@ +package app.termora + +import app.termora.terminal.ColorTheme +import app.termora.terminal.TerminalColor +import com.formdev.flatlaf.FlatDarkLaf +import com.formdev.flatlaf.FlatLightLaf +import com.formdev.flatlaf.FlatPropertiesLaf +import com.formdev.flatlaf.util.SystemInfo +import java.util.* + + +class LightLaf : FlatLightLaf(), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0 + TerminalColor.Normal.RED -> 13501701 + TerminalColor.Normal.GREEN -> 425239 + TerminalColor.Normal.YELLOW -> 11701248 + TerminalColor.Normal.BLUE -> 409563 + TerminalColor.Normal.MAGENTA -> 11733427 + TerminalColor.Normal.CYAN -> 167566 + TerminalColor.Normal.WHITE -> 9605778 + + TerminalColor.Bright.BLACK -> 0x4c4c4c + TerminalColor.Bright.RED -> 0xff0000 + TerminalColor.Bright.GREEN -> 0x00ff00 + TerminalColor.Bright.YELLOW -> if (SystemInfo.isWindows) 0xC18301 else 0xffff00 + TerminalColor.Bright.BLUE -> 0x4682b4 + TerminalColor.Bright.MAGENTA -> 0xff00ff + TerminalColor.Bright.CYAN -> 0x00ffff + TerminalColor.Bright.WHITE -> 0xffffff + + else -> Int.MAX_VALUE + } + } +} + + +class DarkLaf : FlatDarkLaf(), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0 + TerminalColor.Normal.RED -> 15749711 + TerminalColor.Normal.GREEN -> 6067756 + TerminalColor.Normal.YELLOW -> 10914317 + TerminalColor.Normal.BLUE -> 3773396 + TerminalColor.Normal.MAGENTA -> 10973631 + TerminalColor.Normal.CYAN -> 41891 + TerminalColor.Normal.WHITE -> 8421504 + + + TerminalColor.Bright.BLACK -> 0x676767 + TerminalColor.Bright.RED -> 0xef766d + TerminalColor.Bright.GREEN -> 0x8cf67a + TerminalColor.Bright.YELLOW -> 0xfefb7e + TerminalColor.Bright.BLUE -> 0x6a71f6 + TerminalColor.Bright.MAGENTA -> 0xf07ef8 + TerminalColor.Bright.CYAN -> 0x8ef9fd + TerminalColor.Bright.WHITE -> 0xfeffff + +// TerminalColor.Basic.BACKGROUND -> 1974050 + + else -> Int.MAX_VALUE + } + } +} + +class iTerm2DarkLaf : FlatDarkLaf(), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + + TerminalColor.Basic.BACKGROUND -> 0 + TerminalColor.Basic.FOREGROUND -> 0xc7c7c7 + TerminalColor.Basic.SELECTION_BACKGROUND -> 0xc6dcfc + TerminalColor.Basic.SELECTION_FOREGROUND -> 0x000000 + TerminalColor.Basic.HYPERLINK -> 0x255ab4 + TerminalColor.Find.BACKGROUND -> 0xffff00 + TerminalColor.Find.FOREGROUND -> 0 + + TerminalColor.Cursor.BACKGROUND -> 0xc7c7c7 + + TerminalColor.Normal.BLACK -> 0 + TerminalColor.Normal.RED -> 0xb83019 + TerminalColor.Normal.GREEN -> 0x51bf37 + TerminalColor.Normal.YELLOW -> 0xc6c43d + TerminalColor.Normal.BLUE -> 0x0c24bf + TerminalColor.Normal.MAGENTA -> 0xb93ec1 + TerminalColor.Normal.CYAN -> 0x53c2c5 + TerminalColor.Normal.WHITE -> 0xc7c7c7 + + + TerminalColor.Bright.BLACK -> 0x676767 + TerminalColor.Bright.RED -> 0xef766d + TerminalColor.Bright.GREEN -> 0x8cf67a + TerminalColor.Bright.YELLOW -> 0xfefb7e + TerminalColor.Bright.BLUE -> 0x6a71f6 + TerminalColor.Bright.MAGENTA -> 0xf07ef8 + TerminalColor.Bright.CYAN -> 0x8ef9fd + TerminalColor.Bright.WHITE -> 0xfeffff + + + else -> Int.MAX_VALUE + } + } +} + + +class TermiusLightLaf : FlatPropertiesLaf("Termius Light", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "light", + "@background" to "#d5dde0", + "@windowText" to "#32364a", + ) + ) +}), ColorTheme { + + override fun getColor(color: TerminalColor): Int { + + return when (color) { + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x32364a + + TerminalColor.Basic.FOREGROUND -> 0x32364a + + TerminalColor.Normal.BLACK -> 0x141729 + TerminalColor.Normal.RED -> 0xf24e50 + TerminalColor.Normal.GREEN -> 0x198c51 + TerminalColor.Normal.YELLOW -> 0xf8aa4b + TerminalColor.Normal.BLUE -> 0x004878 + TerminalColor.Normal.MAGENTA -> 0x8f3c91 + TerminalColor.Normal.CYAN -> 0x2091f6 + TerminalColor.Normal.WHITE -> 0xeeeeee + + TerminalColor.Bright.BLACK -> 0x3e4257 + TerminalColor.Bright.RED -> 0xff7375 + TerminalColor.Bright.GREEN -> 0x21b568 + TerminalColor.Bright.YELLOW -> 0xfdc47d + TerminalColor.Bright.BLUE -> 0x1d6da2 + TerminalColor.Bright.MAGENTA -> 0xff7dc5 + TerminalColor.Bright.CYAN -> 0x44a7ff + TerminalColor.Bright.WHITE -> 0xffffff + + + else -> Int.MAX_VALUE + } + } +} + + +class TermiusDarkLaf : FlatPropertiesLaf("Termius Dark", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#141729", + "@windowText" to "#21b568", + ) + ) +}), ColorTheme { + + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x21b568 + + TerminalColor.Basic.SELECTION_FOREGROUND ->0 + + TerminalColor.Basic.FOREGROUND -> 0x21b568 + + TerminalColor.Normal.BLACK -> 0x343851 + TerminalColor.Normal.RED -> 0xf24e50 + TerminalColor.Normal.GREEN -> 0x008463 + TerminalColor.Normal.YELLOW -> 0xeca855 + TerminalColor.Normal.BLUE -> 0x08639f + TerminalColor.Normal.MAGENTA -> 0xc13282 + TerminalColor.Normal.CYAN -> 0x2091f6 + TerminalColor.Normal.WHITE -> 0xe2e3e8 + + TerminalColor.Bright.BLACK -> 0x8d91a5 + TerminalColor.Bright.RED -> 0xff7375 + TerminalColor.Bright.GREEN -> 0x3ed7be + TerminalColor.Bright.YELLOW -> 0xfdc47d + TerminalColor.Bright.BLUE -> 0x6ba0c3 + TerminalColor.Bright.MAGENTA -> 0xff7dc5 + TerminalColor.Bright.CYAN -> 0x44a7ff + TerminalColor.Bright.WHITE -> 0xffffff + + else -> Int.MAX_VALUE + } + } +} + +class NovelLaf : FlatPropertiesLaf("Novel", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "light", + "@background" to "#dfdbc3", + "@windowText" to "#3b2322", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x000000 + TerminalColor.Normal.RED -> 0xd30f0f + TerminalColor.Normal.GREEN -> 0x00933b + TerminalColor.Normal.YELLOW -> 0xd38b40 + TerminalColor.Normal.BLUE -> 0x00528e + TerminalColor.Normal.MAGENTA -> 0xcc32cf + TerminalColor.Normal.CYAN -> 0x26c3e6 + TerminalColor.Normal.WHITE -> 0xa6a6a6 + + TerminalColor.Bright.BLACK -> 0x5c5c5c + TerminalColor.Bright.RED -> 0xe0692f + TerminalColor.Bright.GREEN -> 0x00b400 + TerminalColor.Bright.YELLOW -> 0xfff284 + TerminalColor.Bright.BLUE -> 0x3ba6f3 + TerminalColor.Bright.MAGENTA -> 0xec88c2 + TerminalColor.Bright.CYAN -> 0x38daff + TerminalColor.Bright.WHITE -> 0xf2f2f2 + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x73635a + + + else -> Int.MAX_VALUE + } + } +} + + +class AtomOneDarkLaf : FlatPropertiesLaf("Atom One Dark", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#1e2127", + "@windowText" to "#abb2bf", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x000000 + TerminalColor.Normal.RED -> 0xca6169 + TerminalColor.Normal.GREEN -> 0x82a568 + TerminalColor.Normal.YELLOW -> 0xbf8c5d + TerminalColor.Normal.BLUE -> 0x56a2e1 + TerminalColor.Normal.MAGENTA -> 0xb76ccd + TerminalColor.Normal.CYAN -> 0x4e9aa3 + TerminalColor.Normal.WHITE -> 0xc5cbd6 + + TerminalColor.Bright.BLACK -> 0x5c6370 + TerminalColor.Bright.RED -> 0xe77c84 + TerminalColor.Bright.GREEN -> 0xb4e294 + TerminalColor.Bright.YELLOW -> 0xe9b17b + TerminalColor.Bright.BLUE -> 0x7ec5ff + TerminalColor.Bright.MAGENTA -> 0xdb8df2 + TerminalColor.Bright.CYAN -> 0x64cfdd + TerminalColor.Bright.WHITE -> 0xffffff + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0xabb2bf + + else -> Int.MAX_VALUE + } + } +} + + +class AtomOneLightLaf : FlatPropertiesLaf("Atom One Light", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "light", + "@background" to "#f9f9f9", + "@windowText" to "#383a42", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x000000 + TerminalColor.Normal.RED -> 0xe45649 + TerminalColor.Normal.GREEN -> 0x4c9b4b + TerminalColor.Normal.YELLOW -> 0xc99525 + TerminalColor.Normal.BLUE -> 0x4078f2 + TerminalColor.Normal.MAGENTA -> 0xa626a4 + TerminalColor.Normal.CYAN -> 0x0184bc + TerminalColor.Normal.WHITE -> 0xb8b9bf + + TerminalColor.Bright.BLACK -> 0x474747 + TerminalColor.Bright.RED -> 0xff7468 + TerminalColor.Bright.GREEN -> 0x74ca72 + TerminalColor.Bright.YELLOW -> 0xdba633 + TerminalColor.Bright.BLUE -> 0x6a99ff + TerminalColor.Bright.MAGENTA -> 0xc142bf + TerminalColor.Bright.CYAN -> 0x00b1fd + TerminalColor.Bright.WHITE -> 0xffffff + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x383a42 + + else -> Int.MAX_VALUE + } + } +} + + +class EverforestDarkLaf : FlatPropertiesLaf("Everforest Dark", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#282e32", + "@windowText" to "#d3c6aa", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x42494e + TerminalColor.Normal.RED -> 0xa1484a + TerminalColor.Normal.GREEN -> 0x778e54 + TerminalColor.Normal.YELLOW -> 0xba9e68 + TerminalColor.Normal.BLUE -> 0x388084 + TerminalColor.Normal.MAGENTA -> 0x906378 + TerminalColor.Normal.CYAN -> 0x6ca37a + TerminalColor.Normal.WHITE -> 0xc0dac6 + TerminalColor.Bright.BLACK -> 0x575656 + TerminalColor.Bright.RED -> 0xe67e80 + TerminalColor.Bright.GREEN -> 0xa7c080 + TerminalColor.Bright.YELLOW -> 0xdbbc7f + TerminalColor.Bright.BLUE -> 0x7fbbb3 + TerminalColor.Bright.MAGENTA -> 0xd699b6 + TerminalColor.Bright.CYAN -> 0x83c092 + TerminalColor.Bright.WHITE -> 0xe8f4eb + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0xd3c6aa + + else -> Int.MAX_VALUE + } + } +} + + +class EverforestLightLaf : FlatPropertiesLaf("Everforest Light", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "light", + "@background" to "#fefbf1", + "@windowText" to "#5c6a72", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x42494e + TerminalColor.Normal.RED -> 0xd2413e + TerminalColor.Normal.GREEN -> 0x919d45 + TerminalColor.Normal.YELLOW -> 0xd89902 + TerminalColor.Normal.BLUE -> 0x2b7ba7 + TerminalColor.Normal.MAGENTA -> 0xbc72a5 + TerminalColor.Normal.CYAN -> 0x50b08c + TerminalColor.Normal.WHITE -> 0xc8d0c9 + TerminalColor.Bright.BLACK -> 0x575656 + TerminalColor.Bright.RED -> 0xe67e80 + TerminalColor.Bright.GREEN -> 0xa7c080 + TerminalColor.Bright.YELLOW -> 0xdbbc7f + TerminalColor.Bright.BLUE -> 0x7fbbb3 + TerminalColor.Bright.MAGENTA -> 0xd699b6 + TerminalColor.Bright.CYAN -> 0x83c092 + TerminalColor.Bright.WHITE -> 0xd7e2d8 + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x5c6a72 + + else -> Int.MAX_VALUE + } + } +} + + +class NightOwlLaf : FlatPropertiesLaf("Night Owl", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#011627", + "@windowText" to "#d6deeb", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x072945 + TerminalColor.Normal.RED -> 0xef5350 + TerminalColor.Normal.GREEN -> 0x22da6e + TerminalColor.Normal.YELLOW -> 0xc5e478 + TerminalColor.Normal.BLUE -> 0x82aaff + TerminalColor.Normal.MAGENTA -> 0xc792ea + TerminalColor.Normal.CYAN -> 0x21c7a8 + TerminalColor.Normal.WHITE -> 0xe1f1ff + TerminalColor.Bright.BLACK -> 0x575656 + TerminalColor.Bright.RED -> 0xff7472 + TerminalColor.Bright.GREEN -> 0x40fa8d + TerminalColor.Bright.YELLOW -> 0xffeb95 + TerminalColor.Bright.BLUE -> 0xa0beff + TerminalColor.Bright.MAGENTA -> 0xdaa4ff + TerminalColor.Bright.CYAN -> 0x7fdbca + TerminalColor.Bright.WHITE -> 0xffffff + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x80a4c2 + + else -> Int.MAX_VALUE + } + } +} + + +class LightOwlLaf : FlatPropertiesLaf("Light Owl", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "light", + "@background" to "#fbfbfb", + "@windowText" to "#403f53", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x403f53 + TerminalColor.Normal.RED -> 0xde3d3b + TerminalColor.Normal.GREEN -> 0x08916a + TerminalColor.Normal.YELLOW -> 0xe0af02 + TerminalColor.Normal.BLUE -> 0x288ed7 + TerminalColor.Normal.MAGENTA -> 0xd6438a + TerminalColor.Normal.CYAN -> 0x2aa298 + TerminalColor.Normal.WHITE -> 0xe8e5e5 + TerminalColor.Bright.BLACK -> 0x57566d + TerminalColor.Bright.RED -> 0xfa5d5b + TerminalColor.Bright.GREEN -> 0x1abf90 + TerminalColor.Bright.YELLOW -> 0xf4c315 + TerminalColor.Bright.BLUE -> 0x3ca3ec + TerminalColor.Bright.MAGENTA -> 0xf559a4 + TerminalColor.Bright.CYAN -> 0x39c6ba + TerminalColor.Bright.WHITE -> 0xf6f6f6 + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x90a7b2 + + else -> Int.MAX_VALUE + } + } +} + + +class AuraLaf : FlatPropertiesLaf("Aura", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#21202e", + "@windowText" to "#edecee", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x1c1b22 + TerminalColor.Normal.RED -> 0xff6767 + TerminalColor.Normal.GREEN -> 0x4deeb8 + TerminalColor.Normal.YELLOW -> 0xf4be77 + TerminalColor.Normal.BLUE -> 0x5b72ee + TerminalColor.Normal.MAGENTA -> 0xa277ff + TerminalColor.Normal.CYAN -> 0x51fafa + TerminalColor.Normal.WHITE -> 0xdddbfa + TerminalColor.Bright.BLACK -> 0x4d4d4d + TerminalColor.Bright.RED -> 0xffa285 + TerminalColor.Bright.GREEN -> 0x99ffdd + TerminalColor.Bright.YELLOW -> 0xffd49d + TerminalColor.Bright.BLUE -> 0x8296ff + TerminalColor.Bright.MAGENTA -> 0xb592ff + TerminalColor.Bright.CYAN -> 0x8cffff + TerminalColor.Bright.WHITE -> 0xffffff + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0xedecee + + else -> Int.MAX_VALUE + } + } +} + + +class Cobalt2Laf : FlatPropertiesLaf("Cobalt2", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#132738", + "@windowText" to "#ffffff", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x000000 + TerminalColor.Normal.RED -> 0xff0000 + TerminalColor.Normal.GREEN -> 0x38de21 + TerminalColor.Normal.YELLOW -> 0xffe50a + TerminalColor.Normal.BLUE -> 0x1460d2 + TerminalColor.Normal.MAGENTA -> 0xff4387 + TerminalColor.Normal.CYAN -> 0x00bbbb + TerminalColor.Normal.WHITE -> 0xcfcfcf + TerminalColor.Bright.BLACK -> 0x555555 + TerminalColor.Bright.RED -> 0xff757a + TerminalColor.Bright.GREEN -> 0x69fb79 + TerminalColor.Bright.YELLOW -> 0xfff285 + TerminalColor.Bright.BLUE -> 0x77adff + TerminalColor.Bright.MAGENTA -> 0xff92cc + TerminalColor.Bright.CYAN -> 0x6bffff + TerminalColor.Bright.WHITE -> 0xffffff + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0xf0cc09 + + else -> Int.MAX_VALUE + } + } +} + + +class OctocatDarkLaf : FlatPropertiesLaf("Octocat Dark", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#101216", + "@windowText" to "#8b949e", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x000000 + TerminalColor.Normal.RED -> 0xf78166 + TerminalColor.Normal.GREEN -> 0x56d364 + TerminalColor.Normal.YELLOW -> 0xe3b341 + TerminalColor.Normal.BLUE -> 0x6ca4f8 + TerminalColor.Normal.MAGENTA -> 0xdb61a2 + TerminalColor.Normal.CYAN -> 0x2b7489 + TerminalColor.Normal.WHITE -> 0xDADADA + TerminalColor.Bright.BLACK -> 0x4d4d4d + TerminalColor.Bright.RED -> 0xffb5a5 + TerminalColor.Bright.GREEN -> 0x69fb79 + TerminalColor.Bright.YELLOW -> 0xffcf5f + TerminalColor.Bright.BLUE -> 0xb0d0ff + TerminalColor.Bright.MAGENTA -> 0xff92cc + TerminalColor.Bright.CYAN -> 0x54d8ff + TerminalColor.Bright.WHITE -> 0xffffff + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0xc9d1d9 + + else -> Int.MAX_VALUE + } + } +} + + +class OctocatLightLaf : FlatPropertiesLaf("Octocat Light", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "light", + "@background" to "#f4f4f4", + "@windowText" to "#3e3e3e", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x000000 + TerminalColor.Normal.RED -> 0xff0000 + TerminalColor.Normal.GREEN -> 0x38de21 + TerminalColor.Normal.YELLOW -> 0xffe50a + TerminalColor.Normal.BLUE -> 0x1460d2 + TerminalColor.Normal.MAGENTA -> 0xff4387 + TerminalColor.Normal.CYAN -> 0x00bbbb + TerminalColor.Normal.WHITE -> 0xcfcfcf + TerminalColor.Bright.BLACK -> 0x555555 + TerminalColor.Bright.RED -> 0xff757a + TerminalColor.Bright.GREEN -> 0x69fb79 + TerminalColor.Bright.YELLOW -> 0xfff285 + TerminalColor.Bright.BLUE -> 0x77adff + TerminalColor.Bright.MAGENTA -> 0xff92cc + TerminalColor.Bright.CYAN -> 0x6bffff + TerminalColor.Bright.WHITE -> 0xffffff + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x3f3f3f + + else -> Int.MAX_VALUE + } + } +} + + +class AyuDarkLaf : FlatPropertiesLaf("Ayu Dark", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#0f1419", + "@windowText" to "#e6e1cf", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x000000 + TerminalColor.Normal.RED -> 0xff3333 + TerminalColor.Normal.GREEN -> 0xb8cc52 + TerminalColor.Normal.YELLOW -> 0xdbb012 + TerminalColor.Normal.BLUE -> 0x36a3d9 + TerminalColor.Normal.MAGENTA -> 0xdf7a80 + TerminalColor.Normal.CYAN -> 0x6ceedf + TerminalColor.Normal.WHITE -> 0xababab + TerminalColor.Bright.BLACK -> 0x323232 + TerminalColor.Bright.RED -> 0xff8181 + TerminalColor.Bright.GREEN -> 0xeafe84 + TerminalColor.Bright.YELLOW -> 0xffe174 + TerminalColor.Bright.BLUE -> 0x68d5ff + TerminalColor.Bright.MAGENTA -> 0xffa3aa + TerminalColor.Bright.CYAN -> 0x94fff1 + TerminalColor.Bright.WHITE -> 0xffffff + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0xf29718 + + else -> Int.MAX_VALUE + } + } +} + + +class AyuLightLaf : FlatPropertiesLaf("Ayu Light", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "light", + "@background" to "#fafafa", + "@windowText" to "#5c6773", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x000000 + TerminalColor.Normal.RED -> 0xff3333 + TerminalColor.Normal.GREEN -> 0x319900 + TerminalColor.Normal.YELLOW -> 0xf29718 + TerminalColor.Normal.BLUE -> 0x41a6d9 + TerminalColor.Normal.MAGENTA -> 0xe07ead + TerminalColor.Normal.CYAN -> 0x1dd1b0 + TerminalColor.Normal.WHITE -> 0xdfdddd + TerminalColor.Bright.BLACK -> 0x323232 + TerminalColor.Bright.RED -> 0xff5959 + TerminalColor.Bright.GREEN -> 0xb8e532 + TerminalColor.Bright.YELLOW -> 0xffc94a + TerminalColor.Bright.BLUE -> 0x73d8ff + TerminalColor.Bright.MAGENTA -> 0xffa3aa + TerminalColor.Bright.CYAN -> 0x7ff1cb + TerminalColor.Bright.WHITE -> 0xffffff + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0xff6a00 + + else -> Int.MAX_VALUE + } + } +} + + +class HomebrewLaf : FlatPropertiesLaf("Homebrew", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#000000", + "@windowText" to "#00ff00", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x2e2e2e + TerminalColor.Normal.RED -> 0xc93434 + TerminalColor.Normal.GREEN -> 0x348e48 + TerminalColor.Normal.YELLOW -> 0xe09e00 + TerminalColor.Normal.BLUE -> 0x0031e0 + TerminalColor.Normal.MAGENTA -> 0xe235ff + TerminalColor.Normal.CYAN -> 0x3fc1dd + TerminalColor.Normal.WHITE -> 0xd0cfcf + TerminalColor.Bright.BLACK -> 0x5b5b5b + TerminalColor.Bright.RED -> 0xff6767 + TerminalColor.Bright.GREEN -> 0x31ff31 + TerminalColor.Bright.YELLOW -> 0xffdca8 + TerminalColor.Bright.BLUE -> 0x4465da + TerminalColor.Bright.MAGENTA -> 0xff5fc8 + TerminalColor.Bright.CYAN -> 0x8debff + TerminalColor.Bright.WHITE -> 0xe6e6e6 + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x23ff18 + + TerminalColor.Basic.FOREGROUND -> 0x00ff00 + + else -> Int.MAX_VALUE + } + } +} + + +class ProLaf : FlatPropertiesLaf("Pro", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#000000", + "@windowText" to "#f2f2f2", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x2e2e2e + TerminalColor.Normal.RED -> 0xc93434 + TerminalColor.Normal.GREEN -> 0x348e48 + TerminalColor.Normal.YELLOW -> 0xe09e00 + TerminalColor.Normal.BLUE -> 0x002bc7 + TerminalColor.Normal.MAGENTA -> 0xe235ff + TerminalColor.Normal.CYAN -> 0x3fc1dd + TerminalColor.Normal.WHITE -> 0xd0cfcf + TerminalColor.Bright.BLACK -> 0x5b5b5b + TerminalColor.Bright.RED -> 0xff6767 + TerminalColor.Bright.GREEN -> 0x31ff31 + TerminalColor.Bright.YELLOW -> 0xffdca8 + TerminalColor.Bright.BLUE -> 0x4465da + TerminalColor.Bright.MAGENTA -> 0xff5fc8 + TerminalColor.Bright.CYAN -> 0x8debff + TerminalColor.Bright.WHITE -> 0xe6e6e6 + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x4d4d4d + + TerminalColor.Basic.FOREGROUND -> 0xf2f2f2 + + else -> Int.MAX_VALUE + } + } +} + + +class NordLightLaf : FlatPropertiesLaf("Nord Light", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "light", + "@background" to "#e5e9f0", + "@windowText" to "#414858", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x2c3344 + TerminalColor.Normal.RED -> 0xae545d + TerminalColor.Normal.GREEN -> 0x8ca377 + TerminalColor.Normal.YELLOW -> 0xdabe84 + TerminalColor.Normal.BLUE -> 0x718fae + TerminalColor.Normal.MAGENTA -> 0x95728e + TerminalColor.Normal.CYAN -> 0x78acbb + TerminalColor.Normal.WHITE -> 0xd8dee9 + TerminalColor.Bright.BLACK -> 0x4c556a + TerminalColor.Bright.RED -> 0xd97982 + TerminalColor.Bright.GREEN -> 0xa3be8b + TerminalColor.Bright.YELLOW -> 0xeacb8a + TerminalColor.Bright.BLUE -> 0xa4c7e9 + TerminalColor.Bright.MAGENTA -> 0xb48dac + TerminalColor.Bright.CYAN -> 0x8fbcbb + TerminalColor.Bright.WHITE -> 0xeceff4 + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x88c0d0 + + TerminalColor.Basic.FOREGROUND -> 0x414858 + + else -> Int.MAX_VALUE + } + } +} + + +class NordDarkLaf : FlatPropertiesLaf("Nord Dark", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#2e3440", + "@windowText" to "#d8dee9", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x3b4252 + TerminalColor.Normal.RED -> 0xae545d + TerminalColor.Normal.GREEN -> 0x8ca377 + TerminalColor.Normal.YELLOW -> 0xdabe84 + TerminalColor.Normal.BLUE -> 0x718fae + TerminalColor.Normal.MAGENTA -> 0x95728e + TerminalColor.Normal.CYAN -> 0x78acbb + TerminalColor.Normal.WHITE -> 0xd8dee9 + TerminalColor.Bright.BLACK -> 0x4c556a + TerminalColor.Bright.RED -> 0xd97982 + TerminalColor.Bright.GREEN -> 0xa3be8b + TerminalColor.Bright.YELLOW -> 0xeacb8a + TerminalColor.Bright.BLUE -> 0xa4c7e9 + TerminalColor.Bright.MAGENTA -> 0xb48dac + TerminalColor.Bright.CYAN -> 0x8fbcbb + TerminalColor.Bright.WHITE -> 0xeceff4 + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0xeceff4 + + TerminalColor.Basic.FOREGROUND -> 0xd8dee9 + + + else -> Int.MAX_VALUE + } + } +} + + +class GitHubLightLaf : FlatPropertiesLaf("GitHub Light", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "light", + "@background" to "#f4f4f4", + "@windowText" to "#3e3e3e", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x3e3e3e + TerminalColor.Normal.RED -> 0x970b16 + TerminalColor.Normal.GREEN -> 0x07962a + TerminalColor.Normal.YELLOW -> 0xf8eec7 + TerminalColor.Normal.BLUE -> 0x003e8a + TerminalColor.Normal.MAGENTA -> 0xe94691 + TerminalColor.Normal.CYAN -> 0x89d1ec + TerminalColor.Normal.WHITE -> 0x3e3e3e + TerminalColor.Bright.BLACK -> 0x666666 + TerminalColor.Bright.RED -> 0xde0000 + TerminalColor.Bright.GREEN -> 0x87d5a2 + TerminalColor.Bright.YELLOW -> 0xf1d007 + TerminalColor.Bright.BLUE -> 0x2e6cba + TerminalColor.Bright.MAGENTA -> 0xffa29f + TerminalColor.Bright.CYAN -> 0x1cfafe + TerminalColor.Bright.WHITE -> 0xffffff + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x3f3f3f + + TerminalColor.Basic.FOREGROUND -> 0x3e3e3e + + else -> Int.MAX_VALUE + } + } +} + + +class GitHubDarkLaf : FlatPropertiesLaf("GitHub Dark", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#101216", + "@windowText" to "#8b949e", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x000000 + TerminalColor.Normal.RED -> 0xf78166 + TerminalColor.Normal.GREEN -> 0x56d364 + TerminalColor.Normal.YELLOW -> 0xe3b341 + TerminalColor.Normal.BLUE -> 0x6ca4f8 + TerminalColor.Normal.MAGENTA -> 0xdb61a2 + TerminalColor.Normal.CYAN -> 0x2b7489 + TerminalColor.Normal.WHITE -> 0x8b949e + TerminalColor.Bright.BLACK -> 0x4d4d4d + TerminalColor.Bright.RED -> 0xf78166 + TerminalColor.Bright.GREEN -> 0x56d364 + TerminalColor.Bright.YELLOW -> 0xe3b341 + TerminalColor.Bright.BLUE -> 0x6ca4f8 + TerminalColor.Bright.MAGENTA -> 0xdb61a2 + TerminalColor.Bright.CYAN -> 0x2b7489 + TerminalColor.Bright.WHITE -> 0xffffff + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0xc9d1d9 + + TerminalColor.Basic.FOREGROUND -> 0x8b949e + + + else -> Int.MAX_VALUE + } + } +} + + +class ChalkLaf : FlatPropertiesLaf("Chalk", Properties().apply { + putAll( + mapOf( + "@baseTheme" to "dark", + "@background" to "#2b2d2e", + "@windowText" to "#d2d8d9", + ) + ) +}), ColorTheme { + override fun getColor(color: TerminalColor): Int { + return when (color) { + TerminalColor.Normal.BLACK -> 0x7d8b8f + TerminalColor.Normal.RED -> 0xb23a52 + TerminalColor.Normal.GREEN -> 0x789b6a + TerminalColor.Normal.YELLOW -> 0xb9ac4a + TerminalColor.Normal.BLUE -> 0x2a7fac + TerminalColor.Normal.MAGENTA -> 0xbd4f5a + TerminalColor.Normal.CYAN -> 0x44a799 + TerminalColor.Normal.WHITE -> 0xd2d8d9 + TerminalColor.Bright.BLACK -> 0x888888 + TerminalColor.Bright.RED -> 0xf24840 + TerminalColor.Bright.GREEN -> 0x80c470 + TerminalColor.Bright.YELLOW -> 0xffeb62 + TerminalColor.Bright.BLUE -> 0x4196ff + TerminalColor.Bright.MAGENTA -> 0xfc5275 + TerminalColor.Bright.CYAN -> 0x53cdbd + TerminalColor.Bright.WHITE -> 0xd2d8d9 + + TerminalColor.Basic.SELECTION_BACKGROUND, + TerminalColor.Cursor.BACKGROUND -> 0x708284 + + TerminalColor.Basic.FOREGROUND -> 0xd2d8d9 + + + else -> Int.MAX_VALUE + } + } +} diff --git a/src/main/kotlin/app/termora/LocalTerminalTab.kt b/src/main/kotlin/app/termora/LocalTerminalTab.kt new file mode 100644 index 0000000..a1b92c9 --- /dev/null +++ b/src/main/kotlin/app/termora/LocalTerminalTab.kt @@ -0,0 +1,20 @@ +package app.termora + +import app.termora.terminal.PtyConnector +import org.apache.commons.io.Charsets +import java.nio.charset.StandardCharsets + +class LocalTerminalTab(host: Host) : PtyHostTerminalTab(host) { + + override suspend fun openPtyConnector(): PtyConnector { + val winSize = terminalPanel.winSize() + val ptyConnector = PtyConnectorFactory.instance.createPtyConnector( + winSize.rows, winSize.cols, + host.options.envs(), + Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8), + ) + + return ptyConnector + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/LogicCustomTitleBar.kt b/src/main/kotlin/app/termora/LogicCustomTitleBar.kt new file mode 100644 index 0000000..fa38c65 --- /dev/null +++ b/src/main/kotlin/app/termora/LogicCustomTitleBar.kt @@ -0,0 +1,109 @@ +package app.termora + +import com.formdev.flatlaf.FlatClientProperties +import com.jetbrains.JBR +import com.jetbrains.WindowDecorations.CustomTitleBar +import java.awt.Rectangle +import java.awt.Window +import javax.swing.RootPaneContainer + +class LogicCustomTitleBar(private val titleBar: CustomTitleBar) : CustomTitleBar { + companion object { + fun createCustomTitleBar(rootPaneContainer: RootPaneContainer): CustomTitleBar { + if (!JBR.isWindowDecorationsSupported()) { + return LogicCustomTitleBar(object : CustomTitleBar { + override fun getHeight(): Float { + val bounds = rootPaneContainer.rootPane + .getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS) + if (bounds is Rectangle) { + return bounds.height.toFloat() + } + return 0f + } + + override fun setHeight(height: Float) { + rootPaneContainer.rootPane.putClientProperty( + FlatClientProperties.TITLE_BAR_HEIGHT, + height.toInt() + ) + } + + override fun getProperties(): MutableMap { + return mutableMapOf() + } + + override fun putProperties(m: MutableMap?) { + + } + + override fun putProperty(key: String?, value: Any?) { + if (key == "controls.visible" && value is Boolean) { + rootPaneContainer.rootPane.putClientProperty( + FlatClientProperties.TITLE_BAR_SHOW_CLOSE, + value + ) + } + } + + override fun getLeftInset(): Float { + return 0f + } + + override fun getRightInset(): Float { + val bounds = rootPaneContainer.rootPane + .getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS) + if (bounds is Rectangle) { + return bounds.width.toFloat() + } + return 0f + } + + override fun forceHitTest(client: Boolean) { + + } + + override fun getContainingWindow(): Window { + return rootPaneContainer as Window + } + }) + } + return JBR.getWindowDecorations().createCustomTitleBar() + } + } + + override fun getHeight(): Float { + return titleBar.height + } + + override fun setHeight(height: Float) { + titleBar.height = height + } + + override fun getProperties(): MutableMap { + return titleBar.properties + } + + override fun putProperties(m: MutableMap?) { + titleBar.putProperties(m) + } + + override fun putProperty(key: String?, value: Any?) { + titleBar.putProperty(key, value) + } + + override fun getLeftInset(): Float { + return titleBar.leftInset + } + + override fun getRightInset(): Float { + return titleBar.rightInset + } + + override fun forceHitTest(client: Boolean) { + titleBar.forceHitTest(client) + } + + override fun getContainingWindow(): Window { + return titleBar.containingWindow + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Main.kt b/src/main/kotlin/app/termora/Main.kt new file mode 100644 index 0000000..6486990 --- /dev/null +++ b/src/main/kotlin/app/termora/Main.kt @@ -0,0 +1,6 @@ +package app.termora + +fun main() { + ApplicationRunner().run() +} + diff --git a/src/main/kotlin/app/termora/MultiplePtyConnector.kt b/src/main/kotlin/app/termora/MultiplePtyConnector.kt new file mode 100644 index 0000000..cddec2c --- /dev/null +++ b/src/main/kotlin/app/termora/MultiplePtyConnector.kt @@ -0,0 +1,45 @@ +package app.termora + +import app.termora.terminal.PtyConnector +import app.termora.terminal.PtyConnectorDelegate +import org.jdesktop.swingx.action.ActionManager + +/** + * 当开启转发时,会获取到所有的 [PtyConnector] 然后跳过中间层,直接找到最近的一个 [MultiplePtyConnector],如果找不到那就以最后一个匹配不到的为准 [getMultiplePtyConnector]。 + */ +class MultiplePtyConnector(private val myConnector: PtyConnector) : PtyConnectorDelegate(myConnector) { + + private val isMultiple get() = ActionManager.getInstance().isSelected(Actions.MULTIPLE) + private val ptyConnectors get() = PtyConnectorFactory.instance.getPtyConnectors() + + override fun write(buffer: ByteArray, offset: Int, len: Int) { + if (isMultiple) { + for (connector in ptyConnectors) { + getMultiplePtyConnector(connector).write(buffer, offset, len) + } + } else { + myConnector.write(buffer, offset, len) + } + } + + + private fun getMultiplePtyConnector(connector: PtyConnector): PtyConnector { + if (connector is MultiplePtyConnector) { + val c = connector.myConnector + if (c is MultiplePtyConnector) { + return getMultiplePtyConnector(c) + } + return c + } + + if (connector is PtyConnectorDelegate) { + val c = connector.ptyConnector + if (c != null) { + return getMultiplePtyConnector(c) + } + } + + return connector + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/MultipleTerminalListener.kt b/src/main/kotlin/app/termora/MultipleTerminalListener.kt new file mode 100644 index 0000000..8ffa3bc --- /dev/null +++ b/src/main/kotlin/app/termora/MultipleTerminalListener.kt @@ -0,0 +1,44 @@ +package app.termora + +import app.termora.terminal.Terminal +import app.termora.terminal.TerminalColor +import app.termora.terminal.TextStyle +import app.termora.terminal.panel.TerminalDisplay +import app.termora.terminal.panel.TerminalPaintListener +import app.termora.terminal.panel.TerminalPanel +import org.jdesktop.swingx.action.ActionManager +import java.awt.Color +import java.awt.Graphics + +class MultipleTerminalListener : TerminalPaintListener { + override fun after( + offset: Int, + count: Int, + g: Graphics, + terminalPanel: TerminalPanel, + terminalDisplay: TerminalDisplay, + terminal: Terminal + ) { + if (!ActionManager.getInstance().isSelected(Actions.MULTIPLE)) { + return + } + + val oldFont = g.font + val colorPalette = terminal.getTerminalModel().getColorPalette() + val text = I18n.getString("termora.tools.multiple") + val font = terminalDisplay.getDisplayFont(text, TextStyle.Default) + val width = g.getFontMetrics(font).stringWidth(text) + // 正在搜索那么需要下移 + val finding = terminal.getTerminalModel().getData(TerminalPanel.Finding, false) + + g.font = font + g.color = Color(colorPalette.getColor(TerminalColor.Normal.RED)) + g.drawString( + text, + terminalDisplay.width - width - terminalPanel.getAverageCharWidth() / 2, + g.fontMetrics.ascent + if (finding) + g.fontMetrics.height + g.fontMetrics.ascent / 2 else 0 + ) + g.font = oldFont + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/MyTabbedPane.kt b/src/main/kotlin/app/termora/MyTabbedPane.kt new file mode 100644 index 0000000..d88c83b --- /dev/null +++ b/src/main/kotlin/app/termora/MyTabbedPane.kt @@ -0,0 +1,11 @@ +package app.termora + +import com.formdev.flatlaf.extras.components.FlatTabbedPane + +class MyTabbedPane : FlatTabbedPane() { + override fun setSelectedIndex(index: Int) { + val oldIndex = selectedIndex + super.setSelectedIndex(index) + firePropertyChange("selectedIndex", oldIndex,index) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/OpenHostActionEvent.kt b/src/main/kotlin/app/termora/OpenHostActionEvent.kt new file mode 100644 index 0000000..e78e54d --- /dev/null +++ b/src/main/kotlin/app/termora/OpenHostActionEvent.kt @@ -0,0 +1,5 @@ +package app.termora + +import java.awt.event.ActionEvent + +class OpenHostActionEvent(source: Any, val host: Host) : ActionEvent(source, ACTION_PERFORMED, String()) \ No newline at end of file diff --git a/src/main/kotlin/app/termora/OptionPane.kt b/src/main/kotlin/app/termora/OptionPane.kt new file mode 100644 index 0000000..813ab48 --- /dev/null +++ b/src/main/kotlin/app/termora/OptionPane.kt @@ -0,0 +1,165 @@ +package app.termora + +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.extras.components.FlatTextPane +import com.formdev.flatlaf.util.SystemInfo +import com.jetbrains.JBR +import kotlinx.coroutines.* +import kotlinx.coroutines.swing.Swing +import org.jdesktop.swingx.JXLabel +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Desktop +import java.awt.Dimension +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import java.io.File +import javax.swing.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +object OptionPane { + fun showConfirmDialog( + parentComponent: Component?, + message: Any, + title: String = UIManager.getString("OptionPane.messageDialogTitle"), + optionType: Int = JOptionPane.YES_NO_OPTION, + messageType: Int = JOptionPane.QUESTION_MESSAGE, + icon: Icon? = null, + options: Array? = null, + initialValue: Any? = null, + ): Int { + + val panel = if (message is JComponent) { + message + } else { + val label = FlatTextPane() + label.contentType = "text/html" + label.text = "$message" + label.isEditable = false + label.background = null + label.border = BorderFactory.createEmptyBorder() + label + } + + val pane = object : JOptionPane(panel, messageType, optionType, icon, options, initialValue) { + override fun selectInitialValue() { + super.selectInitialValue() + if (message is JComponent) { + message.requestFocusInWindow() + } + } + } + val dialog = initDialog(pane.createDialog(parentComponent, title)) + dialog.addWindowListener(object : WindowAdapter() { + override fun windowOpened(e: WindowEvent) { + pane.selectInitialValue() + } + }) + dialog.isVisible = true + dialog.dispose() + val selectedValue = pane.value + + + if (selectedValue == null) { + return -1 + } else if (pane.options == null) { + return if (selectedValue is Int) selectedValue else -1 + } else { + var counter = 0 + + val maxCounter: Int = pane.options.size + while (counter < maxCounter) { + if (pane.options[counter] == selectedValue) { + return counter + } + ++counter + } + + return -1 + } + } + + fun showMessageDialog( + parentComponent: Component?, + message: String, + title: String = UIManager.getString("OptionPane.messageDialogTitle"), + messageType: Int = JOptionPane.INFORMATION_MESSAGE, + duration: Duration = 0.milliseconds, + ) { + val label = JTextPane() + label.contentType = "text/html" + label.text = "$message" + label.isEditable = false + label.background = null + label.border = BorderFactory.createEmptyBorder() + val pane = JOptionPane(label, messageType, JOptionPane.DEFAULT_OPTION) + val dialog = initDialog(pane.createDialog(parentComponent, title)) + if (duration.inWholeMilliseconds > 0) { + dialog.addWindowListener(object : WindowAdapter() { + @OptIn(DelicateCoroutinesApi::class) + override fun windowOpened(e: WindowEvent) { + GlobalScope.launch(Dispatchers.Swing) { + delay(duration.inWholeMilliseconds) + if (dialog.isVisible) { + dialog.isVisible = false + } + } + } + }) + } + pane.selectInitialValue() + dialog.isVisible = true + dialog.dispose() + } + + fun openFileInFolder( + parentComponent: Component, + file: File, + yMessage: String, + nMessage: String? = null, + ) { + if (Desktop.isDesktopSupported() && Desktop.getDesktop() + .isSupported(Desktop.Action.BROWSE_FILE_DIR) + ) { + if (JOptionPane.YES_OPTION == showConfirmDialog( + parentComponent, + yMessage, + optionType = JOptionPane.YES_NO_OPTION + ) + ) { + Desktop.getDesktop().browseFileDirectory(file) + } + } else if (nMessage != null) { + showMessageDialog( + parentComponent, + nMessage, + messageType = JOptionPane.INFORMATION_MESSAGE + ) + } + } + + private fun initDialog(dialog: JDialog): JDialog { + + if (JBR.isWindowDecorationsSupported()) { + + val windowDecorations = JBR.getWindowDecorations() + val titleBar = windowDecorations.createCustomTitleBar() + titleBar.putProperty("controls.visible", false) + titleBar.height = UIManager.getInt("TabbedPane.tabHeight") - if (SystemInfo.isMacOS) 10f else 6f + windowDecorations.setCustomTitleBar(dialog, titleBar) + + val label = JLabel(dialog.title) + label.putClientProperty(FlatClientProperties.STYLE, "font: bold") + val box = Box.createHorizontalBox() + box.add(Box.createHorizontalGlue()) + box.add(label) + box.add(Box.createHorizontalGlue()) + box.preferredSize = Dimension(-1, titleBar.height.toInt()) + + dialog.contentPane.add(box, BorderLayout.NORTH) + } + + return dialog + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/OptionsPane.kt b/src/main/kotlin/app/termora/OptionsPane.kt new file mode 100644 index 0000000..0d5da1f --- /dev/null +++ b/src/main/kotlin/app/termora/OptionsPane.kt @@ -0,0 +1,136 @@ +package app.termora + +import com.formdev.flatlaf.FlatLaf +import java.awt.* +import javax.swing.* +import javax.swing.border.Border + + +open class OptionsPane : JPanel(BorderLayout()) { + protected val formMargin = "7dlu" + + protected val tabListModel = DefaultListModel