diff --git a/THIRDPARTY b/THIRDPARTY index 8b0672c..b8f932a 100644 --- a/THIRDPARTY +++ b/THIRDPARTY @@ -2,10 +2,6 @@ annotations Apache License 2.0 https://github.com/JetBrains/java-annotations/blob/master/LICENSE.txt -kotlin-bip39 -MIT License -https://github.com/Electric-Coin-Company/kotlin-bip39/blob/main/LICENSE - colorpicker BSD 3-Clause "New" or "Revised" License https://github.com/dheid/colorpicker/blob/main/LICENSE @@ -18,10 +14,6 @@ commons-codec Apache License 2.0 https://github.com/apache/commons-codec/blob/master/LICENSE.txt -commons-compress -Apache License 2.0 -https://github.com/apache/commons-compress/blob/master/LICENSE.txt - commons-vfs2 Apache License 2.0 https://github.com/apache/commons-vfs/blob/master/LICENSE.txt @@ -226,26 +218,6 @@ versioncompare Apache License 2.0 https://github.com/G00fY2/version-compare/blob/main/LICENSE -xodus-compress -Apache License 2.0 -https://github.com/JetBrains/xodus/blob/master/LICENSE.txt - -xodus-environment -Apache License 2.0 -https://github.com/JetBrains/xodus/blob/master/LICENSE.txt - -xodus-openAPI -Apache License 2.0 -https://github.com/JetBrains/xodus/blob/master/LICENSE.txt - -xodus-utils -Apache License 2.0 -https://github.com/JetBrains/xodus/blob/master/LICENSE.txt - -xodus-vfs -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 @@ -260,4 +232,32 @@ https://github.com/stleary/JSON-java/blob/master/LICENSE jSerialComm Apache License 2.0 -https://github.com/Fazecast/jSerialComm/blob/master/LICENSE-APACHE-2.0 \ No newline at end of file +https://github.com/Fazecast/jSerialComm/blob/master/LICENSE-APACHE-2.0 + +exposed-core +Apache License 2.0 +https://github.com/JetBrains/Exposed/blob/main/LICENSE.txt + +exposed-crypt +Apache License 2.0 +https://github.com/JetBrains/Exposed/blob/main/LICENSE.txt + +exposed-jdbc +Apache License 2.0 +https://github.com/JetBrains/Exposed/blob/main/LICENSE.txt + +sqlite-jdbc +Apache License 2.0 +https://www.apache.org/licenses/LICENSE-2.0.txt + +java-uuid-generator +Apache License 2.0 +https://github.com/cowtowncoder/java-uuid-generator/blob/master/LICENSE + +semver4j +MIT +https://github.com/semver4j/semver4j/blob/main/LICENSE + +dom4j +Plexus (https://dom4j.github.io) +https://github.com/dom4j/dom4j/blob/master/LICENSE \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..952cca7 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.0.0-beta.1 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index acf70d3..fab2d45 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,8 +5,10 @@ import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.jetbrains.kotlin.org.apache.commons.io.FileUtils import org.jetbrains.kotlin.org.apache.commons.io.filefilter.FileFilterUtils import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils +import org.jetbrains.kotlin.org.apache.commons.lang3.time.DateFormatUtils import java.io.FileNotFoundException import java.nio.file.Files +import java.util.* import java.util.concurrent.Executors import java.util.concurrent.Future @@ -21,10 +23,11 @@ plugins { group = "app.termora" -version = "1.0.16" +version = rootProject.projectDir.resolve("VERSION").readText().trim() val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem() val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture() +val appVersion = project.version.toString().split("-")[0] // macOS 签名信息 val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY @@ -36,15 +39,16 @@ val macOSNotaryKeychainProfile = System.getenv("TERMORA_MAC_NOTARY_KEYCHAIN_PROF val macOSNotary = macOSSign && macOSNotaryKeychainProfile.isNotBlank() && System.getenv("TERMORA_MAC_NOTARY").toBoolean() -repositories { - mavenCentral() - maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies") - maven("https://www.jitpack.io") +allprojects { + repositories { + mavenCentral() + maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies") + maven("https://www.jitpack.io") + maven("https://central.sonatype.com/repository/maven-snapshots") + } } dependencies { - // 由于签名和公证,macOS 不携带 natives - val useNoNativesFlatLaf = os.isMacOsX && System.getenv("ENABLE_BUILD").toBoolean() testImplementation(kotlin("test")) testImplementation(libs.hutool) @@ -54,6 +58,8 @@ dependencies { testImplementation(libs.delight.rhino.sandbox) testImplementation(platform(libs.testcontainers.bom)) testImplementation(libs.testcontainers) + testImplementation(libs.h2) + testImplementation(libs.exposed.migration) // implementation(platform(libs.koin.bom)) // implementation(libs.koin.core) @@ -67,28 +73,13 @@ dependencies { api(libs.commons.csv) api(libs.commons.net) api(libs.commons.text) - api(libs.commons.compress) api(libs.commons.vfs2) { exclude(group = "*", module = "*") } api(libs.kotlinx.coroutines.swing) api(libs.kotlinx.coroutines.core) - api(libs.flatlaf) { - artifact { - if (useNoNativesFlatLaf) { - classifier = "no-natives" - } - } - } - api(libs.flatlaf.extras) { - if (useNoNativesFlatLaf) { - exclude(group = "com.formdev", module = "flatlaf") - } - } - api(libs.flatlaf.swingx) { - if (useNoNativesFlatLaf) { - exclude(group = "com.formdev", module = "flatlaf") - } - } + api(libs.flatlaf) + api(libs.flatlafextras) + api(libs.flatlafswingx) api(libs.kotlinx.serialization.json) api(libs.swingx) @@ -109,24 +100,26 @@ dependencies { api(libs.jgit.agent) { exclude(group = "*", module = "sshd-osgi") } api(libs.eddsa) api(libs.jnafilechooser) - api(libs.xodus.vfs) - api(libs.xodus.openAPI) - api(libs.xodus.environment) - api(libs.bip39) + api(libs.colorpicker) api(libs.mixpanel) api(libs.jSerialComm) api(libs.ini4j) api(libs.restart4j) + api(libs.exposed.core) + api(libs.exposed.crypt) + api(libs.exposed.jdbc) + api(libs.sqlite) + api(libs.jug) + api(libs.semver4j) + api(libs.jsvg) + api(libs.dom4j) { exclude(group = "*", module = "*") } } application { val args = mutableListOf( - "-Xmx2g", - "-XX:+UseZGC", - "-XX:+ZUncommit", - "-XX:+ZGenerational", - "-XX:ZUncommitDelay=60", + "-Xmx2048m", + "-Drelease-date=${DateFormatUtils.format(Date(), "yyyy-MM-dd")}" ) if (os.isMacOsX) { @@ -139,7 +132,7 @@ application { args.add("-Dapple.awt.application.appearance=system") } - args.add("-Dapp-version=${project.version}") + args.add("-DTERMORA_PLUGIN_DIRECTORY=${layout.buildDirectory.get().asFile.absolutePath}${File.separator}plugins") if (os.isLinux) { args.add("-Dsun.java2d.opengl=true") @@ -153,6 +146,7 @@ publishing { publications { create("mavenJava") { from(components["java"]) + pom { name = project.name description = "Termora is a terminal emulator and SSH client for Windows, macOS and Linux" @@ -189,8 +183,10 @@ tasks.register("copy-dependencies") { from(configurations.runtimeClasspath).into(dir) val jna = libs.jna.asProvider().get() val pty4j = libs.pty4j.get() + val flatlaf = libs.flatlaf.get() val jSerialComm = libs.jSerialComm.get() val restart4j = libs.restart4j.get() + val sqlite = libs.sqlite.get() // 对 JNA 和 PTY4J 的本地库提取 // 提取出来是为了单独签名,不然无法通过公证 @@ -254,6 +250,22 @@ tasks.register("copy-dependencies") { )) { e.setExecutable(true) } + } else if ("${sqlite.name}-${sqlite.version}" == file.nameWithoutExtension) { + val targetDir = FileUtils.getFile(dylib, sqlite.name) + FileUtils.forceMkdir(targetDir) + // @formatter:off + exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "org/sqlite/native/Mac/${archName}/*", "-d", targetDir.absolutePath) } + // @formatter:on + // 删除所有二进制类库 + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/*") } + } else if ("${flatlaf.name}-${flatlaf.version}" == file.nameWithoutExtension) { + val targetDir = FileUtils.getFile(dylib, flatlaf.name) + FileUtils.forceMkdir(targetDir) + val isArm = arch.isArm + // @formatter:off + exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "com/formdev/flatlaf/natives/*macos*${if (isArm) "arm" else "x86"}*", "-d", targetDir.absolutePath) } + // @formatter:on + exec { commandLine("zip", "-d", file.absolutePath, "com/formdev/flatlaf/natives/*") } } } @@ -330,6 +342,48 @@ tasks.register("copy-dependencies") { exec { commandLine("zip", "-d", file.absolutePath, "linux/aarch64/*") } } } + } else if ("${sqlite.name}-${sqlite.version}" == file.nameWithoutExtension) { + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Linux-*") } + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/FreeBSD/*") } + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Mac/*") } + if (os.isWindows) { + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Linux/*") } + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Windows/armv7/*") } + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Windows/x86/*") } + if (arch.isArm) { + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Windows/x86_64/*") } + } else { + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Windows/aarch64/*") } + } + } else if (os.isLinux) { + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Windows/*") } + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Linux/arm*") } + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Linux/ppc64/*") } + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Linux/riscv64/*") } + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Linux/x86/*") } + if (arch.isArm) { + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Linux/x86_64/*") } + } else { + exec { commandLine("zip", "-d", file.absolutePath, "org/sqlite/native/Linux/aarch64/*") } + } + } + } else if ("${flatlaf.name}-${flatlaf.version}" == file.nameWithoutExtension) { + exec { commandLine("zip", "-d", file.absolutePath, "com/formdev/flatlaf/natives/*macos*") } + if (os.isWindows) { + exec { commandLine("zip", "-d", file.absolutePath, "com/formdev/flatlaf/natives/*linux*") } + if (arch.isArm) { + exec { commandLine("zip", "-d", file.absolutePath, "com/formdev/flatlaf/natives/*x86*") } + } else { + exec { commandLine("zip", "-d", file.absolutePath, "com/formdev/flatlaf/natives/*x86.dll") } + } + } else if (os.isLinux) { + exec { commandLine("zip", "-d", file.absolutePath, "com/formdev/flatlaf/natives/*windows*") } + if (arch.isArm) { + exec { commandLine("zip", "-d", file.absolutePath, "com/formdev/flatlaf/natives/*x86*") } + } else { + exec { commandLine("zip", "-d", file.absolutePath, "com/formdev/flatlaf/natives/*arm*") } + } + } } } } @@ -343,6 +397,7 @@ tasks.register("jlink") { "java.logging", "java.management", "java.rmi", + "java.sql", "java.security.jgss", "jdk.crypto.ec", "jdk.unsupported", @@ -368,26 +423,23 @@ tasks.register("jpackage") { val buildDir = layout.buildDirectory.get() val options = mutableListOf( - "--add-exports java.base/sun.nio.ch=ALL-UNNAMED", - "-Xmx2g", - "-XX:+UseZGC", - "-XX:+ZUncommit", - "-XX:+ZGenerational", - "-XX:ZUncommitDelay=60", + "-Xmx2048m", "-XX:+HeapDumpOnOutOfMemoryError", "-Dlogger.console.level=off", "-Dkotlinx.coroutines.debug=off", "-Dapp-version=${project.version}", + "-Drelease-date=${DateFormatUtils.format(Date(), "yyyy-MM-dd")}", + "--add-exports java.base/sun.nio.ch=ALL-UNNAMED", ) options.add("-Dsun.java2d.metal=true") if (os.isMacOsX) { // NSWindow + options.add("-Dapple.awt.application.appearance=system") options.add("--add-opens java.desktop/java.awt=ALL-UNNAMED") options.add("--add-opens java.desktop/sun.lwawt=ALL-UNNAMED") options.add("--add-opens java.desktop/sun.lwawt.macosx=ALL-UNNAMED") - options.add("-Dapple.awt.application.appearance=system") options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED") options.add("--add-exports java.desktop/com.apple.eawt=ALL-UNNAMED") } @@ -399,7 +451,7 @@ tasks.register("jpackage") { val arguments = mutableListOf("${Jvm.current().javaHome}/bin/jpackage") arguments.addAll(listOf("--runtime-image", "${buildDir}/jlink")) arguments.addAll(listOf("--name", project.name.uppercaseFirstChar())) - arguments.addAll(listOf("--app-version", "${project.version}")) + arguments.addAll(listOf("--app-version", appVersion.toString())) arguments.addAll(listOf("--main-jar", tasks.jar.get().archiveFileName.get())) arguments.addAll(listOf("--main-class", application.mainClass.get())) arguments.addAll(listOf("--input", "$buildDir/libs")) @@ -408,6 +460,7 @@ tasks.register("jpackage") { arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE))) arguments.addAll(listOf("--vendor", "TermoraDev")) arguments.addAll(listOf("--copyright", "TermoraDev")) + arguments.addAll(listOf("--app-content", "$buildDir/plugins")) if (os.isWindows) { arguments.addAll( @@ -470,20 +523,22 @@ tasks.register("dist") { // 清空目录 exec { commandLine(gradlew, "clean") } + // 构建自带的插件 + exec { commandLine(gradlew, ":plugins:migration:build") } + // 打包并复制依赖 exec { - commandLine(gradlew, "jar", "copy-dependencies") - environment("ENABLE_BUILD" to true) + commandLine(gradlew, ":jar", ":copy-dependencies") } // 检查依赖的开源协议 - exec { commandLine(gradlew, "check-license") } + exec { commandLine(gradlew, ":check-license") } // jlink - exec { commandLine(gradlew, "jlink") } + exec { commandLine(gradlew, ":jlink") } // 打包 - exec { commandLine(gradlew, "jpackage") } + exec { commandLine(gradlew, ":jpackage") } // 根据不同的系统构建不同的二进制包 pack() @@ -558,7 +613,7 @@ fun packOnWindows(distributionDir: Directory, finalFilenameWithoutExtension: Str "iscc", "/DMyAppId=${projectName}", "/DMyAppName=${projectName}", - "/DMyAppVersion=${project.version}", + "/DMyAppVersion=${appVersion}", "/DMyOutputDir=${distributionDir.asFile.absolutePath}", "/DMySetupIconFile=${FileUtils.getFile(projectDir, "src", "main", "resources", "icons", "termora.ico")}", "/DMySourceDir=${layout.buildDirectory.dir("jpackage/images/win-msi.image/${projectName}").get().asFile}", @@ -571,7 +626,7 @@ fun packOnWindows(distributionDir: Directory, finalFilenameWithoutExtension: Str exec { commandLine( "cmd", "/c", "move", - "${projectName}-${project.version}.msi", + "${projectName}-${appVersion}.msi", "${finalFilenameWithoutExtension}.msi" ) workingDir = distributionDir.asFile @@ -587,7 +642,7 @@ fun packOnMac(distributionDir: Directory, finalFilenameWithoutExtension: String, // rename // @formatter:off - exec { commandLine("mv", distributionDir.file("${projectName}-${project.version}.dmg").asFile.absolutePath, dmgFile.absolutePath,) } + exec { commandLine("mv", distributionDir.file("${projectName}-${appVersion}.dmg").asFile.absolutePath, dmgFile.absolutePath,) } // @formatter:on // sign dmg @@ -769,6 +824,10 @@ kotlin { } } +java { + withSourcesJar() +} + idea { module { isDownloadJavadoc = true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c02f0fa..c24e25f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ slf4j = "2.0.17" pty4j = "0.13.6" tinylog = "2.7.0" kotlinx-coroutines = "1.10.2" -flatlaf = "3.6" +flatlaf = "3.7-SNAPSHOT" kotlinx-serialization-json = "1.8.1" commons-codec = "1.18.0" commons-lang3 = "3.17.0" @@ -12,7 +12,7 @@ commons-csv = "1.14.0" commons-net = "3.11.1" commons-text = "1.13.1" commons-compress = "1.27.1" -commons-vfs2="2.10.0" +commons-vfs2 = "2.10.0" swingx = "1.6.5-1" jgoodies-forms = "1.9.0" jfa = "1.2.0" @@ -41,6 +41,13 @@ jSerialComm = "2.11.0" ini4j = "0.5.5-2" restart4j = "0.0.1" eddsa = "0.3.0" +exposed = "1.0.0-beta-1" +h2 = "2.3.232" +sqlite = "3.49.1.0" +jug = "5.1.0" +semver4j = "5.7.0" +jsvg = "2.0.0" +dom4j = "2.1.4" [libraries] kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } @@ -59,9 +66,11 @@ commons-vfs2 = { group = "org.apache.commons", name = "commons-vfs2", version.re pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" } ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" } flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" } -flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" } +flatlafextras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" } +flatlafswingx = { module = "com.formdev:flatlaf-swingx", version.ref = "flatlaf" } testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "testcontainers" } testcontainers = { module = "org.testcontainers:testcontainers" } +testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter" } 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" } @@ -73,7 +82,6 @@ oshi-core = { module = "com.github.oshi:oshi-core", version.ref = "oshi" } commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" } restart4j = { module = "com.github.hstyi:restart4j", version.ref = "restart4j" } jbr-api = { module = "com.jetbrains:jbr-api", version.ref = "jbr-api" } -flatlaf-swingx = { module = "com.formdev:flatlaf-swingx", version.ref = "flatlaf" } hutool = { module = "cn.hutool:hutool-all", version.ref = "hutool" } jsch = { module = "com.github.mwiede:jsch", version.ref = "jsch" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } @@ -95,6 +103,16 @@ colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" } jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "jSerialComm" } eddsa = { module = "net.i2p.crypto:eddsa", version.ref = "eddsa" } +exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } +exposed-crypt = { module = "org.jetbrains.exposed:exposed-crypt", version.ref = "exposed" } +exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } +exposed-migration = { module = "org.jetbrains.exposed:exposed-migration", version.ref = "exposed" } +h2 = { module = "com.h2database:h2", version.ref = "h2" } +sqlite = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite" } +jug = { module = "com.fasterxml.uuid:java-uuid-generator", version.ref = "jug" } +jsvg = { module = "com.github.weisj:jsvg", version.ref = "jsvg" } +dom4j = { module = "org.dom4j:dom4j", version.ref = "dom4j" } +semver4j = { module = "org.semver4j:semver4j", version.ref = "semver4j" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 19d404b..1e2fbf0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/plugins/LICENSE b/plugins/LICENSE new file mode 100644 index 0000000..285ddb3 --- /dev/null +++ b/plugins/LICENSE @@ -0,0 +1,10 @@ +Copyright (c) 2025-present hstyi + +The files in this catalogue are for public access only. Specific descriptions are given below: + +- You may view and study the contents of these files; +- You may NOT use them for any commercial purpose; +- You may NOT modify, copy, distribute, republish, or use them to create derivative works; +- Written permission must be obtained from the author for any use beyond personal viewing. + +All rights reserved. \ No newline at end of file diff --git a/plugins/THIRDPARTY b/plugins/THIRDPARTY new file mode 100644 index 0000000..4cfb657 --- /dev/null +++ b/plugins/THIRDPARTY @@ -0,0 +1,75 @@ +minio +Apache License 2.0 +https://github.com/minio/minio-java/blob/master/LICENSE + +aliyun-sdk-oss +Apache License 2.0 +https://www.apache.org/licenses/LICENSE-2.0.html + +jaxb-api +BSD 3-Clause "New" or "Revised" License +https://github.com/jakartaee/jaxb-api/blob/master/LICENSE.md + +activation +COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.1 +https://github.com/javaee/activation/blob/master/LICENSE.txt + +jaxb-runtime +BSD 3-Clause "New" or "Revised" License +https://github.com/eclipse-ee4j/jaxb-ri/blob/master/LICENSE.md + +esdk-obs-java-bundle +HUAWEI LICENSE +https://github.com/huaweicloud/huaweicloud-sdk-java-obs/blob/master/LICENSE + +xodus-compress +Apache License 2.0 +https://github.com/JetBrains/xodus/blob/master/LICENSE.txt + +xodus-environment +Apache License 2.0 +https://github.com/JetBrains/xodus/blob/master/LICENSE.txt + +xodus-openAPI +Apache License 2.0 +https://github.com/JetBrains/xodus/blob/master/LICENSE.txt + +xodus-utils +Apache License 2.0 +https://github.com/JetBrains/xodus/blob/master/LICENSE.txt + +xodus-vfs +Apache License 2.0 +https://github.com/JetBrains/xodus/blob/master/LICENSE.txt + +kotlin-bip39 +MIT License +https://github.com/Electric-Coin-Company/kotlin-bip39/blob/main/LICENSE + +commons-compress +Apache License 2.0 +https://github.com/apache/commons-compress/blob/master/LICENSE.txt + +cos_api +MIT License +https://github.com/tencentyun/cos-java-sdk-v5/blob/master/LICENSE + +AutoComplete +BSD-3-Clause license +https://github.com/bobbylight/AutoComplete/blob/master/LICENSE.md + +RSTALanguageSupport +BSD-3-Clause license +https://github.com/bobbylight/RSTALanguageSupport/blob/master/README.md + +RSyntaxTextArea +BSD-3-Clause license +https://github.com/bobbylight/RSyntaxTextArea/blob/master/LICENSE.md + +MaxMind GeoIP2 API +Apache License, Version 2.0 +https://www.apache.org/licenses/LICENSE-2.0.html + +GeoLite2 (https://www.maxmind.com) +Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) +https://creativecommons.org/licenses/by-sa/4.0/ \ No newline at end of file diff --git a/plugins/bg/build.gradle.kts b/plugins/bg/build.gradle.kts new file mode 100644 index 0000000..5c35f3c --- /dev/null +++ b/plugins/bg/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + + +project.version = "0.0.2" + + + +dependencies { + testImplementation(kotlin("test")) + compileOnly(project(":")) +} + +apply(from = "$rootDir/plugins/common.gradle.kts") + diff --git a/plugins/bg/src/main/kotlin/app/termora/plugins/bg/Appearance.kt b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/Appearance.kt new file mode 100644 index 0000000..5bfade7 --- /dev/null +++ b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/Appearance.kt @@ -0,0 +1,21 @@ +package app.termora.plugins.bg + +import app.termora.EnableManager +import app.termora.database.DatabaseManager + +object Appearance { + private val enableManager get() = EnableManager.getInstance() + private val appearance get() = DatabaseManager.getInstance().appearance + + var backgroundImage: String + get() = enableManager.getFlag("Plugins.bg.backgroundImage", appearance.backgroundImage) + set(value) { + enableManager.setFlag("Plugins.bg.backgroundImage", value) + } + + var interval: Int + get() = enableManager.getFlag("Plugins.bg.interval", 360) + set(value) { + enableManager.setFlag("Plugins.bg.interval", value) + } +} \ No newline at end of file diff --git a/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BGGlassPaneExtension.kt b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BGGlassPaneExtension.kt new file mode 100644 index 0000000..871774d --- /dev/null +++ b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BGGlassPaneExtension.kt @@ -0,0 +1,29 @@ +package app.termora.plugins.bg + +import app.termora.GlassPaneExtension +import com.formdev.flatlaf.FlatLaf +import java.awt.AlphaComposite +import java.awt.Graphics2D +import javax.swing.JComponent + +class BGGlassPaneExtension private constructor() : GlassPaneExtension { + companion object { + val instance = BGGlassPaneExtension() + } + + override fun paint( + c: JComponent, + g2d: Graphics2D + ): Boolean { + + val img = BackgroundManager.getInstance().getBackgroundImage() ?: return false + g2d.composite = AlphaComposite.getInstance( + AlphaComposite.SRC_OVER, + if (FlatLaf.isLafDark()) 0.2f else 0.1f + ) + g2d.drawImage(img, 0, 0, c.width, c.height, null) + g2d.composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER) + + return true + } +} \ No newline at end of file diff --git a/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BGI18n.kt b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BGI18n.kt new file mode 100644 index 0000000..b4a11e7 --- /dev/null +++ b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BGI18n.kt @@ -0,0 +1,26 @@ +package app.termora.plugins.bg + +import app.termora.AbstractI18n +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.util.* + +object BGI18n : AbstractI18n() { + private val log = LoggerFactory.getLogger(BGI18n::class.java) + private val myBundle by lazy { + val bundle = ResourceBundle.getBundle("i18n/messages", Locale.getDefault(), BGI18n::class.java.classLoader) + if (log.isInfoEnabled) { + log.info("I18n: {}", bundle.baseBundleName ?: "null") + } + return@lazy bundle + } + + + override fun getBundle(): ResourceBundle { + return myBundle + } + + override fun getLogger(): Logger { + return log + } +} \ No newline at end of file diff --git a/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BGPlugin.kt b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BGPlugin.kt new file mode 100644 index 0000000..4519887 --- /dev/null +++ b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BGPlugin.kt @@ -0,0 +1,36 @@ +package app.termora.plugins.bg + +import app.termora.ApplicationRunnerExtension +import app.termora.GlassPaneAwareExtension +import app.termora.GlassPaneExtension +import app.termora.SettingsOptionExtension +import app.termora.plugin.Extension +import app.termora.plugin.ExtensionSupport +import app.termora.plugin.Plugin + +class BGPlugin : Plugin { + private val support = ExtensionSupport() + + init { + support.addExtension(GlassPaneExtension::class.java) { BGGlassPaneExtension.instance } + support.addExtension(SettingsOptionExtension::class.java) { BackgroundSettingsOptionExtension.instance } + support.addExtension(ApplicationRunnerExtension::class.java) { BackgroundManager.getInstance() } + support.addExtension(GlassPaneAwareExtension::class.java) { BackgroundManager.getInstance() } + } + + override fun getAuthor(): String { + return "TermoraDev" + } + + + override fun getName(): String { + return "Customize Background" + } + + + override fun getExtensions(clazz: Class): List { + return support.getExtensions(clazz) + } + + +} \ No newline at end of file diff --git a/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BackgroundManager.kt b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BackgroundManager.kt new file mode 100644 index 0000000..f70ce35 --- /dev/null +++ b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BackgroundManager.kt @@ -0,0 +1,167 @@ +package app.termora.plugins.bg + +import app.termora.* +import app.termora.database.DatabaseManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import okhttp3.Request +import org.apache.commons.io.FileUtils +import org.apache.commons.io.IOUtils +import org.apache.commons.lang3.StringUtils +import org.slf4j.LoggerFactory +import java.awt.Window +import java.awt.image.BufferedImage +import java.io.File +import java.lang.ref.WeakReference +import javax.imageio.ImageIO +import javax.swing.JComponent +import javax.swing.JPopupMenu +import javax.swing.SwingUtilities +import kotlin.math.max +import kotlin.time.Duration.Companion.seconds + +internal class BackgroundManager private constructor() : Disposable, GlassPaneAwareExtension, + ApplicationRunnerExtension { + companion object { + private val log = LoggerFactory.getLogger(BackgroundManager::class.java) + fun getInstance(): BackgroundManager { + return ApplicationScope.Companion.forApplicationScope() + .getOrCreate(BackgroundManager::class) { BackgroundManager() } + } + } + + private var bufferedImage: BufferedImage? = null + private var imageFilepath = StringUtils.EMPTY + private val glassPanes = mutableListOf>() + + + fun setBackgroundImage(url: String) { + clearBackgroundImage() + Appearance.backgroundImage = url + refreshBackgroundImage() + } + + fun getBackgroundImage(): BufferedImage? { + val bg = doGetBackgroundImage() + if (bg == null) { + if (JPopupMenu.getDefaultLightWeightPopupEnabled()) { + return null + } else { + JPopupMenu.setDefaultLightWeightPopupEnabled(true) + } + } else { + if (JPopupMenu.getDefaultLightWeightPopupEnabled()) { + JPopupMenu.setDefaultLightWeightPopupEnabled(false) + } + } + return bg + } + + private fun doGetBackgroundImage(): BufferedImage? { + synchronized(this) { + return bufferedImage + } + } + + fun clearBackgroundImage() { + synchronized(this) { + bufferedImage = null + imageFilepath = StringUtils.EMPTY + Appearance.backgroundImage = StringUtils.EMPTY + } + refreshGlassPanes() + } + + private fun refreshBackgroundImage() { + val backgroundImage = Appearance.backgroundImage + if (backgroundImage.isBlank()) { + return + } + + var file: File? = null + + // 从网络下载 + if (backgroundImage.startsWith("http://") || backgroundImage.startsWith("https://")) { + file = Application.httpClient.newCall( + Request.Builder().get() + .url(backgroundImage).build() + ).execute().use { response -> + val tempFile = File(Application.getTemporaryDir(), randomUUID()) + if (response.isSuccessful.not()) { + if (log.isErrorEnabled) { + log.error("Request {} failed with code {}", backgroundImage, response.code) + } + return + } + val body = response.body + if (body != null) { + tempFile.outputStream().use { IOUtils.copy(body.byteStream(), it) } + } + IOUtils.closeQuietly(body) + return@use tempFile + } + } + + val backgroundImageFile = File(backgroundImage) + if (backgroundImageFile.isDirectory) { + val files = FileUtils.listFiles(backgroundImageFile, arrayOf("png", "jpg", "jpeg"), false) + if (files.isNotEmpty()) { + for (i in 0 until files.size) { + file = files.randomOrNull() + if (file == null) break + if (file.absolutePath == imageFilepath) continue + } + } else { + synchronized(this) { + imageFilepath = StringUtils.EMPTY + bufferedImage = null + refreshGlassPanes() + } + } + } else if (backgroundImageFile.isFile) { + file = backgroundImageFile + } + + if (file == null || imageFilepath == file.absolutePath) { + return + } + + bufferedImage = file.inputStream().use { ImageIO.read(it) } + imageFilepath = file.absolutePath + + refreshGlassPanes() + } + + private fun refreshGlassPanes() { + SwingUtilities.invokeLater { + glassPanes.removeIf { + val glassPane = it.get() + glassPane?.repaint() + glassPane == null + } + } + } + + override fun dispose() { + + } + + override fun setGlassPane(window: Window, glassPane: JComponent) { + glassPanes.add(WeakReference(glassPane)) + } + + override fun ready() { + swingCoroutineScope.launch(Dispatchers.IO) { + while (isActive) { + runCatching { refreshBackgroundImage() }.onFailure { + if (log.isErrorEnabled) { + log.error("Refresh failed", it) + } + } + delay(max(Appearance.interval, 30).seconds) + } + } + } +} \ No newline at end of file diff --git a/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BackgroundOption.kt b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BackgroundOption.kt new file mode 100644 index 0000000..7cacd9c --- /dev/null +++ b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BackgroundOption.kt @@ -0,0 +1,152 @@ +package app.termora.plugins.bg + +import app.termora.* +import app.termora.OptionsPane.Companion.FORM_MARGIN +import app.termora.database.DatabaseManager +import app.termora.nv.FileChooser +import com.formdev.flatlaf.extras.components.FlatButton +import com.formdev.flatlaf.extras.components.FlatTextPane +import com.jgoodies.forms.builder.FormBuilder +import com.jgoodies.forms.layout.FormLayout +import org.apache.commons.io.FileUtils +import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.exception.ExceptionUtils +import org.slf4j.LoggerFactory +import java.awt.BorderLayout +import java.io.File +import java.nio.file.StandardCopyOption +import javax.swing.* +import javax.swing.event.DocumentEvent + +class BackgroundOption : JPanel(BorderLayout()), OptionsPane.PluginOption { + companion object { + private val log = LoggerFactory.getLogger(BackgroundOption::class.java) + } + + private val owner get() = SwingUtilities.getWindowAncestor(this) + + val backgroundImageTextField = OutlineTextField() + val intervalSpinner = NumberSpinner(360, minimum = 30, maximum = 86400) + + private val backgroundButton = JButton(Icons.folder) + private val backgroundClearButton = FlatButton() + + + init { + initView() + initEvents() + } + + private fun initView() { + + backgroundImageTextField.isEditable = false + backgroundImageTextField.trailingComponent = backgroundButton + backgroundImageTextField.text = Appearance.backgroundImage + backgroundImageTextField.document.addDocumentListener(object : DocumentAdaptor() { + override fun changedUpdate(e: DocumentEvent) { + backgroundClearButton.isEnabled = backgroundImageTextField.text.isNotBlank() + } + }) + + backgroundClearButton.isFocusable = false + backgroundClearButton.isEnabled = backgroundImageTextField.text.isNotBlank() + backgroundClearButton.icon = Icons.delete + backgroundClearButton.buttonType = FlatButton.ButtonType.toolBarButton + + intervalSpinner.value = Appearance.interval + + add(getFormPanel(), BorderLayout.CENTER) + } + + private fun initEvents() { + backgroundButton.addActionListener { + val chooser = FileChooser() + chooser.osxAllowedFileTypes = listOf("png", "jpg", "jpeg") + chooser.allowsMultiSelection = false + chooser.win32Filters.add(Pair("Image files", listOf("png", "jpg", "jpeg"))) + chooser.fileSelectionMode = JFileChooser.FILES_AND_DIRECTORIES + chooser.showOpenDialog(owner).thenAccept { + if (it.isNotEmpty()) { + onSelectedBackgroundImage(it.first()) + } + } + } + + backgroundClearButton.addActionListener { + BackgroundManager.getInstance().clearBackgroundImage() + backgroundImageTextField.text = StringUtils.EMPTY + } + + intervalSpinner.addChangeListener { + val value = intervalSpinner.value + if (value is Int) { + Appearance.interval = value + } + } + } + + private fun onSelectedBackgroundImage(file: File) { + try { + if (file.isFile) { + val destFile = FileUtils.getFile(Application.getBaseDataDir(), "background", file.name) + FileUtils.forceMkdirParent(destFile) + FileUtils.deleteQuietly(destFile) + FileUtils.copyFile(file, destFile, StandardCopyOption.REPLACE_EXISTING) + BackgroundManager.getInstance().setBackgroundImage(destFile.absolutePath) + } else if (file.isDirectory) { + BackgroundManager.getInstance().setBackgroundImage(file.absolutePath) + } + backgroundImageTextField.text = file.absolutePath + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + SwingUtilities.invokeLater { + OptionPane.showMessageDialog( + owner, + ExceptionUtils.getRootCauseMessage(e), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + } + } + + override fun getIcon(isSelected: Boolean): Icon { + return Icons.imageGray + } + + override fun getTitle(): String { + return BGI18n.getString("termora.plugins.bg.background-image") + } + + override fun getJComponent(): JComponent { + return this + } + + + private fun getFormPanel(): JPanel { + val layout = FormLayout( + "left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, default", + "pref, $FORM_MARGIN, pref" + ) + + var rows = 1 + val step = 2 + val builder = FormBuilder.create().layout(layout) + val bgClearBox = Box.createHorizontalBox() + bgClearBox.add(backgroundClearButton) + + builder.add("${BGI18n.getString("termora.plugins.bg.background-image")}:").xy(1, rows) + .add(backgroundImageTextField).xy(3, rows) + .add(bgClearBox).xy(5, rows) + .apply { rows += step } + + builder.add("${BGI18n.getString("termora.plugins.bg.interval")}:").xy(1, rows) + .add(intervalSpinner).xy(3, rows) + .apply { rows += step } + + + return builder.build() + } + +} \ No newline at end of file diff --git a/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BackgroundSettingsOptionExtension.kt b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BackgroundSettingsOptionExtension.kt new file mode 100644 index 0000000..809e107 --- /dev/null +++ b/plugins/bg/src/main/kotlin/app/termora/plugins/bg/BackgroundSettingsOptionExtension.kt @@ -0,0 +1,14 @@ +package app.termora.plugins.bg + +import app.termora.OptionsPane +import app.termora.SettingsOptionExtension + +class BackgroundSettingsOptionExtension private constructor(): SettingsOptionExtension { + companion object { + val instance by lazy { BackgroundSettingsOptionExtension() } + } + + override fun createSettingsOption(): OptionsPane.Option { + return BackgroundOption() + } +} \ No newline at end of file diff --git a/plugins/bg/src/main/resources/META-INF/plugin.xml b/plugins/bg/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..c429cb5 --- /dev/null +++ b/plugins/bg/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,23 @@ + + + bg + + Customize Background + + ${projectVersion} + + app.termora.plugins.bg.BGPlugin + + + + + + Customize application background + 自定义应用程序背景 + 自訂應用程式背景 + + + TermoraDev + + + diff --git a/plugins/bg/src/main/resources/META-INF/pluginIcon.svg b/plugins/bg/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..1850417 --- /dev/null +++ b/plugins/bg/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/bg/src/main/resources/META-INF/pluginIcon_dark.svg b/plugins/bg/src/main/resources/META-INF/pluginIcon_dark.svg new file mode 100644 index 0000000..27d7271 --- /dev/null +++ b/plugins/bg/src/main/resources/META-INF/pluginIcon_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/bg/src/main/resources/i18n/messages.properties b/plugins/bg/src/main/resources/i18n/messages.properties new file mode 100644 index 0000000..58f334c --- /dev/null +++ b/plugins/bg/src/main/resources/i18n/messages.properties @@ -0,0 +1,2 @@ +termora.plugins.bg.interval=Interval +termora.plugins.bg.background-image=Background Image diff --git a/plugins/bg/src/main/resources/i18n/messages_zh_CN.properties b/plugins/bg/src/main/resources/i18n/messages_zh_CN.properties new file mode 100644 index 0000000..5755aa2 --- /dev/null +++ b/plugins/bg/src/main/resources/i18n/messages_zh_CN.properties @@ -0,0 +1,2 @@ +termora.plugins.bg.background-image=背景图 +termora.plugins.bg.interval=切换间隔 diff --git a/plugins/bg/src/main/resources/i18n/messages_zh_TW.properties b/plugins/bg/src/main/resources/i18n/messages_zh_TW.properties new file mode 100644 index 0000000..b8be3ce --- /dev/null +++ b/plugins/bg/src/main/resources/i18n/messages_zh_TW.properties @@ -0,0 +1,2 @@ +termora.plugins.bg.background-image=背景圖 +termora.plugins.bg.interval=切換間隔 diff --git a/plugins/common.gradle.kts b/plugins/common.gradle.kts new file mode 100644 index 0000000..6d0708c --- /dev/null +++ b/plugins/common.gradle.kts @@ -0,0 +1,89 @@ +import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform + + +tasks.withType { + + manifest { + attributes( + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } + + from("${rootProject.projectDir}/plugins/LICENSE") { + into("META-INF") + } + + from("${rootProject.projectDir}/plugins/THIRDPARTY") { + into("META-INF") + } + + // archiveBaseName.set("${project.name}-${rootProject.version}") + destinationDirectory.set(file("${rootProject.layout.buildDirectory.get().asFile.absolutePath}/plugins/${project.name}")) +} + +tasks.named("processResources") { + filesMatching("META-INF/plugin.xml") { + expand( + "projectName" to project.name, + "projectVersion" to project.version, + "rootProjectVersion" to rootProject.version, + ) + } +} + +tasks.register("copy-dependencies") { + from(configurations.getByName("runtimeClasspath").filterNot { + it.name.startsWith("kotlin-stdlib") || it.name.startsWith("annotations") + }) + into("${rootProject.layout.buildDirectory.get().asFile.absolutePath}/plugins/${project.name}") +} + +tasks.named("build") { + dependsOn("copy-dependencies") +} + +tasks.register("run-plugin") { + dependsOn("build") + + doLast { + val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem() + + val runtimeCompileOnly by configurations.creating { extendsFrom(configurations.getByName("compileOnly")) } + val mainClass = "app.termora.MainKt" + val executable = System.getProperty("java.home") + "/bin/java" + val classpath = (configurations.getByName("compileClasspath") + configurations.getByName("runtimeClasspath") + + runtimeCompileOnly).joinToString(if (os.isWindows) ";" else ":") + val commands = mutableListOf(executable) + commands.add("-Dapp-version=${rootProject.version}") + commands.add("--add-exports java.base/sun.nio.ch=ALL-UNNAMED") + if (os.isMacOsX) { + // NSWindow + commands.add("--add-opens java.desktop/java.awt=ALL-UNNAMED") + commands.add("--add-opens java.desktop/sun.lwawt=ALL-UNNAMED") + commands.add("--add-opens java.desktop/sun.lwawt.macosx=ALL-UNNAMED") + commands.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED") + commands.add("--add-exports java.desktop/com.apple.eawt=ALL-UNNAMED") + commands.add("-Dapple.awt.application.appearance=system") + } + commands.addAll(listOf("-cp", classpath, mainClass)) + + exec { + commandLine = commands + environment( + "TERMORA_PLUGIN_DIRECTORY" to file("${rootProject.layout.buildDirectory.get().asFile.absolutePath}/plugins/"), + "TERMORA_BASE_DATA_DIR" to "${layout.buildDirectory.get().asFile.absolutePath}/data", + ) + } + } +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +tasks.named("clean") { + doLast { + file("${rootProject.layout.buildDirectory.get().asFile.absolutePath}/plugins/${project.name}").deleteRecursively() + } +} \ No newline at end of file diff --git a/plugins/cos/build.gradle.kts b/plugins/cos/build.gradle.kts new file mode 100644 index 0000000..ce50d6e --- /dev/null +++ b/plugins/cos/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +project.version = "0.0.1" + + + +dependencies { + testImplementation(kotlin("test")) + implementation("com.qcloud:cos_api:5.6.245") + compileOnly(project(":")) +} + + +apply(from = "$rootDir/plugins/common.gradle.kts") diff --git a/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSFileProvider.kt b/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSFileProvider.kt new file mode 100644 index 0000000..7817e72 --- /dev/null +++ b/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSFileProvider.kt @@ -0,0 +1,41 @@ +package app.termora.plugins.cos + +import org.apache.commons.vfs2.Capability +import org.apache.commons.vfs2.FileName +import org.apache.commons.vfs2.FileSystem +import org.apache.commons.vfs2.FileSystemOptions +import org.apache.commons.vfs2.provider.AbstractOriginatingFileProvider + +class COSFileProvider private constructor() : AbstractOriginatingFileProvider() { + + companion object { + val instance by lazy { COSFileProvider() } + val capabilities = listOf( + Capability.CREATE, + Capability.DELETE, + Capability.RENAME, + Capability.GET_TYPE, + Capability.LIST_CHILDREN, + Capability.READ_CONTENT, + Capability.URI, + Capability.WRITE_CONTENT, + Capability.GET_LAST_MODIFIED, + Capability.SET_LAST_MODIFIED_FILE, + Capability.RANDOM_ACCESS_READ, + Capability.APPEND_CONTENT + ) + } + + override fun getCapabilities(): Collection { + return COSFileProvider.capabilities + } + + override fun doCreateFileSystem( + rootFileName: FileName, + fileSystemOptions: FileSystemOptions + ): FileSystem? { + TODO("Not yet implemented") + } + + +} \ No newline at end of file diff --git a/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSPlugin.kt b/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSPlugin.kt new file mode 100644 index 0000000..0e71228 --- /dev/null +++ b/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSPlugin.kt @@ -0,0 +1,36 @@ +package app.termora.plugins.cos + +import app.termora.DynamicIcon +import app.termora.I18n +import app.termora.Icons +import app.termora.plugin.Extension +import app.termora.plugin.ExtensionSupport +import app.termora.plugin.PaidPlugin +import app.termora.protocol.ProtocolHostPanelExtension +import app.termora.protocol.ProtocolProviderExtension + +class COSPlugin : PaidPlugin { + private val support = ExtensionSupport() + + init { + support.addExtension(ProtocolProviderExtension::class.java) { COSProtocolProviderExtension.Companion.instance } + support.addExtension(ProtocolHostPanelExtension::class.java) { COSProtocolHostPanelExtension.Companion.instance } + } + + override fun getAuthor(): String { + return "TermoraDev" + } + + + override fun getName(): String { + return "Tencent COS" + } + + + + override fun getExtensions(clazz: Class): List { + return support.getExtensions(clazz) + } + + +} \ No newline at end of file diff --git a/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSProtocolHostPanel.kt b/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSProtocolHostPanel.kt new file mode 100644 index 0000000..c7e11fa --- /dev/null +++ b/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSProtocolHostPanel.kt @@ -0,0 +1,22 @@ +package app.termora.plugins.cos + +import app.termora.Host +import app.termora.protocol.ProtocolHostPanel +import org.apache.commons.lang3.StringUtils + +class COSProtocolHostPanel : ProtocolHostPanel() { + override fun getHost(): Host { + return Host( + name = StringUtils.EMPTY, + protocol = COSProtocolProvider.Companion.PROTOCOL + ) + } + + override fun setHost(host: Host) { + + } + + override fun validateFields(): Boolean { + return true + } +} \ No newline at end of file diff --git a/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSProtocolHostPanelExtension.kt b/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSProtocolHostPanelExtension.kt new file mode 100644 index 0000000..61aa7f0 --- /dev/null +++ b/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSProtocolHostPanelExtension.kt @@ -0,0 +1,19 @@ +package app.termora.plugins.cos + +import app.termora.protocol.ProtocolHostPanel +import app.termora.protocol.ProtocolHostPanelExtension +import app.termora.protocol.ProtocolProvider + +class COSProtocolHostPanelExtension private constructor() : ProtocolHostPanelExtension { + companion object { + val instance by lazy { COSProtocolHostPanelExtension() } + } + + override fun getProtocolProvider(): ProtocolProvider { + return COSProtocolProvider.Companion.instance + } + + override fun createProtocolHostPanel(): ProtocolHostPanel { + return COSProtocolHostPanel() + } +} \ No newline at end of file diff --git a/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSProtocolProvider.kt b/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSProtocolProvider.kt new file mode 100644 index 0000000..410deca --- /dev/null +++ b/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSProtocolProvider.kt @@ -0,0 +1,33 @@ +package app.termora.plugins.cos + +import app.termora.DynamicIcon +import app.termora.Icons +import app.termora.protocol.FileObjectHandler +import app.termora.protocol.FileObjectRequest +import app.termora.protocol.TransferProtocolProvider +import org.apache.commons.vfs2.provider.FileProvider + +class COSProtocolProvider private constructor() : TransferProtocolProvider { + + companion object { + val instance by lazy { COSProtocolProvider() } + const val PROTOCOL = "COS" + } + + override fun getProtocol(): String { + return PROTOCOL + } + + override fun getIcon(width: Int, height: Int): DynamicIcon { + return Icons.tencent + } + + override fun getFileProvider(): FileProvider { + return COSFileProvider.instance + } + + override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler { + TODO("Not yet implemented") + } + +} \ No newline at end of file diff --git a/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSProtocolProviderExtension.kt b/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSProtocolProviderExtension.kt new file mode 100644 index 0000000..ee3bc35 --- /dev/null +++ b/plugins/cos/src/main/kotlin/app/termora/plugins/cos/COSProtocolProviderExtension.kt @@ -0,0 +1,14 @@ +package app.termora.plugins.cos + +import app.termora.protocol.ProtocolProvider +import app.termora.protocol.ProtocolProviderExtension + +class COSProtocolProviderExtension private constructor() : ProtocolProviderExtension { + companion object { + val instance by lazy { COSProtocolProviderExtension() } + } + + override fun getProtocolProvider(): ProtocolProvider { + return COSProtocolProvider.Companion.instance + } +} \ No newline at end of file diff --git a/plugins/cos/src/main/resources/META-INF/plugin.xml b/plugins/cos/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..8dc8d73 --- /dev/null +++ b/plugins/cos/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,25 @@ + + + cos + + Tencent COS + + + + + ${projectVersion} + + + + app.termora.plugins.cos.COSPlugin + + + Connecting to Tencent COS + 支持连接到腾讯云对象存储 + 支援連接到騰訊雲物件存儲 + + + TermoraDev + + + diff --git a/plugins/cos/src/main/resources/META-INF/pluginIcon.svg b/plugins/cos/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..905f584 --- /dev/null +++ b/plugins/cos/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/cos/src/main/resources/META-INF/pluginIcon_dark.svg b/plugins/cos/src/main/resources/META-INF/pluginIcon_dark.svg new file mode 100644 index 0000000..05a97e2 --- /dev/null +++ b/plugins/cos/src/main/resources/META-INF/pluginIcon_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/editor/build.gradle.kts b/plugins/editor/build.gradle.kts new file mode 100644 index 0000000..35ee3f3 --- /dev/null +++ b/plugins/editor/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + + + +project.version = "0.0.3" + + +dependencies { + testImplementation(kotlin("test")) + compileOnly(project(":")) + implementation("com.fifesoft:rsyntaxtextarea:3.6.0") + implementation("com.fifesoft:languagesupport:3.3.0") + implementation("com.fifesoft:autocomplete:3.3.2") +} + +apply(from = "$rootDir/plugins/common.gradle.kts") + diff --git a/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorDialog.kt b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorDialog.kt new file mode 100644 index 0000000..cf12446 --- /dev/null +++ b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorDialog.kt @@ -0,0 +1,74 @@ +package app.termora.plugins.editor + +import app.termora.DialogWrapper +import app.termora.Disposable +import app.termora.Disposer +import app.termora.OptionPane +import app.termora.sftp.absolutePathString +import org.apache.commons.vfs2.FileObject +import java.awt.Dimension +import java.awt.Window +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import java.io.File +import javax.swing.JComponent +import javax.swing.JOptionPane +import javax.swing.UIManager + + +class EditorDialog(file: FileObject, owner: Window, myDisposable: Disposable) : DialogWrapper(null) { + + private val filename = file.name.baseName + private val filepath = File(file.absolutePathString()) + private val editorPanel = EditorPanel(this, filepath) + + init { + Disposer.register(disposable, myDisposable) + + size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height")) + isModal = false + controlsVisible = true + isResizable = true + title = filename + iconImages = owner.iconImages + escapeDispose = false + defaultCloseOperation = DO_NOTHING_ON_CLOSE + + initEvents() + + setLocationRelativeTo(owner) + + init() + } + + + private fun initEvents() { + addWindowListener(object : WindowAdapter() { + override fun windowClosing(e: WindowEvent?) { + doCancelAction() + } + }) + } + + override fun doCancelAction() { + if (editorPanel.changes()) { + if (OptionPane.showConfirmDialog( + this, + "文件尚未保存,你确定要退出吗?", + optionType = JOptionPane.OK_CANCEL_OPTION, + ) != JOptionPane.OK_OPTION + ) { + return + } + } + super.doCancelAction() + } + + override fun createCenterPanel(): JComponent { + return editorPanel + } + + override fun createSouthPanel(): JComponent? { + return null + } +} \ No newline at end of file diff --git a/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorPanel.kt b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorPanel.kt new file mode 100644 index 0000000..bd99e58 --- /dev/null +++ b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorPanel.kt @@ -0,0 +1,225 @@ +package app.termora.plugins.editor + +import app.termora.DocumentAdaptor +import app.termora.DynamicColor +import app.termora.Icons +import app.termora.database.DatabaseManager +import com.formdev.flatlaf.FlatLaf +import com.formdev.flatlaf.extras.components.FlatTextField +import com.formdev.flatlaf.extras.components.FlatToolBar +import org.apache.commons.io.FilenameUtils +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea +import org.fife.ui.rsyntaxtextarea.SyntaxConstants +import org.fife.ui.rsyntaxtextarea.Theme +import org.fife.ui.rtextarea.RTextScrollPane +import org.fife.ui.rtextarea.SearchContext +import org.fife.ui.rtextarea.SearchEngine +import java.awt.BorderLayout +import java.awt.Insets +import java.awt.event.ActionEvent +import java.awt.event.KeyEvent +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import java.io.File +import javax.swing.* +import javax.swing.event.DocumentEvent +import kotlin.math.max + +class EditorPanel(private val window: JDialog, private val file: File) : JPanel(BorderLayout()) { + private var text = file.readText(Charsets.UTF_8) + private val layeredPane = LayeredPane() + + private val textArea = RSyntaxTextArea() + private val scrollPane = RTextScrollPane(textArea) + private val findPanel = FlatToolBar().apply { isFloatable = false } + private val searchTextField = FlatTextField() + private val closeFindPanelBtn = JButton(Icons.close) + private val nextBtn = JButton(Icons.down) + private val prevBtn = JButton(Icons.up) + private val context = SearchContext() + + init { + initView() + initEvents() + } + + + private fun initView() { + textArea.font = textArea.font.deriveFont(DatabaseManager.getInstance().terminal.fontSize.toFloat()) + textArea.text = text + textArea.antiAliasingEnabled = true + + val theme = if (FlatLaf.isLafDark()) + Theme.load(javaClass.getResourceAsStream("/org/fife/ui/rsyntaxtextarea/themes/dark.xml")) + else + Theme.load(javaClass.getResourceAsStream("/org/fife/ui/rsyntaxtextarea/themes/idea.xml")) + + theme.apply(textArea) + + val extension = FilenameUtils.getExtension(file.name)?.lowercase() + textArea.syntaxEditingStyle = when (extension) { + "java" -> SyntaxConstants.SYNTAX_STYLE_JAVA + "kt" -> SyntaxConstants.SYNTAX_STYLE_KOTLIN + "properties" -> SyntaxConstants.SYNTAX_STYLE_PROPERTIES_FILE + "cpp", "c++" -> SyntaxConstants.SYNTAX_STYLE_CPLUSPLUS + "c" -> SyntaxConstants.SYNTAX_STYLE_C + "cs" -> SyntaxConstants.SYNTAX_STYLE_CSHARP + "css" -> SyntaxConstants.SYNTAX_STYLE_CSS + "html", "htm", "htmlx" -> SyntaxConstants.SYNTAX_STYLE_HTML + "js" -> SyntaxConstants.SYNTAX_STYLE_JAVASCRIPT + "ts" -> SyntaxConstants.SYNTAX_STYLE_TYPESCRIPT + "xml", "svg" -> SyntaxConstants.SYNTAX_STYLE_XML + "yaml", "yml" -> SyntaxConstants.SYNTAX_STYLE_YAML + "sh", "shell" -> SyntaxConstants.SYNTAX_STYLE_UNIX_SHELL + "sql" -> SyntaxConstants.SYNTAX_STYLE_SQL + "bat" -> SyntaxConstants.SYNTAX_STYLE_WINDOWS_BATCH + "py" -> SyntaxConstants.SYNTAX_STYLE_PYTHON + "php" -> SyntaxConstants.SYNTAX_STYLE_PHP + "lua" -> SyntaxConstants.SYNTAX_STYLE_LUA + "less" -> SyntaxConstants.SYNTAX_STYLE_LESS + "jsp" -> SyntaxConstants.SYNTAX_STYLE_JSP + "json" -> SyntaxConstants.SYNTAX_STYLE_JSON + "ini" -> SyntaxConstants.SYNTAX_STYLE_INI + "hosts" -> SyntaxConstants.SYNTAX_STYLE_HOSTS + "go" -> SyntaxConstants.SYNTAX_STYLE_GO + "dtd" -> SyntaxConstants.SYNTAX_STYLE_DTD + "dart" -> SyntaxConstants.SYNTAX_STYLE_DART + "csv" -> SyntaxConstants.SYNTAX_STYLE_CSV + "md" -> SyntaxConstants.SYNTAX_STYLE_MARKDOWN + else -> SyntaxConstants.SYNTAX_STYLE_NONE + } + + textArea.discardAllEdits() + + scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor) + + findPanel.isVisible = false + findPanel.isOpaque = true + findPanel.background = DynamicColor("window") + + searchTextField.background = findPanel.background + searchTextField.padding = Insets(0, 4, 0, 0) + searchTextField.border = BorderFactory.createEmptyBorder() + + findPanel.add(searchTextField) + findPanel.add(prevBtn) + findPanel.add(nextBtn) + findPanel.add(closeFindPanelBtn) + findPanel.border = BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 1, 1, 0, DynamicColor.BorderColor), + BorderFactory.createEmptyBorder(2, 2, 2, 2) + ) + + layeredPane.add(findPanel, JLayeredPane.MODAL_LAYER as Any) + layeredPane.add(scrollPane, JLayeredPane.DEFAULT_LAYER as Any) + + add(layeredPane, BorderLayout.CENTER) + } + + + private fun initEvents() { + + window.addWindowListener(object : WindowAdapter() { + override fun windowOpened(e: WindowEvent?) { + scrollPane.verticalScrollBar.value = 0 + window.removeWindowListener(this) + } + }) + + + textArea.inputMap.put( + KeyStroke.getKeyStroke(KeyEvent.VK_S, toolkit.menuShortcutKeyMaskEx), + "Save" + ) + textArea.inputMap.put( + KeyStroke.getKeyStroke(KeyEvent.VK_F, toolkit.menuShortcutKeyMaskEx), + "Find" + ) + + searchTextField.inputMap.put( + KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), + "Esc" + ) + + searchTextField.actionMap.put("Esc", object : AbstractAction("Esc") { + override fun actionPerformed(e: ActionEvent) { + textArea.clearMarkAllHighlights() + textArea.requestFocusInWindow() + findPanel.isVisible = false + } + }) + + closeFindPanelBtn.addActionListener { searchTextField.actionMap.get("Esc").actionPerformed(it) } + + textArea.actionMap.put("Save", object : AbstractAction("Save") { + override fun actionPerformed(e: ActionEvent) { + file.writeText(textArea.text, Charsets.UTF_8) + text = textArea.text + window.title = file.name + } + }) + + textArea.actionMap.put("Find", object : AbstractAction("Find") { + override fun actionPerformed(e: ActionEvent) { + findPanel.isVisible = true + searchTextField.selectAll() + searchTextField.requestFocusInWindow() + } + }) + + textArea.document.addDocumentListener(object : DocumentAdaptor() { + override fun changedUpdate(e: DocumentEvent) { + window.title = if (textArea.text.hashCode() != text.hashCode()) { + "${file.name} *" + } else { + file.name + } + } + }) + + searchTextField.document.addDocumentListener(object : DocumentAdaptor() { + override fun changedUpdate(e: DocumentEvent) { + search() + } + }) + + searchTextField.addActionListener { nextBtn.doClick(0) } + + prevBtn.addActionListener { search(false) } + nextBtn.addActionListener { search(true) } + } + + private fun search(searchForward: Boolean = true) { + textArea.clearMarkAllHighlights() + + + val text: String = searchTextField.getText() + if (text.isEmpty()) return + context.searchFor = text + context.searchForward = searchForward + context.wholeWord = false + val result = SearchEngine.find(textArea, context) + + prevBtn.isEnabled = result.markedCount > 0 + nextBtn.isEnabled = result.markedCount > 0 + + } + + fun changes() = text != textArea.text + + private inner class LayeredPane : JLayeredPane() { + override fun doLayout() { + synchronized(treeLock) { + for (c in components) { + if (c == findPanel) { + val height = max(findPanel.preferredSize.height, findPanel.height) + val x = width / 2 + c.setBounds(x, 1, width - x, height) + } else { + c.setBounds(0, 0, width, height) + } + } + } + } + } +} \ No newline at end of file diff --git a/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorPlugin.kt b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorPlugin.kt new file mode 100644 index 0000000..01a90ed --- /dev/null +++ b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorPlugin.kt @@ -0,0 +1,29 @@ +package app.termora.plugins.editor + +import app.termora.plugin.Extension +import app.termora.plugin.ExtensionSupport +import app.termora.plugin.Plugin +import app.termora.sftp.SFTPEditFileExtension + +class EditorPlugin : Plugin { + private val support = ExtensionSupport() + + init { + support.addExtension(SFTPEditFileExtension::class.java) { MySFTPEditFileExtension.instance } + } + + override fun getAuthor(): String { + return "TermoraDev" + } + + + override fun getName(): String { + return "SFTP File Editor" + } + + override fun getExtensions(clazz: Class): List { + return support.getExtensions(clazz) + } + + +} \ No newline at end of file diff --git a/plugins/editor/src/main/kotlin/app/termora/plugins/editor/MySFTPEditFileExtension.kt b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/MySFTPEditFileExtension.kt new file mode 100644 index 0000000..634df2b --- /dev/null +++ b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/MySFTPEditFileExtension.kt @@ -0,0 +1,21 @@ +package app.termora.plugins.editor + +import app.termora.Disposable +import app.termora.Disposer +import app.termora.sftp.SFTPEditFileExtension +import app.termora.sftp.absolutePathString +import org.apache.commons.vfs2.FileObject +import java.awt.Window +import javax.swing.SwingUtilities + +class MySFTPEditFileExtension private constructor() : SFTPEditFileExtension { + companion object { + val instance = MySFTPEditFileExtension() + } + + override fun edit(owner: Window, file: FileObject): Disposable { + val disposable = Disposer.newDisposable() + SwingUtilities.invokeLater { EditorDialog(file, owner, disposable).isVisible = true } + return disposable + } +} \ No newline at end of file diff --git a/plugins/editor/src/main/resources/META-INF/plugin.xml b/plugins/editor/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..b046f2a --- /dev/null +++ b/plugins/editor/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,22 @@ + + + editor + + SFTP File Editor + + ${projectVersion} + + + + app.termora.plugins.editor.EditorPlugin + + + Edit SFTP files using the built-in editor + 使用内置编辑器编辑 SFTP 文件 + 使用內建編輯器編輯 SFTP 文件 + + + TermoraDev + + + diff --git a/plugins/editor/src/main/resources/META-INF/pluginIcon.svg b/plugins/editor/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..e29b4a4 --- /dev/null +++ b/plugins/editor/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/editor/src/main/resources/META-INF/pluginIcon_dark.svg b/plugins/editor/src/main/resources/META-INF/pluginIcon_dark.svg new file mode 100644 index 0000000..3cb6130 --- /dev/null +++ b/plugins/editor/src/main/resources/META-INF/pluginIcon_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/editor/src/test/java/app/termora/plugins/editor/FindAndReplaceDemo.java b/plugins/editor/src/test/java/app/termora/plugins/editor/FindAndReplaceDemo.java new file mode 100644 index 0000000..ad0a462 --- /dev/null +++ b/plugins/editor/src/test/java/app/termora/plugins/editor/FindAndReplaceDemo.java @@ -0,0 +1,107 @@ +package app.termora.plugins.editor; + +import java.awt.*; +import java.awt.event.*; +import javax.swing.*; + +import org.fife.ui.rtextarea.*; +import org.fife.ui.rsyntaxtextarea.*; + +/** + * A simple example showing how to do search and replace in a RSyntaxTextArea. + * The toolbar isn't very user-friendly, but this is just to show you how to use + * the API.

+ * + * This example uses RSyntaxTextArea 2.5.6. + */ +public class FindAndReplaceDemo extends JFrame implements ActionListener { + + private static final long serialVersionUID = 1L; + + private RSyntaxTextArea textArea; + private JTextField searchField; + private JCheckBox regexCB; + private JCheckBox matchCaseCB; + + public FindAndReplaceDemo() { + + JPanel cp = new JPanel(new BorderLayout()); + + textArea = new RSyntaxTextArea(20, 60); + textArea.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_JAVA); + textArea.setCodeFoldingEnabled(true); + RTextScrollPane sp = new RTextScrollPane(textArea); + cp.add(sp); + + // Create a toolbar with searching options. + JToolBar toolBar = new JToolBar(); + searchField = new JTextField(30); + toolBar.add(searchField); + final JButton nextButton = new JButton("Find Next"); + nextButton.setActionCommand("FindNext"); + nextButton.addActionListener(this); + toolBar.add(nextButton); + searchField.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + nextButton.doClick(0); + } + }); + JButton prevButton = new JButton("Find Previous"); + prevButton.setActionCommand("FindPrev"); + prevButton.addActionListener(this); + toolBar.add(prevButton); + regexCB = new JCheckBox("Regex"); + toolBar.add(regexCB); + matchCaseCB = new JCheckBox("Match Case"); + toolBar.add(matchCaseCB); + cp.add(toolBar, BorderLayout.NORTH); + + setContentPane(cp); + setTitle("Find and Replace Demo"); + setDefaultCloseOperation(EXIT_ON_CLOSE); + pack(); + setLocationRelativeTo(null); + + } + + public void actionPerformed(ActionEvent e) { + + // "FindNext" => search forward, "FindPrev" => search backward + String command = e.getActionCommand(); + boolean forward = "FindNext".equals(command); + + // Create an object defining our search parameters. + SearchContext context = new SearchContext(); + String text = searchField.getText(); + if (text.length() == 0) { + return; + } + context.setSearchFor(text); + context.setMatchCase(matchCaseCB.isSelected()); + context.setRegularExpression(regexCB.isSelected()); + context.setSearchForward(forward); + context.setWholeWord(false); + + boolean found = SearchEngine.find(textArea, context).wasFound(); + if (!found) { + JOptionPane.showMessageDialog(this, "Text not found"); + } + + } + + public static void main(String[] args) { + // Start all Swing applications on the EDT. + SwingUtilities.invokeLater(new Runnable() { + public void run() { + try { + String laf = UIManager.getSystemLookAndFeelClassName(); + UIManager.setLookAndFeel(laf); + } catch (Exception e) { /* never happens */ } + FindAndReplaceDemo demo = new FindAndReplaceDemo(); + demo.setVisible(true); + demo.textArea.requestFocusInWindow(); + } + }); + } + +} \ No newline at end of file diff --git a/plugins/ftp/build.gradle.kts b/plugins/ftp/build.gradle.kts new file mode 100644 index 0000000..fe0ee89 --- /dev/null +++ b/plugins/ftp/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + + +project.version = "0.0.1" + + +dependencies { + testImplementation(kotlin("test")) + compileOnly(project(":")) +} + + +apply(from = "$rootDir/plugins/common.gradle.kts") diff --git a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPFileProvider.kt b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPFileProvider.kt new file mode 100644 index 0000000..cae71ec --- /dev/null +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPFileProvider.kt @@ -0,0 +1,41 @@ +package app.termora.plugins.ftp + +import org.apache.commons.vfs2.Capability +import org.apache.commons.vfs2.FileName +import org.apache.commons.vfs2.FileSystem +import org.apache.commons.vfs2.FileSystemOptions +import org.apache.commons.vfs2.provider.AbstractOriginatingFileProvider + +class FTPFileProvider private constructor() : AbstractOriginatingFileProvider() { + + companion object { + val instance by lazy { FTPFileProvider() } + val capabilities = listOf( + Capability.CREATE, + Capability.DELETE, + Capability.RENAME, + Capability.GET_TYPE, + Capability.LIST_CHILDREN, + Capability.READ_CONTENT, + Capability.URI, + Capability.WRITE_CONTENT, + Capability.GET_LAST_MODIFIED, + Capability.SET_LAST_MODIFIED_FILE, + Capability.RANDOM_ACCESS_READ, + Capability.APPEND_CONTENT + ) + } + + override fun getCapabilities(): Collection { + return FTPFileProvider.capabilities + } + + override fun doCreateFileSystem( + rootFileName: FileName, + fileSystemOptions: FileSystemOptions + ): FileSystem? { + TODO("Not yet implemented") + } + + +} \ No newline at end of file diff --git a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPPlugin.kt b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPPlugin.kt new file mode 100644 index 0000000..c351e5c --- /dev/null +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPPlugin.kt @@ -0,0 +1,35 @@ +package app.termora.plugins.ftp + +import app.termora.DynamicIcon +import app.termora.I18n +import app.termora.Icons +import app.termora.plugin.Extension +import app.termora.plugin.ExtensionSupport +import app.termora.plugin.PaidPlugin +import app.termora.protocol.ProtocolHostPanelExtension +import app.termora.protocol.ProtocolProviderExtension + +class FTPPlugin : PaidPlugin { + private val support = ExtensionSupport() + + init { + support.addExtension(ProtocolProviderExtension::class.java) { FTPProtocolProviderExtension.Companion.instance } + support.addExtension(ProtocolHostPanelExtension::class.java) { FTPProtocolHostPanelExtension.Companion.instance } + } + + override fun getAuthor(): String { + return "TermoraDev" + } + + + override fun getName(): String { + return "FTP" + } + + + override fun getExtensions(clazz: Class): List { + return support.getExtensions(clazz) + } + + +} \ No newline at end of file diff --git a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolHostPanel.kt b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolHostPanel.kt new file mode 100644 index 0000000..8ff5955 --- /dev/null +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolHostPanel.kt @@ -0,0 +1,22 @@ +package app.termora.plugins.ftp + +import app.termora.Host +import app.termora.protocol.ProtocolHostPanel +import org.apache.commons.lang3.StringUtils + +class FTPProtocolHostPanel : ProtocolHostPanel() { + override fun getHost(): Host { + return Host( + name = StringUtils.EMPTY, + protocol = FTPProtocolProvider.PROTOCOL + ) + } + + override fun setHost(host: Host) { + + } + + override fun validateFields(): Boolean { + return true + } +} \ No newline at end of file diff --git a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolHostPanelExtension.kt b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolHostPanelExtension.kt new file mode 100644 index 0000000..4a5bf12 --- /dev/null +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolHostPanelExtension.kt @@ -0,0 +1,19 @@ +package app.termora.plugins.ftp + +import app.termora.protocol.ProtocolHostPanel +import app.termora.protocol.ProtocolHostPanelExtension +import app.termora.protocol.ProtocolProvider + +class FTPProtocolHostPanelExtension private constructor() : ProtocolHostPanelExtension { + companion object { + val instance by lazy { FTPProtocolHostPanelExtension() } + } + + override fun getProtocolProvider(): ProtocolProvider { + return FTPProtocolProvider.instance + } + + override fun createProtocolHostPanel(): ProtocolHostPanel { + return FTPProtocolHostPanel() + } +} \ No newline at end of file diff --git a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolProvider.kt b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolProvider.kt new file mode 100644 index 0000000..df22eb1 --- /dev/null +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolProvider.kt @@ -0,0 +1,33 @@ +package app.termora.plugins.ftp + +import app.termora.DynamicIcon +import app.termora.Icons +import app.termora.protocol.FileObjectHandler +import app.termora.protocol.FileObjectRequest +import app.termora.protocol.TransferProtocolProvider +import org.apache.commons.vfs2.provider.FileProvider + +class FTPProtocolProvider private constructor() : TransferProtocolProvider { + + companion object { + val instance by lazy { FTPProtocolProvider() } + const val PROTOCOL = "FTP" + } + + override fun getProtocol(): String { + return PROTOCOL + } + + override fun getIcon(width: Int, height: Int): DynamicIcon { + return Icons.ftp + } + + override fun getFileProvider(): FileProvider { + return FTPFileProvider.instance + } + + override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler { + TODO("Not yet implemented") + } + +} \ No newline at end of file diff --git a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolProviderExtension.kt b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolProviderExtension.kt new file mode 100644 index 0000000..2f462c7 --- /dev/null +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolProviderExtension.kt @@ -0,0 +1,14 @@ +package app.termora.plugins.ftp + +import app.termora.protocol.ProtocolProvider +import app.termora.protocol.ProtocolProviderExtension + +class FTPProtocolProviderExtension private constructor() : ProtocolProviderExtension { + companion object { + val instance by lazy { FTPProtocolProviderExtension() } + } + + override fun getProtocolProvider(): ProtocolProvider { + return FTPProtocolProvider.Companion.instance + } +} \ No newline at end of file diff --git a/plugins/ftp/src/main/resources/META-INF/plugin.xml b/plugins/ftp/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..8c32a64 --- /dev/null +++ b/plugins/ftp/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,24 @@ + + + ftp + + FTP + + + + ${projectVersion} + + + + app.termora.plugins.ftp.FTPPlugin + + + Connecting to FTP + 支持连接到到 FTP + 支援連接到 FTP + + + TermoraDev + + + diff --git a/plugins/ftp/src/main/resources/META-INF/pluginIcon.svg b/plugins/ftp/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..65fcd84 --- /dev/null +++ b/plugins/ftp/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/ftp/src/main/resources/META-INF/pluginIcon_dark.svg b/plugins/ftp/src/main/resources/META-INF/pluginIcon_dark.svg new file mode 100644 index 0000000..138c864 --- /dev/null +++ b/plugins/ftp/src/main/resources/META-INF/pluginIcon_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/geo/build.gradle.kts b/plugins/geo/build.gradle.kts new file mode 100644 index 0000000..09b6364 --- /dev/null +++ b/plugins/geo/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +project.version = "0.0.1" + +dependencies { + testImplementation(kotlin("test")) + compileOnly(project(":")) + implementation("com.maxmind.geoip2:geoip2:4.3.1") +} + +apply(from = "$rootDir/plugins/common.gradle.kts") + diff --git a/plugins/geo/src/main/kotlin/app/termora/plugins/geo/Geo.kt b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/Geo.kt new file mode 100644 index 0000000..a9bbc71 --- /dev/null +++ b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/Geo.kt @@ -0,0 +1,97 @@ +package app.termora.plugins.geo + +import app.termora.Application +import app.termora.ApplicationScope +import app.termora.Disposable +import com.maxmind.db.CHMCache +import com.maxmind.geoip2.DatabaseReader +import org.apache.commons.io.FileUtils +import org.apache.commons.io.IOUtils +import org.slf4j.LoggerFactory +import java.io.File +import java.net.InetAddress +import java.util.* +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.jvm.optionals.getOrNull + + +internal class Geo private constructor() : Disposable { + companion object { + private val log = LoggerFactory.getLogger(Geo::class.java) + + fun getInstance(): Geo { + return ApplicationScope.forApplicationScope() + .getOrCreate(Geo::class) { Geo() } + } + } + + private val initialized = AtomicBoolean(false) + private var reader: DatabaseReader? = null + + private fun initialize() { + if (GeoApplicationRunnerExtension.instance.isReady().not()) return + if (isInitialized()) return + + if (initialized.compareAndSet(false, true)) { + try { + val database = getDatabaseFile() + if ((database.exists() && database.isFile).not()) { + throw IllegalStateException("${database.absolutePath} not be found") + } + val locale = Locale.getDefault().toString().replace("_", "-") + try { + reader = DatabaseReader.Builder(database) + .locales(listOf(locale, "en")) + .withCache(CHMCache()).build() + } catch (e: Exception) { + + // 打开数据失败一般都是数据文件顺坏,删除数据库 + FileUtils.deleteQuietly(database) + + // 重新下载 + GeoApplicationRunnerExtension.instance.reload() + + throw e + } + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error("Failed to initialize geo database", e) + } + initialized.set(false) + } + } + + } + + fun getDatabaseFile(): File { + val dir = FileUtils.getFile(Application.getBaseDataDir(), "config", "plugins", "geo") + return File(dir, "GeoLite2-Country.mmdb") + } + + fun country(ip: String): Country? { + try { + initialize() + + val reader = reader ?: return null + val response = reader.tryCountry(InetAddress.getByName(ip)).getOrNull() ?: return null + val isoCode = response.country.isoCode + var name = response.country.name + // 控制名称不要太长,如果太长则使用缩写。例如:United States + if (name != null && name.length > 6) name = isoCode + return Country(isoCode, name ?: isoCode) + } catch (e: Exception) { + if (log.isDebugEnabled) { + log.error("Failed to initialize geo database", e) + } + return null + } + } + + fun isInitialized(): Boolean = initialized.get() + + override fun dispose() { + IOUtils.closeQuietly(reader) + } + + data class Country(val isoCode: String, val name: String) +} \ No newline at end of file diff --git a/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoApplicationRunnerExtension.kt b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoApplicationRunnerExtension.kt new file mode 100644 index 0000000..89e5e0e --- /dev/null +++ b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoApplicationRunnerExtension.kt @@ -0,0 +1,108 @@ +package app.termora.plugins.geo + +import app.termora.Application +import app.termora.ApplicationRunnerExtension +import app.termora.randomUUID +import app.termora.swingCoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.swing.Swing +import kotlinx.coroutines.withContext +import okhttp3.Request +import org.apache.commons.io.FileUtils +import org.apache.commons.io.IOUtils +import org.slf4j.LoggerFactory +import java.io.File +import java.net.ProxySelector +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds + +class GeoApplicationRunnerExtension private constructor() : ApplicationRunnerExtension { + companion object { + private val log = LoggerFactory.getLogger(GeoApplicationRunnerExtension::class.java) + val instance = GeoApplicationRunnerExtension() + } + + private var ready = false + private val httpClient by lazy { + Application.httpClient.newBuilder() + .callTimeout(15, TimeUnit.MINUTES) + .readTimeout(10, TimeUnit.MINUTES) + .proxySelector(ProxySelector.getDefault()) + .build() + } + + override fun ready() { + + val databaseFile = Geo.getInstance().getDatabaseFile() + if (databaseFile.exists()) { + ready = true + return + } + + // 重新加载 + reload() + + } + + fun isReady() = ready + + internal fun reload() { + ready = false + + val databaseFile = Geo.getInstance().getDatabaseFile() + + swingCoroutineScope.launch(Dispatchers.IO) { + var timeout = 3 + + while (ready.not()) { + try { + FileUtils.forceMkdirParent(databaseFile) + + downloadGeoLite2(databaseFile) + + withContext(Dispatchers.Swing) { GeoHostTreeShowMoreEnableExtension.instance.updateComponentTreeUI() } + } catch (e: Exception) { + if (log.isWarnEnabled) { + log.warn(e.message, e) + } + } + delay(timeout.seconds) + timeout = timeout * 2 + } + } + } + + + private fun downloadGeoLite2(dbFile: File) { + val url = "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" + val response = httpClient.newCall( + Request.Builder().get().url(url) + .build() + ).execute() + log.info("Fetched GeoLite2-Country.mmdb from {} status {}", url, response.code) + if (response.isSuccessful.not()) { + IOUtils.closeQuietly(response) + throw IllegalStateException("GeoLite2-Country.mmdb could not be downloaded, HTTP ${response.code}") + } + + val body = response.body + val input = body?.byteStream() + val file = FileUtils.getFile(Application.getTemporaryDir(), randomUUID()) + val output = file.outputStream() + + val downloaded = runCatching { IOUtils.copy(input, output) }.isSuccess + IOUtils.closeQuietly(input, output, body, response) + + log.info("Downloaded GeoLite2-Country.mmdb from {} , result: {}", url, downloaded) + + if (downloaded) { + FileUtils.moveFile(file, dbFile) + ready = true + } else { + throw IllegalStateException("GeoLite2-Country.mmdb could not be downloaded") + } + + } +} \ No newline at end of file diff --git a/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoFrameExtension.kt b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoFrameExtension.kt new file mode 100644 index 0000000..7baedee --- /dev/null +++ b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoFrameExtension.kt @@ -0,0 +1,47 @@ +package app.termora.plugins.geo + +import app.termora.EnableManager +import app.termora.FrameExtension +import app.termora.OptionPane +import app.termora.TermoraFrame +import java.awt.Window +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import javax.swing.JOptionPane +import javax.swing.SwingUtilities + +class GeoFrameExtension private constructor() : FrameExtension { + companion object { + val instance = GeoFrameExtension() + + private const val FIRST_KEY = "Plugins.Geo.isFirst" + } + + private val enableManager get() = EnableManager.getInstance() + + + override fun customize(frame: TermoraFrame) { + // 已经加载完毕,那么不需要提示 + if (GeoApplicationRunnerExtension.instance.isReady()) return + + // 已经提示过了,直接退出 + val isFirst = enableManager.getFlag(FIRST_KEY, true) + if (isFirst.not()) return + + frame.addWindowListener(object : WindowAdapter() { + override fun windowOpened(e: WindowEvent) { + enableManager.setFlag(FIRST_KEY, false) + frame.removeWindowListener(this) + SwingUtilities.invokeLater { showMessageDialog(frame) } + } + }) + } + + private fun showMessageDialog(window: Window) { + OptionPane.showMessageDialog( + window, + GeoI18n.getString("termora.plugins.geo.first-message"), + messageType = JOptionPane.INFORMATION_MESSAGE + ) + } +} \ No newline at end of file diff --git a/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoHostTreeShowMoreEnableExtension.kt b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoHostTreeShowMoreEnableExtension.kt new file mode 100644 index 0000000..29d2fb6 --- /dev/null +++ b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoHostTreeShowMoreEnableExtension.kt @@ -0,0 +1,48 @@ +package app.termora.plugins.geo + +import app.termora.EnableManager +import app.termora.I18n +import app.termora.SwingUtils +import app.termora.TermoraFrameManager +import app.termora.tree.HostTreeShowMoreEnableExtension +import app.termora.tree.NewHostTree +import javax.swing.JCheckBoxMenuItem +import javax.swing.JTree +import javax.swing.SwingUtilities + +internal class GeoHostTreeShowMoreEnableExtension private constructor() : HostTreeShowMoreEnableExtension { + companion object { + private const val KEY = "Plugins.Geo.ShowMore.Enable" + + val instance = GeoHostTreeShowMoreEnableExtension() + } + + private val enableManager get() = EnableManager.getInstance() + + override fun createJCheckBoxMenuItem(tree: JTree): JCheckBoxMenuItem { + val item = JCheckBoxMenuItem("Geo") + item.isEnabled = GeoApplicationRunnerExtension.instance.isReady() + item.isSelected = item.isEnabled && enableManager.getFlag(KEY, true) + if (item.isEnabled.not()) { + item.text = GeoI18n.getString("termora.plugins.geo.coming-soon") + } + item.addActionListener { + enableManager.setFlag(KEY, item.isSelected) + updateComponentTreeUI() + } + return item + } + + fun updateComponentTreeUI() { + // reload all tree + for (frame in TermoraFrameManager.getInstance().getWindows()) { + for (tree in SwingUtils.getDescendantsOfClass(NewHostTree::class.java, frame)) { + SwingUtilities.updateComponentTreeUI(tree) + } + } + } + + fun isShowMore(): Boolean { + return enableManager.getFlag(KEY, true) + } +} \ No newline at end of file diff --git a/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoI18n.kt b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoI18n.kt new file mode 100644 index 0000000..8a70a8a --- /dev/null +++ b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoI18n.kt @@ -0,0 +1,26 @@ +package app.termora.plugins.geo + +import app.termora.AbstractI18n +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.util.* + +object GeoI18n : AbstractI18n() { + private val log = LoggerFactory.getLogger(GeoI18n::class.java) + private val myBundle by lazy { + val bundle = ResourceBundle.getBundle("i18n/messages", Locale.getDefault(), GeoI18n::class.java.classLoader) + if (log.isInfoEnabled) { + log.info("I18n: {}", bundle.baseBundleName ?: "null") + } + return@lazy bundle + } + + + override fun getBundle(): ResourceBundle { + return myBundle + } + + override fun getLogger(): Logger { + return log + } +} \ No newline at end of file diff --git a/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoPlugin.kt b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoPlugin.kt new file mode 100644 index 0000000..361259f --- /dev/null +++ b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoPlugin.kt @@ -0,0 +1,37 @@ +package app.termora.plugins.geo + +import app.termora.ApplicationRunnerExtension +import app.termora.FrameExtension +import app.termora.plugin.Extension +import app.termora.plugin.ExtensionSupport +import app.termora.plugin.Plugin +import app.termora.tree.HostTreeShowMoreEnableExtension +import app.termora.tree.SimpleTreeCellRendererExtension + +class GeoPlugin : Plugin { + private val support = ExtensionSupport() + + init { + support.addExtension(ApplicationRunnerExtension::class.java) { GeoApplicationRunnerExtension.instance } + support.addExtension(SimpleTreeCellRendererExtension::class.java) { GeoSimpleTreeCellRendererExtension.instance } + support.addExtension(HostTreeShowMoreEnableExtension::class.java) { GeoHostTreeShowMoreEnableExtension.instance } + support.addExtension(FrameExtension::class.java) { GeoFrameExtension.instance } + } + + + override fun getAuthor(): String { + return "TermoraDev" + } + + + override fun getName(): String { + return "Geo" + } + + + override fun getExtensions(clazz: Class): List { + return support.getExtensions(clazz) + } + + +} \ No newline at end of file diff --git a/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoSimpleTreeCellRendererExtension.kt b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoSimpleTreeCellRendererExtension.kt new file mode 100644 index 0000000..424ff3d --- /dev/null +++ b/plugins/geo/src/main/kotlin/app/termora/plugins/geo/GeoSimpleTreeCellRendererExtension.kt @@ -0,0 +1,58 @@ +package app.termora.plugins.geo + +import app.termora.ColorHash +import app.termora.tree.HostTreeNode +import app.termora.tree.MarkerSimpleTreeCellAnnotation +import app.termora.tree.SimpleTreeCellAnnotation +import app.termora.tree.SimpleTreeCellRendererExtension +import java.awt.Color +import javax.swing.JTree + +class GeoSimpleTreeCellRendererExtension private constructor() : SimpleTreeCellRendererExtension { + companion object { + val instance = GeoSimpleTreeCellRendererExtension() + } + + private val geo get() = Geo.getInstance() + + override fun createAnnotations( + tree: JTree, + value: Any?, + sel: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean + ): List { + + val node = value as HostTreeNode? ?: return emptyList() + if (node.isFolder) return emptyList() + val protocol = node.data.protocol + if ((protocol == "SSH" || protocol == "RDP").not()) return emptyList() + + if (GeoHostTreeShowMoreEnableExtension.instance.isShowMore().not()) return emptyList() + val country = geo.country(node.data.host) ?: return emptyList() + + val text = "${countryCodeToFlagEmoji(country.isoCode)}${country.name}" + return listOf( + MarkerSimpleTreeCellAnnotation( + text, + foreground = Color.white, + background = ColorHash.hash(country.isoCode), + ) + ) + } + + private fun countryCodeToFlagEmoji(code: String): String { + if (code.length < 2) return "❓" + val upper = code.take(2).uppercase() + val first = Character.codePointAt(upper, 0) - 'A'.code + 0x1F1E6 + val second = Character.codePointAt(upper, 1) - 'A'.code + 0x1F1E6 + return String(Character.toChars(first)) + String(Character.toChars(second)) + } + + override fun ordered(): Long { + return 1 + } + +} \ No newline at end of file diff --git a/plugins/geo/src/main/resources/META-INF/plugin.xml b/plugins/geo/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..70cc1b4 --- /dev/null +++ b/plugins/geo/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,23 @@ + + + geo + + Geo + + ${projectVersion} + + app.termora.plugins.geo.GeoPlugin + + + + + + Display the geographical location of the host + 显示主机的地理位置 + 顯示主機的地理位置 + + + TermoraDev + + + diff --git a/plugins/geo/src/main/resources/META-INF/pluginIcon.svg b/plugins/geo/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..b14a959 --- /dev/null +++ b/plugins/geo/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/geo/src/main/resources/META-INF/pluginIcon_dark.svg b/plugins/geo/src/main/resources/META-INF/pluginIcon_dark.svg new file mode 100644 index 0000000..336bf9a --- /dev/null +++ b/plugins/geo/src/main/resources/META-INF/pluginIcon_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/geo/src/main/resources/i18n/messages.properties b/plugins/geo/src/main/resources/i18n/messages.properties new file mode 100644 index 0000000..b3e8bf1 --- /dev/null +++ b/plugins/geo/src/main/resources/i18n/messages.properties @@ -0,0 +1,2 @@ +termora.plugins.geo.first-message=The first time you use the Geo plugin, it will download the GeoLite2.mmdb database.
Once the download is complete, it will display the host region information. +termora.plugins.geo.coming-soon=Geo loading diff --git a/plugins/geo/src/main/resources/i18n/messages_zh_CN.properties b/plugins/geo/src/main/resources/i18n/messages_zh_CN.properties new file mode 100644 index 0000000..df9e49c --- /dev/null +++ b/plugins/geo/src/main/resources/i18n/messages_zh_CN.properties @@ -0,0 +1,2 @@ +termora.plugins.geo.first-message=首次使用 Geo 插件会下载 GeoLite2.mmdb 数据库,下载完成后会显示主机地域信息 +termora.plugins.geo.coming-soon=Geo 加载中 diff --git a/plugins/geo/src/main/resources/i18n/messages_zh_TW.properties b/plugins/geo/src/main/resources/i18n/messages_zh_TW.properties new file mode 100644 index 0000000..3e99b98 --- /dev/null +++ b/plugins/geo/src/main/resources/i18n/messages_zh_TW.properties @@ -0,0 +1,2 @@ +termora.plugins.geo.first-message=首次使用 Geo 外掛程式會下載 GeoLite2.mmdb 資料庫,下載完成後會顯示主機地域訊息 +termora.plugins.geo.coming-soon=Geo 加载中 diff --git a/plugins/migration/build.gradle.kts b/plugins/migration/build.gradle.kts new file mode 100644 index 0000000..eb840a7 --- /dev/null +++ b/plugins/migration/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + + +project.version = "0.0.2" + + +dependencies { + testImplementation(kotlin("test")) + compileOnly(project(":")) + + implementation(libs.xodus.vfs) + implementation(libs.xodus.openAPI) + implementation(libs.xodus.environment) + implementation(libs.bip39) + implementation(libs.commons.compress) +} + + +ext.set("Termora-Plugin-Entry", "app.termora.plugins.migration.MigrationPlugin") +apply(from = "$rootDir/plugins/common.gradle.kts") diff --git a/src/main/kotlin/app/termora/Database.kt b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/Database.kt similarity index 97% rename from src/main/kotlin/app/termora/Database.kt rename to plugins/migration/src/main/kotlin/app/termora/plugins/migration/Database.kt index 1f7af62..41a57e8 100644 --- a/src/main/kotlin/app/termora/Database.kt +++ b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/Database.kt @@ -1,13 +1,12 @@ -package app.termora +package app.termora.plugins.migration +import app.termora.* import app.termora.Application.ohMyJson import app.termora.highlight.KeywordHighlight import app.termora.keymap.Keymap import app.termora.keymgr.OhKeyPair import app.termora.macro.Macro import app.termora.snippet.Snippet -import app.termora.sync.SyncManager -import app.termora.sync.SyncType import app.termora.terminal.CursorStyle import jetbrains.exodus.bindings.StringBinding import jetbrains.exodus.env.* @@ -47,7 +46,7 @@ class Database private constructor(private val env: Environment) : Disposable { fun getDatabase(): Database { return ApplicationScope.forApplicationScope() - .getOrCreate(Database::class) { open(Application.getDatabaseFile()) } + .getOrCreate(Database::class) { open(MigrationApplicationRunnerExtension.instance.getDatabaseFile()) } } } @@ -289,18 +288,6 @@ class Database private constructor(private val env: Environment) : Disposable { val k = StringBinding.stringToEntry(key) val v = StringBinding.stringToEntry(value) store.put(tx, k, v) - - // 数据变动时触发一次同步 - if (name == HOST_STORE || - name == KEYMAP_STORE || - name == SNIPPET_STORE || - name == KEYWORD_HIGHLIGHT_STORE || - name == MACRO_STORE || - name == KEY_PAIR_STORE || - name == DELETED_DATA_STORE - ) { - SyncManager.getInstance().triggerOnChanged() - } } private fun delete(tx: Transaction, name: String, key: String) { @@ -356,7 +343,7 @@ class Database private constructor(private val env: Environment) : Disposable { } - abstract inner class Property(private val name: String) { + abstract inner class Property(val name: String) { private val properties = Collections.synchronizedMap(mutableMapOf()) init { diff --git a/src/main/kotlin/app/termora/DoormanDialog.kt b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/DoormanDialog.kt similarity index 96% rename from src/main/kotlin/app/termora/DoormanDialog.kt rename to plugins/migration/src/main/kotlin/app/termora/plugins/migration/DoormanDialog.kt index 0b5c6f0..2705230 100644 --- a/src/main/kotlin/app/termora/DoormanDialog.kt +++ b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/DoormanDialog.kt @@ -1,5 +1,6 @@ -package app.termora +package app.termora.plugins.migration +import app.termora.* import app.termora.AES.decodeBase64 import app.termora.actions.AnAction import app.termora.actions.AnActionEvent @@ -86,7 +87,7 @@ class DoormanDialog(owner: Window?) : DialogWrapper(owner) { .layout( FormLayout( "$formMargin, default:grow, 4dlu, pref, $formMargin", - "${if (SystemInfo.isWindows) "20dlu" else "0dlu"}, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin" + "${"0dlu"}, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin" ) ) .add(icon).xyw(2, rows, 4).apply { rows += step } @@ -114,7 +115,7 @@ class DoormanDialog(owner: Window?) : DialogWrapper(owner) { I18n.getString("termora.doorman.delete-data"), messageType = JOptionPane.WARNING_MESSAGE ) - Application.browse(Application.getDatabaseFile().toURI()) + Application.browse(MigrationApplicationRunnerExtension.instance.getDatabaseFile().toURI()) } } }).apply { isFocusable = false }).xyw(2, rows, 4, "center, fill") @@ -136,6 +137,9 @@ class DoormanDialog(owner: Window?) : DialogWrapper(owner) { val key = AES.ECB.decrypt(entropy, keyBackup.decodeBase64()) Doorman.getInstance().work(key) } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } OptionPane.showMessageDialog( this, I18n.getString("termora.doorman.mnemonic-data-corrupted"), messageType = JOptionPane.ERROR_MESSAGE @@ -219,7 +223,7 @@ class DoormanDialog(owner: Window?) : DialogWrapper(owner) { ) val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin") - .layout(layout).debug(true) + .layout(layout).debug(false) val iterator = textFields.iterator() for (i in 1..5 step 2) { for (j in 1..7 step 2) { diff --git a/plugins/migration/src/main/kotlin/app/termora/plugins/migration/MigrationApplicationRunnerExtension.kt b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/MigrationApplicationRunnerExtension.kt new file mode 100644 index 0000000..641498d --- /dev/null +++ b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/MigrationApplicationRunnerExtension.kt @@ -0,0 +1,198 @@ +package app.termora.plugins.migration + +import app.termora.* +import app.termora.account.AccountManager +import app.termora.account.AccountOwner +import app.termora.database.DatabaseManager +import app.termora.database.OwnerType +import app.termora.highlight.KeywordHighlightManager +import app.termora.keymap.KeymapManager +import app.termora.keymgr.KeyManager +import app.termora.macro.MacroManager +import app.termora.snippet.SnippetManager +import org.apache.commons.io.FileUtils +import org.slf4j.LoggerFactory +import java.io.File +import java.util.concurrent.CountDownLatch +import javax.swing.JOptionPane +import javax.swing.SwingUtilities +import kotlin.system.exitProcess + +class MigrationApplicationRunnerExtension private constructor() : ApplicationRunnerExtension { + companion object { + private val log = LoggerFactory.getLogger(MigrationApplicationRunnerExtension::class.java) + val instance by lazy { MigrationApplicationRunnerExtension() } + } + + override fun ready() { + val file = getDatabaseFile() + if (file.exists().not()) return + + // 如果数据库文件存在,那么需要迁移文件 + val countDownLatch = CountDownLatch(1) + + SwingUtilities.invokeAndWait { + try { + // 打开数据 + openDatabase() + + // 尝试解锁 + openDoor() + + // 询问是否迁移 + if (askMigrate()) { + + // 迁移 + migrate() + + // 移动到旧的目录 + moveOldDirectory() + + // 重启 + restart() + + } + + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + } finally { + countDownLatch.countDown() + } + + } + + countDownLatch.await() + + } + + private fun openDoor() { + if (Doorman.getInstance().isWorking()) { + if (DoormanDialog(null).open().not()) { + Disposer.dispose(TermoraFrameManager.getInstance()) + } + } + } + + private fun openDatabase() { + try { + // 初始化数据库 + Database.getDatabase() + } 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) + } + } + + private fun migrate() { + val database = Database.getDatabase() + val accountManager = AccountManager.getInstance() + val databaseManager = DatabaseManager.getInstance() + val ownerId = accountManager.getAccountId() + val hostManager = HostManager.getInstance() + val snippetManager = SnippetManager.getInstance() + val macroManager = MacroManager.getInstance() + val keymapManager = KeymapManager.getInstance() + val keyManager = KeyManager.getInstance() + val highlightManager = KeywordHighlightManager.getInstance() + val accountOwner = AccountOwner( + id = accountManager.getAccountId(), + name = accountManager.getEmail(), + type = OwnerType.User + ) + + for (host in database.getHosts()) { + if (host.deleted) continue + hostManager.addHost(host.copy(ownerId = accountManager.getAccountId(), ownerType = OwnerType.User.name)) + } + + for (snippet in database.getSnippets()) { + if (snippet.deleted) continue + snippetManager.addSnippet(snippet) + } + + for (macro in database.getMacros()) { + macroManager.addMacro(macro) + } + + for (keymap in database.getKeymaps()) { + keymapManager.addKeymap(keymap) + } + + for (keypair in database.getKeyPairs()) { + keyManager.addOhKeyPair(keypair, accountOwner) + } + + for (e in database.getKeywordHighlights()) { + highlightManager.addKeywordHighlight(e, accountOwner) + } + + val list = listOf( + database.sync, + database.properties, + database.terminal, + database.sftp, + database.appearance, + ) + + for (e in list) { + for (k in e.getProperties()) { + databaseManager.setSetting(e.name + "." + k.key, k.value) + } + } + + for (e in database.safetyProperties.getProperties()) { + databaseManager.setSetting(database.properties.name + "." + e.key, e.value) + } + + + } + + private fun askMigrate(): Boolean { + + if (MigrationDialog(null).open()) { + return true + } + + // 移动到旧的目录 + moveOldDirectory() + + // 重启 + restart() + + return false + } + + private fun moveOldDirectory() { + // 关闭数据库 + Disposer.dispose(Database.getDatabase()) + + val file = getDatabaseFile() + FileUtils.moveDirectory( + file, + FileUtils.getFile(file.parentFile, file.name + "-old-" + System.currentTimeMillis()) + ) + + } + + private fun restart() { + + // 重启 + TermoraRestarter.getInstance().scheduleRestart(null, ask = false) + + // 退出程序 + Disposer.dispose(TermoraFrameManager.getInstance()) + } + + + fun getDatabaseFile(): File { + return FileUtils.getFile(Application.getBaseDataDir(), "storage") + } +} \ No newline at end of file diff --git a/plugins/migration/src/main/kotlin/app/termora/plugins/migration/MigrationDialog.kt b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/MigrationDialog.kt new file mode 100644 index 0000000..c1ac919 --- /dev/null +++ b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/MigrationDialog.kt @@ -0,0 +1,112 @@ +package app.termora.plugins.migration + +import app.termora.* +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.extras.FlatSVGIcon +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.JXEditorPane +import java.awt.Dimension +import java.awt.Window +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import javax.imageio.ImageIO +import javax.swing.* +import javax.swing.event.HyperlinkEvent + +class MigrationDialog(owner: Window?) : DialogWrapper(owner) { + + private var isOpened = false + + init { + size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150) + isModal = true + isResizable = false + controlsVisible = false + escapeDispose = false + + if (SystemInfo.isWindows || SystemInfo.isLinux) { + title = StringUtils.EMPTY + 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 { + var rows = 2 + val step = 2 + val formMargin = "7dlu" + val icon = JLabel() + icon.horizontalAlignment = SwingConstants.CENTER + icon.icon = FlatSVGIcon(Icons.newUI.name, 80, 80) + + val editorPane = JXEditorPane() + editorPane.contentType = "text/html" + editorPane.text = MigrationI18n.getString("termora.plugins.migration.message") + editorPane.isEditable = false + editorPane.addHyperlinkListener { + if (it.eventType == HyperlinkEvent.EventType.ACTIVATED) { + Application.browse(it.url.toURI()) + } + } + editorPane.background = DynamicColor("window") + val scrollPane = JScrollPane(editorPane) + scrollPane.border = BorderFactory.createEmptyBorder() + scrollPane.preferredSize = Dimension(Int.MAX_VALUE, 225) + + addWindowListener(object : WindowAdapter() { + override fun windowOpened(e: WindowEvent) { + removeWindowListener(this) + SwingUtilities.invokeLater { scrollPane.verticalScrollBar.value = 0 } + } + }) + + return FormBuilder.create().debug(false) + .layout( + FormLayout( + "$formMargin, default:grow, 4dlu, pref, $formMargin", + "${"0dlu"}, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin" + ) + ) + .add(icon).xyw(2, rows, 4).apply { rows += step } + .add(scrollPane).xyw(2, rows, 4).apply { rows += step } + .build() + } + + + fun open(): Boolean { + isModal = true + isVisible = true + return isOpened + } + + override fun doOKAction() { + isOpened = true + super.doOKAction() + } + + override fun doCancelAction() { + isOpened = false + super.doCancelAction() + } + + override fun createOkAction(): AbstractAction { + return OkAction(MigrationI18n.getString("termora.plugins.migration.migrate")) + } + + +} \ No newline at end of file diff --git a/plugins/migration/src/main/kotlin/app/termora/plugins/migration/MigrationI18n.kt b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/MigrationI18n.kt new file mode 100644 index 0000000..7494b15 --- /dev/null +++ b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/MigrationI18n.kt @@ -0,0 +1,13 @@ +package app.termora.plugins.migration + +import app.termora.NamedI18n +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +object MigrationI18n : NamedI18n("i18n/messages") { + private val log = LoggerFactory.getLogger(MigrationI18n::class.java) + + override fun getLogger(): Logger { + return log + } +} \ No newline at end of file diff --git a/plugins/migration/src/main/kotlin/app/termora/plugins/migration/MigrationPlugin.kt b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/MigrationPlugin.kt new file mode 100644 index 0000000..fa094b2 --- /dev/null +++ b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/MigrationPlugin.kt @@ -0,0 +1,33 @@ +package app.termora.plugins.migration + +import app.termora.ApplicationRunnerExtension +import app.termora.DynamicIcon +import app.termora.I18n +import app.termora.Icons +import app.termora.plugin.Extension +import app.termora.plugin.ExtensionSupport +import app.termora.plugin.Plugin + +class MigrationPlugin : Plugin { + private val support = ExtensionSupport() + + init { + support.addExtension(ApplicationRunnerExtension::class.java) { MigrationApplicationRunnerExtension.instance } + } + + override fun getAuthor(): String { + return "TermoraDev" + } + + + override fun getName(): String { + return "Migration" + } + + + override fun getExtensions(clazz: Class): List { + return support.getExtensions(clazz) + } + + +} \ No newline at end of file diff --git a/plugins/migration/src/main/kotlin/app/termora/plugins/migration/PBKDF2.kt b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/PBKDF2.kt new file mode 100644 index 0000000..3af69bf --- /dev/null +++ b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/PBKDF2.kt @@ -0,0 +1,37 @@ +package app.termora.plugins.migration + +import org.slf4j.LoggerFactory +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import kotlin.time.measureTime + +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 + } + + fun hash(slat: ByteArray, password: CharArray, iterationCount: Int, keyLength: Int): ByteArray { + val spec = PBEKeySpec(password, slat, iterationCount, keyLength) + val secretKeyFactory = SecretKeyFactory.getInstance(ALGORITHM) + return secretKeyFactory.generateSecret(spec).encoded + } + +} diff --git a/src/main/kotlin/app/termora/Doorman.kt b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/PasswordWrongException.kt similarity index 97% rename from src/main/kotlin/app/termora/Doorman.kt rename to plugins/migration/src/main/kotlin/app/termora/plugins/migration/PasswordWrongException.kt index 7537e4e..5ea585b 100644 --- a/src/main/kotlin/app/termora/Doorman.kt +++ b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/PasswordWrongException.kt @@ -1,5 +1,6 @@ -package app.termora +package app.termora.plugins.migration +import app.termora.* import app.termora.AES.decodeBase64 import app.termora.AES.encodeBase64String diff --git a/plugins/migration/src/main/kotlin/app/termora/plugins/migration/SyncType.kt b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/SyncType.kt new file mode 100644 index 0000000..f22a5ec --- /dev/null +++ b/plugins/migration/src/main/kotlin/app/termora/plugins/migration/SyncType.kt @@ -0,0 +1,7 @@ +package app.termora.plugins.migration +enum class SyncType { + GitLab, + GitHub, + Gitee, + WebDAV, +} \ No newline at end of file diff --git a/plugins/migration/src/main/resources/META-INF/plugin.xml b/plugins/migration/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..4d31b61 --- /dev/null +++ b/plugins/migration/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,24 @@ + + + migration + + Migration + + ${projectVersion} + + + + + + app.termora.plugins.migration.MigrationPlugin + + + Migrate version 1.x configuration files to 2.x + 将 1.x 版本的配置文件迁移到 2.x + 將 1.x 版本的設定檔移轉到 2.x + + + TermoraDev + + + diff --git a/plugins/migration/src/main/resources/META-INF/pluginIcon.svg b/plugins/migration/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..0f5d1ef --- /dev/null +++ b/plugins/migration/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/plugins/migration/src/main/resources/i18n/messages.properties b/plugins/migration/src/main/resources/i18n/messages.properties new file mode 100644 index 0000000..2d0726e --- /dev/null +++ b/plugins/migration/src/main/resources/i18n/messages.properties @@ -0,0 +1,9 @@ +termora.plugins.migration.message= \ +

2.0 is ready.

\ +
\ +

1. The storage structure has been updated. Existing data needs to be migrated. Just click “Migrate” to complete the process.

\ +

2. The Sync feature is now provided as a plugin. If needed, please manually install it from Settings.

\ +

3. The Data Encryption feature has been removed (local data will now be stored with basic encryption). Please ensure your device is in a trusted environment.

\ +

📎 For more information, please see: TermoraDev/termora/issues/645

\ + +termora.plugins.migration.migrate=Migrate diff --git a/plugins/migration/src/main/resources/i18n/messages_zh_CN.properties b/plugins/migration/src/main/resources/i18n/messages_zh_CN.properties new file mode 100644 index 0000000..78633e3 --- /dev/null +++ b/plugins/migration/src/main/resources/i18n/messages_zh_CN.properties @@ -0,0 +1,9 @@ +termora.plugins.migration.message= \ +

2.0 已就绪。

\ +
\ +

1. 存储结构已更新,需迁移现有数据。只需点击 “迁移” 即可完成操作。

\ +

2. 同步功能 现作为插件提供,如需使用,请前往设置中 手动安装

\ +

3. 数据加密 功能已被 移除(本地数据将以简单加密方式存储),请确保你的设备处于可信环境中。

\ +

📎 更多信息请查看:TermoraDev/termora/issues/645

\ + +termora.plugins.migration.migrate=迁移 diff --git a/plugins/migration/src/main/resources/i18n/messages_zh_TW.properties b/plugins/migration/src/main/resources/i18n/messages_zh_TW.properties new file mode 100644 index 0000000..d8d8076 --- /dev/null +++ b/plugins/migration/src/main/resources/i18n/messages_zh_TW.properties @@ -0,0 +1,9 @@ +termora.plugins.migration.message= \ +

2.0 已準備就緒。

\ +
\ +

1. 儲存結構已更新,需要遷移現有資料。只需點擊 「遷移」 即可完成操作。

\ +

2. 同步功能 現以外掛形式提供,如需使用,請至設定中 手動安裝

\ +

3. 資料加密 功能已被 移除(本機資料將以簡易加密方式儲存),請確保你的裝置處於可信環境中。

\ +

📎 更多資訊請參見:TermoraDev/termora/issues/645

\ + +termora.plugins.migration.migrate=遷移 diff --git a/plugins/obs/build.gradle.kts b/plugins/obs/build.gradle.kts new file mode 100644 index 0000000..e824027 --- /dev/null +++ b/plugins/obs/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + + +project.version = "0.0.1" + + +dependencies { + testImplementation(kotlin("test")) + implementation("com.huaweicloud:esdk-obs-java-bundle:3.25.4") + compileOnly(project(":")) +} + + +apply(from = "$rootDir/plugins/common.gradle.kts") diff --git a/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSFileProvider.kt b/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSFileProvider.kt new file mode 100644 index 0000000..ecb5031 --- /dev/null +++ b/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSFileProvider.kt @@ -0,0 +1,41 @@ +package app.termora.plugins.obs + +import org.apache.commons.vfs2.Capability +import org.apache.commons.vfs2.FileName +import org.apache.commons.vfs2.FileSystem +import org.apache.commons.vfs2.FileSystemOptions +import org.apache.commons.vfs2.provider.AbstractOriginatingFileProvider + +class OBSFileProvider private constructor() : AbstractOriginatingFileProvider() { + + companion object { + val instance by lazy { OBSFileProvider() } + val capabilities = listOf( + Capability.CREATE, + Capability.DELETE, + Capability.RENAME, + Capability.GET_TYPE, + Capability.LIST_CHILDREN, + Capability.READ_CONTENT, + Capability.URI, + Capability.WRITE_CONTENT, + Capability.GET_LAST_MODIFIED, + Capability.SET_LAST_MODIFIED_FILE, + Capability.RANDOM_ACCESS_READ, + Capability.APPEND_CONTENT + ) + } + + override fun getCapabilities(): Collection { + return OBSFileProvider.capabilities + } + + override fun doCreateFileSystem( + rootFileName: FileName, + fileSystemOptions: FileSystemOptions + ): FileSystem? { + TODO("Not yet implemented") + } + + +} \ No newline at end of file diff --git a/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSPlugin.kt b/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSPlugin.kt new file mode 100644 index 0000000..5c444d2 --- /dev/null +++ b/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSPlugin.kt @@ -0,0 +1,35 @@ +package app.termora.plugins.obs + +import app.termora.DynamicIcon +import app.termora.I18n +import app.termora.Icons +import app.termora.plugin.Extension +import app.termora.plugin.ExtensionSupport +import app.termora.plugin.PaidPlugin +import app.termora.protocol.ProtocolHostPanelExtension +import app.termora.protocol.ProtocolProviderExtension + +class OBSPlugin : PaidPlugin { + private val support = ExtensionSupport() + + init { + support.addExtension(ProtocolProviderExtension::class.java) { OBSProtocolProviderExtension.instance } + support.addExtension(ProtocolHostPanelExtension::class.java) { OBSProtocolHostPanelExtension.instance } + } + + override fun getAuthor(): String { + return "TermoraDev" + } + + + override fun getName(): String { + return "Huawei OBS" + } + + + override fun getExtensions(clazz: Class): List { + return support.getExtensions(clazz) + } + + +} \ No newline at end of file diff --git a/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSProtocolHostPanel.kt b/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSProtocolHostPanel.kt new file mode 100644 index 0000000..340ae17 --- /dev/null +++ b/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSProtocolHostPanel.kt @@ -0,0 +1,22 @@ +package app.termora.plugins.obs + +import app.termora.Host +import app.termora.protocol.ProtocolHostPanel +import org.apache.commons.lang3.StringUtils + +class OBSProtocolHostPanel : ProtocolHostPanel() { + override fun getHost(): Host { + return Host( + name = StringUtils.EMPTY, + protocol = OBSProtocolProvider.PROTOCOL + ) + } + + override fun setHost(host: Host) { + + } + + override fun validateFields(): Boolean { + return true + } +} \ No newline at end of file diff --git a/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSProtocolHostPanelExtension.kt b/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSProtocolHostPanelExtension.kt new file mode 100644 index 0000000..a109f4c --- /dev/null +++ b/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSProtocolHostPanelExtension.kt @@ -0,0 +1,19 @@ +package app.termora.plugins.obs + +import app.termora.protocol.ProtocolHostPanel +import app.termora.protocol.ProtocolHostPanelExtension +import app.termora.protocol.ProtocolProvider + +class OBSProtocolHostPanelExtension private constructor() : ProtocolHostPanelExtension { + companion object { + val instance by lazy { OBSProtocolHostPanelExtension() } + } + + override fun getProtocolProvider(): ProtocolProvider { + return OBSProtocolProvider.instance + } + + override fun createProtocolHostPanel(): ProtocolHostPanel { + return OBSProtocolHostPanel() + } +} \ No newline at end of file diff --git a/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSProtocolProvider.kt b/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSProtocolProvider.kt new file mode 100644 index 0000000..fe1f709 --- /dev/null +++ b/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSProtocolProvider.kt @@ -0,0 +1,33 @@ +package app.termora.plugins.obs + +import app.termora.DynamicIcon +import app.termora.Icons +import app.termora.protocol.FileObjectHandler +import app.termora.protocol.FileObjectRequest +import app.termora.protocol.TransferProtocolProvider +import org.apache.commons.vfs2.provider.FileProvider + +class OBSProtocolProvider private constructor() : TransferProtocolProvider { + + companion object { + val instance by lazy { OBSProtocolProvider() } + const val PROTOCOL = "OBS" + } + + override fun getProtocol(): String { + return PROTOCOL + } + + override fun getIcon(width: Int, height: Int): DynamicIcon { + return Icons.huawei + } + + override fun getFileProvider(): FileProvider { + return OBSFileProvider.instance + } + + override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler { + TODO("Not yet implemented") + } + +} \ No newline at end of file diff --git a/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSProtocolProviderExtension.kt b/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSProtocolProviderExtension.kt new file mode 100644 index 0000000..33a9c96 --- /dev/null +++ b/plugins/obs/src/main/kotlin/app/termora/plugins/obs/OBSProtocolProviderExtension.kt @@ -0,0 +1,14 @@ +package app.termora.plugins.obs + +import app.termora.protocol.ProtocolProvider +import app.termora.protocol.ProtocolProviderExtension + +class OBSProtocolProviderExtension private constructor() : ProtocolProviderExtension { + companion object { + val instance by lazy { OBSProtocolProviderExtension() } + } + + override fun getProtocolProvider(): ProtocolProvider { + return OBSProtocolProvider.Companion.instance + } +} \ No newline at end of file diff --git a/plugins/obs/src/main/resources/META-INF/plugin.xml b/plugins/obs/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..0e6582a --- /dev/null +++ b/plugins/obs/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,24 @@ + + + obs + + Huawei OBS + + + + ${projectVersion} + + + + app.termora.plugins.obs.OBSPlugin + + + Connecting to Huawei OBS + 支持连接到华为云对象存储 + 支援連接到華為雲端物件存儲 + + + TermoraDev + + + diff --git a/plugins/obs/src/main/resources/META-INF/pluginIcon.svg b/plugins/obs/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..165af1d --- /dev/null +++ b/plugins/obs/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/obs/src/main/resources/META-INF/pluginIcon_dark.svg b/plugins/obs/src/main/resources/META-INF/pluginIcon_dark.svg new file mode 100644 index 0000000..2ec24e7 --- /dev/null +++ b/plugins/obs/src/main/resources/META-INF/pluginIcon_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/oss/build.gradle.kts b/plugins/oss/build.gradle.kts new file mode 100644 index 0000000..872d151 --- /dev/null +++ b/plugins/oss/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +project.version = "0.0.1" + +dependencies { + testImplementation(kotlin("test")) + implementation("com.aliyun.oss:aliyun-sdk-oss:3.18.2") + implementation("javax.xml.bind:jaxb-api:2.3.1") + implementation("javax.activation:activation:1.1.1") + implementation("org.glassfish.jaxb:jaxb-runtime:2.3.3") + compileOnly(project(":")) +} + + +apply(from = "$rootDir/plugins/common.gradle.kts") diff --git a/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSFileProvider.kt b/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSFileProvider.kt new file mode 100644 index 0000000..2e1fe4d --- /dev/null +++ b/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSFileProvider.kt @@ -0,0 +1,41 @@ +package app.termora.plugins.oss + +import org.apache.commons.vfs2.Capability +import org.apache.commons.vfs2.FileName +import org.apache.commons.vfs2.FileSystem +import org.apache.commons.vfs2.FileSystemOptions +import org.apache.commons.vfs2.provider.AbstractOriginatingFileProvider + +class OSSFileProvider private constructor() : AbstractOriginatingFileProvider() { + + companion object { + val instance by lazy { OSSFileProvider() } + val capabilities = listOf( + Capability.CREATE, + Capability.DELETE, + Capability.RENAME, + Capability.GET_TYPE, + Capability.LIST_CHILDREN, + Capability.READ_CONTENT, + Capability.URI, + Capability.WRITE_CONTENT, + Capability.GET_LAST_MODIFIED, + Capability.SET_LAST_MODIFIED_FILE, + Capability.RANDOM_ACCESS_READ, + Capability.APPEND_CONTENT + ) + } + + override fun getCapabilities(): Collection { + return OSSFileProvider.capabilities + } + + override fun doCreateFileSystem( + rootFileName: FileName, + fileSystemOptions: FileSystemOptions + ): FileSystem? { + TODO("Not yet implemented") + } + + +} \ No newline at end of file diff --git a/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSPlugin.kt b/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSPlugin.kt new file mode 100644 index 0000000..9396303 --- /dev/null +++ b/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSPlugin.kt @@ -0,0 +1,35 @@ +package app.termora.plugins.oss + +import app.termora.DynamicIcon +import app.termora.I18n +import app.termora.Icons +import app.termora.plugin.Extension +import app.termora.plugin.ExtensionSupport +import app.termora.plugin.PaidPlugin +import app.termora.protocol.ProtocolHostPanelExtension +import app.termora.protocol.ProtocolProviderExtension + +class OSSPlugin : PaidPlugin { + private val support = ExtensionSupport() + + init { + support.addExtension(ProtocolProviderExtension::class.java) { OSSProtocolProviderExtension.instance } + support.addExtension(ProtocolHostPanelExtension::class.java) { OSSProtocolHostPanelExtension.instance } + } + + override fun getAuthor(): String { + return "TermoraDev" + } + + + override fun getName(): String { + return "Alibaba OSS" + } + + + override fun getExtensions(clazz: Class): List { + return support.getExtensions(clazz) + } + + +} \ No newline at end of file diff --git a/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSProtocolHostPanel.kt b/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSProtocolHostPanel.kt new file mode 100644 index 0000000..2fb5de8 --- /dev/null +++ b/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSProtocolHostPanel.kt @@ -0,0 +1,22 @@ +package app.termora.plugins.oss + +import app.termora.Host +import app.termora.protocol.ProtocolHostPanel +import org.apache.commons.lang3.StringUtils + +class OSSProtocolHostPanel : ProtocolHostPanel() { + override fun getHost(): Host { + return Host( + name = StringUtils.EMPTY, + protocol = OSSProtocolProvider.PROTOCOL + ) + } + + override fun setHost(host: Host) { + + } + + override fun validateFields(): Boolean { + return true + } +} \ No newline at end of file diff --git a/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSProtocolHostPanelExtension.kt b/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSProtocolHostPanelExtension.kt new file mode 100644 index 0000000..41e5f86 --- /dev/null +++ b/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSProtocolHostPanelExtension.kt @@ -0,0 +1,19 @@ +package app.termora.plugins.oss + +import app.termora.protocol.ProtocolHostPanel +import app.termora.protocol.ProtocolHostPanelExtension +import app.termora.protocol.ProtocolProvider + +class OSSProtocolHostPanelExtension private constructor() : ProtocolHostPanelExtension { + companion object { + val instance by lazy { OSSProtocolHostPanelExtension() } + } + + override fun getProtocolProvider(): ProtocolProvider { + return OSSProtocolProvider.instance + } + + override fun createProtocolHostPanel(): ProtocolHostPanel { + return OSSProtocolHostPanel() + } +} \ No newline at end of file diff --git a/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSProtocolProvider.kt b/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSProtocolProvider.kt new file mode 100644 index 0000000..2e9e932 --- /dev/null +++ b/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSProtocolProvider.kt @@ -0,0 +1,33 @@ +package app.termora.plugins.oss + +import app.termora.DynamicIcon +import app.termora.Icons +import app.termora.protocol.FileObjectHandler +import app.termora.protocol.FileObjectRequest +import app.termora.protocol.TransferProtocolProvider +import org.apache.commons.vfs2.provider.FileProvider + +class OSSProtocolProvider private constructor() : TransferProtocolProvider { + + companion object { + val instance by lazy { OSSProtocolProvider() } + const val PROTOCOL = "OSS" + } + + override fun getProtocol(): String { + return PROTOCOL + } + + override fun getIcon(width: Int, height: Int): DynamicIcon { + return Icons.aliyun + } + + override fun getFileProvider(): FileProvider { + return OSSFileProvider.instance + } + + override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler { + TODO("Not yet implemented") + } + +} \ No newline at end of file diff --git a/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSProtocolProviderExtension.kt b/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSProtocolProviderExtension.kt new file mode 100644 index 0000000..62dfe97 --- /dev/null +++ b/plugins/oss/src/main/kotlin/app/termora/plugins/oss/OSSProtocolProviderExtension.kt @@ -0,0 +1,14 @@ +package app.termora.plugins.oss + +import app.termora.protocol.ProtocolProvider +import app.termora.protocol.ProtocolProviderExtension + +class OSSProtocolProviderExtension private constructor() : ProtocolProviderExtension { + companion object { + val instance by lazy { OSSProtocolProviderExtension() } + } + + override fun getProtocolProvider(): ProtocolProvider { + return OSSProtocolProvider.Companion.instance + } +} \ No newline at end of file diff --git a/plugins/oss/src/main/resources/META-INF/plugin.xml b/plugins/oss/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..7b2f36d --- /dev/null +++ b/plugins/oss/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,24 @@ + + + oss + + Alibaba OSS + + + + ${projectVersion} + + + + app.termora.plugins.oss.OSSPlugin + + + Connecting to Alibaba OSS + 支持连接到阿里云对象存储 + 支援連接到阿里雲物件存儲 + + + TermoraDev + + + diff --git a/plugins/oss/src/main/resources/META-INF/pluginIcon.svg b/plugins/oss/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..1525aab --- /dev/null +++ b/plugins/oss/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/oss/src/main/resources/META-INF/pluginIcon_dark.svg b/plugins/oss/src/main/resources/META-INF/pluginIcon_dark.svg new file mode 100644 index 0000000..11e6c1c --- /dev/null +++ b/plugins/oss/src/main/resources/META-INF/pluginIcon_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/s3/build.gradle.kts b/plugins/s3/build.gradle.kts new file mode 100644 index 0000000..2f1f6cc --- /dev/null +++ b/plugins/s3/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + + +project.version = "0.0.1" + + +dependencies { + testImplementation(kotlin("test")) + testImplementation(platform(libs.testcontainers.bom)) + testImplementation(libs.testcontainers) + testImplementation(libs.testcontainers.junit.jupiter) + testImplementation(project(":")) + + implementation("io.minio:minio:8.5.17") + compileOnly(project(":")) +} + + +apply(from = "$rootDir/plugins/common.gradle.kts") diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileObject.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileObject.kt new file mode 100644 index 0000000..88b39e1 --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileObject.kt @@ -0,0 +1,223 @@ +package app.termora.plugins.s3 + +import app.termora.DynamicIcon +import app.termora.Icons +import app.termora.vfs2.FileObjectDescriptor +import io.minio.* +import org.apache.commons.io.IOUtils +import org.apache.commons.lang3.StringUtils +import org.apache.commons.vfs2.FileObject +import org.apache.commons.vfs2.FileType +import org.apache.commons.vfs2.provider.AbstractFileName +import org.apache.commons.vfs2.provider.AbstractFileObject +import java.io.InputStream +import java.io.OutputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream + +class S3FileObject( + private val minio: MinioClient, + fileName: AbstractFileName, + fileSystem: S3FileSystem +) : AbstractFileObject(fileName, fileSystem), FileObjectDescriptor { + private var attributes = Attributes() + + init { + attributes = attributes.copy(isRoot = name.path == fileSystem.getDelimiter()) + } + + override fun doGetContentSize(): Long { + return attributes.size + } + + override fun doGetType(): FileType { + return if (attributes.isRoot || attributes.isBucket) FileType.FOLDER + else if (attributes.isDirectory && attributes.isFile) FileType.FILE_OR_FOLDER + else if (attributes.isFile) FileType.FILE + else if (attributes.isDirectory) FileType.FOLDER + else FileType.IMAGINARY + } + + override fun doListChildren(): Array? { + return null + } + + override fun doCreateFolder() { + // Nothing + } + + private fun getBucketName(): String { + if (StringUtils.isNotBlank(attributes.bucket)) { + return attributes.bucket + } + if (parent is S3FileObject) { + return (parent as S3FileObject).getBucketName() + } + throw IllegalArgumentException("Bucket must be a S3 file object") + } + + override fun doListChildrenResolved(): Array? { + if (isFile) return null + + val children = mutableListOf() + + if (attributes.isRoot) { + val buckets = minio.listBuckets() + for (bucket in buckets) { + val file = resolveFile(bucket.name()) + if (file is S3FileObject) { + file.attributes = file.attributes.copy( + isBucket = true, + bucket = bucket.name(), + isDirectory = false, + isFile = false, + lastModified = bucket.creationDate().toInstant().toEpochMilli() + ) + children.add(file) + } + } + } else if (attributes.isBucket || attributes.isDirectory) { + val builder = ListObjectsArgs.builder().bucket(getBucketName()) + .delimiter(fileSystem.getDelimiter()) + var prefix = StringUtils.EMPTY + if (attributes.isDirectory) { + // remove first delimiter + prefix = StringUtils.removeStart(name.path, fileSystem.getDelimiter()) + // remove bucket + prefix = StringUtils.removeStart(prefix, getBucketName()) + // remove first delimiter + prefix = StringUtils.removeStart(prefix, fileSystem.getDelimiter()) + // remove last delimiter + prefix = StringUtils.removeEnd(prefix, fileSystem.getDelimiter()) + prefix = prefix + fileSystem.getDelimiter() + } + builder.prefix(prefix) + + for (e in minio.listObjects(builder.build())) { + val item = e.get() + val objectName = StringUtils.removeStart(item.objectName(), prefix) + val file = resolveFile(objectName) + if (file is S3FileObject) { + val lastModified = if (item.lastModified() != null) item.lastModified() + .toInstant().toEpochMilli() else 0 + val owner = if (item.owner() != null) item.owner().displayName() else StringUtils.EMPTY + file.attributes = file.attributes.copy( + bucket = attributes.bucket, + isDirectory = item.isDir, + isFile = item.isDir.not(), + lastModified = lastModified, + size = if (item.isDir.not()) item.size() else 0, + owner = owner + ) + children.add(file) + } + } + + } + + return children.toTypedArray() + } + + override fun getFileSystem(): S3FileSystem { + return super.getFileSystem() as S3FileSystem + } + + override fun doGetLastModifiedTime(): Long { + return attributes.lastModified + } + + override fun getIcon(width: Int, height: Int): DynamicIcon? { + if (attributes.isBucket) { + return Icons.dbms + } + return super.getIcon(width, height) + } + + override fun getTypeDescription(): String? { + if (attributes.isBucket) { + return "Bucket" + } + return null + } + + override fun getLastModified(): Long? { + return attributes.lastModified + } + + override fun getOwner(): String? { + return attributes.owner + } + + override fun doDelete() { + if (isFile) { + minio.removeObject( + RemoveObjectArgs.builder() + .bucket(getBucketName()).`object`(getObjectName()).build() + ) + } + } + + override fun doGetOutputStream(bAppend: Boolean): OutputStream? { + return createStreamer() + } + + private fun createStreamer(): OutputStream { + val pis = PipedInputStream() + val pos = PipedOutputStream(pis) + + val thread = Thread.ofVirtual().start { + minio.putObject( + PutObjectArgs.builder() + .bucket(getBucketName()) + .stream(pis, -1, 32 * 1024 * 1024) + .`object`(getObjectName()).build() + ) + IOUtils.closeQuietly(pis) + } + + return object : OutputStream() { + override fun write(b: Int) { + pos.write(b) + } + + override fun close() { + pos.close() + thread.join() + } + } + } + + override fun doGetInputStream(bufferSize: Int): InputStream? { + return minio.getObject(GetObjectArgs.builder().bucket(getBucketName()).`object`(getObjectName()).build()) + } + + private fun getObjectName(): String { + var objectName = StringUtils.removeStart(name.path, fileSystem.getDelimiter()) + objectName = StringUtils.removeStart(objectName, getBucketName()) + objectName = StringUtils.removeStart(objectName, fileSystem.getDelimiter()) + return objectName + } + + private data class Attributes( + val isRoot: Boolean = false, + val isBucket: Boolean = false, + val isDirectory: Boolean = false, + val isFile: Boolean = false, + /** + * 只要不是 root 那么一定存在 bucket + */ + val bucket: String = StringUtils.EMPTY, + /** + * 最后修改时间 + */ + val lastModified: Long = 0, + /** + * 文件大小 + */ + val size: Long = 0, + /** + * 所有者 + */ + val owner: String = StringUtils.EMPTY + ) +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileProvider.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileProvider.kt new file mode 100644 index 0000000..5620d8b --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileProvider.kt @@ -0,0 +1,47 @@ +package app.termora.plugins.s3 + +import io.minio.MinioClient +import org.apache.commons.vfs2.Capability +import org.apache.commons.vfs2.FileName +import org.apache.commons.vfs2.FileSystem +import org.apache.commons.vfs2.FileSystemOptions +import org.apache.commons.vfs2.provider.AbstractOriginatingFileProvider + +class S3FileProvider private constructor() : AbstractOriginatingFileProvider() { + + companion object { + val instance by lazy { S3FileProvider() } + val capabilities = listOf( + Capability.CREATE, + Capability.DELETE, + Capability.RENAME, + Capability.GET_TYPE, + Capability.LIST_CHILDREN, + Capability.READ_CONTENT, + Capability.WRITE_CONTENT, + Capability.GET_LAST_MODIFIED, + Capability.RANDOM_ACCESS_READ, + ) + } + + override fun getCapabilities(): Collection { + return S3FileProvider.capabilities + } + + override fun doCreateFileSystem( + rootFileName: FileName, + options: FileSystemOptions + ): FileSystem { + val region = S3FileSystemConfigBuilder.instance.getRegion(options) + val endpoint = S3FileSystemConfigBuilder.instance.getEndpoint(options) + val accessKey = S3FileSystemConfigBuilder.instance.getAccessKey(options) + val secretKey = S3FileSystemConfigBuilder.instance.getSecretKey(options) + val builder = MinioClient.builder() + builder.endpoint(endpoint) + builder.credentials(accessKey, secretKey) + if (region.isNotBlank()) builder.region(region) + return S3FileSystem(builder.build(), rootFileName, options) + } + + +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystem.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystem.kt new file mode 100644 index 0000000..63db266 --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystem.kt @@ -0,0 +1,32 @@ +package app.termora.plugins.s3 + +import io.minio.MinioClient +import org.apache.commons.vfs2.Capability +import org.apache.commons.vfs2.FileName +import org.apache.commons.vfs2.FileObject +import org.apache.commons.vfs2.FileSystemOptions +import org.apache.commons.vfs2.provider.AbstractFileName +import org.apache.commons.vfs2.provider.AbstractFileSystem + +class S3FileSystem( + private val minio: MinioClient, + rootName: FileName, + fileSystemOptions: FileSystemOptions +) : AbstractFileSystem(rootName, null, fileSystemOptions) { + + override fun addCapabilities(caps: MutableCollection) { + caps.addAll(S3FileProvider.capabilities) + } + + override fun createFile(name: AbstractFileName): FileObject? { + return S3FileObject(minio, name, this) + } + + fun getDelimiter(): String { + return S3FileSystemConfigBuilder.instance.getDelimiter(fileSystemOptions) + } + + override fun close() { + minio.close() + } +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystemConfigBuilder.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystemConfigBuilder.kt new file mode 100644 index 0000000..e6c0139 --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystemConfigBuilder.kt @@ -0,0 +1,14 @@ +package app.termora.plugins.s3 + +import app.termora.vfs2.s3.AbstractS3FileSystemConfigBuilder +import org.apache.commons.vfs2.FileSystem + +class S3FileSystemConfigBuilder private constructor() : AbstractS3FileSystemConfigBuilder() { + companion object { + val instance by lazy { S3FileSystemConfigBuilder() } + } + + override fun getConfigClass(): Class { + return S3FileSystem::class.java + } +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3HostOptionsPane.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3HostOptionsPane.kt new file mode 100644 index 0000000..426fa12 --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3HostOptionsPane.kt @@ -0,0 +1,301 @@ +package app.termora.plugins.s3 + +import app.termora.* +import app.termora.plugin.internal.BasicProxyOption +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.ui.FlatTextBorder +import com.jgoodies.forms.builder.FormBuilder +import com.jgoodies.forms.layout.FormLayout +import org.apache.commons.lang3.StringUtils +import java.awt.BorderLayout +import java.awt.KeyboardFocusManager +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import javax.swing.* + +class S3HostOptionsPane : OptionsPane() { + private val generalOption = GeneralOption() + private val proxyOption = BasicProxyOption() + private val sftpOption = SFTPOption() + + init { + addOption(generalOption) + addOption(proxyOption) + addOption(sftpOption) + + } + + + fun getHost(): Host { + val name = generalOption.nameTextField.text + val protocol = S3ProtocolProvider.PROTOCOL + val host = generalOption.hostTextField.text + val port = 0 + var authentication = Authentication.Companion.No + var proxy = Proxy.Companion.No + val authenticationType = AuthenticationType.Password + + authentication = authentication.copy( + type = authenticationType, + password = String(generalOption.passwordTextField.password) + ) + + + 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( + sftpDefaultDirectory = sftpOption.defaultDirectoryField.text, + extras = mutableMapOf( + "s3.region" to generalOption.regionTextField.text, + "s3.delimiter" to generalOption.delimiterTextField.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, + ) + } + + fun setHost(host: Host) { + generalOption.nameTextField.text = host.name + generalOption.usernameTextField.text = host.username + generalOption.hostTextField.text = host.host + generalOption.remarkTextArea.text = host.remark + generalOption.passwordTextField.text = host.authentication.password + generalOption.regionTextField.text = host.options.extras["s3.region"] ?: StringUtils.EMPTY + generalOption.delimiterTextField.text = host.options.extras["s3.delimiter"] ?: StringUtils.EMPTY + + 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 + + + sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory + } + + fun validateFields(): Boolean { + val host = getHost() + + // general + if (validateField(generalOption.nameTextField) + || validateField(generalOption.hostTextField) + ) { + return false + } + + if (validateField(generalOption.usernameTextField)) { + return false + } + + if (host.authentication.type == AuthenticationType.Password) { + if (validateField(generalOption.passwordTextField)) { + 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()) { + setOutlineError(textField) + return true + } + return false + } + + private fun setOutlineError(textField: JTextField) { + selectOptionJComponent(textField) + textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR) + textField.requestFocusInWindow() + } + + + private inner class GeneralOption : JPanel(BorderLayout()), Option { + val nameTextField = OutlineTextField(128) + val usernameTextField = OutlineTextField(128) + val hostTextField = OutlineTextField(255) + val passwordTextField = OutlinePasswordField(255) + val remarkTextArea = FixedLengthTextArea(512) + val regionTextField = OutlineTextField(128) + val delimiterTextField = OutlineTextField(128) + + init { + initView() + initEvents() + } + + private fun initView() { + delimiterTextField.text = "/" + add(getCenterComponent(), BorderLayout.CENTER) + } + + private fun initEvents() { + + addComponentListener(object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent) { + SwingUtilities.invokeLater { nameTextField.requestFocusInWindow() } + removeComponentListener(this) + } + }) + } + + + 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, $FORM_MARGIN, default:grow, $FORM_MARGIN, pref, $FORM_MARGIN, default", + "pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref" + ) + 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) + + + 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("Endpoint:").xy(1, rows) + .add(hostTextField).xyw(3, rows, 5).apply { rows += step } + + .add("AccessKey:").xy(1, rows) + .add(usernameTextField).xyw(3, rows, 5).apply { rows += step } + + .add("SecureKey:").xy(1, rows) + .add(passwordTextField).xyw(3, rows, 5).apply { rows += step } + + .add("Region:").xy(1, rows) + .add(regionTextField).xyw(3, rows, 5).apply { rows += step } + + .add("Delimiter:").xy(1, rows) + .add(delimiterTextField).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 inner class SFTPOption : JPanel(BorderLayout()), Option { + val defaultDirectoryField = OutlineTextField(255) + + + init { + initView() + initEvents() + } + + private fun initView() { + add(getCenterComponent(), BorderLayout.CENTER) + } + + private fun initEvents() { + + } + + + override fun getIcon(isSelected: Boolean): Icon { + return Icons.folder + } + + override fun getTitle(): String { + return I18n.getString("termora.transport.sftp") + } + + override fun getJComponent(): JComponent { + return this + } + + private fun getCenterComponent(): JComponent { + val layout = FormLayout( + "left:pref, $FORM_MARGIN, default:grow", + "pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref" + ) + + var rows = 1 + val step = 2 + val panel = FormBuilder.create().layout(layout) + .add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows) + .add(defaultDirectoryField).xy(3, rows).apply { rows += step } + .build() + + + return panel + } + } + + +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3Plugin.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3Plugin.kt new file mode 100644 index 0000000..050face --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3Plugin.kt @@ -0,0 +1,36 @@ +package app.termora.plugins.s3 + +import app.termora.DynamicIcon +import app.termora.I18n +import app.termora.Icons +import app.termora.plugin.Extension +import app.termora.plugin.ExtensionSupport +import app.termora.plugin.PaidPlugin +import app.termora.protocol.ProtocolHostPanelExtension +import app.termora.protocol.ProtocolProviderExtension + +class S3Plugin : PaidPlugin { + private val support = ExtensionSupport() + + init { + support.addExtension(ProtocolProviderExtension::class.java) { S3ProtocolProviderExtension.instance } + support.addExtension(ProtocolHostPanelExtension::class.java) { S3ProtocolHostPanelExtension.instance } + } + + override fun getAuthor(): String { + return "TermoraDev" + } + + + + override fun getName(): String { + return "S3" + } + + + override fun getExtensions(clazz: Class): List { + return support.getExtensions(clazz) + } + + +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolHostPanel.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolHostPanel.kt new file mode 100644 index 0000000..00c8531 --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolHostPanel.kt @@ -0,0 +1,36 @@ +package app.termora.plugins.s3 + +import app.termora.Disposer +import app.termora.Host +import app.termora.protocol.ProtocolHostPanel +import java.awt.BorderLayout + +class S3ProtocolHostPanel : ProtocolHostPanel() { + + private val pane = S3HostOptionsPane() + + init { + initView() + initEvents() + } + + + private fun initView() { + add(pane, BorderLayout.CENTER) + Disposer.register(this, pane) + } + + private fun initEvents() {} + + override fun getHost(): Host { + return pane.getHost() + } + + override fun setHost(host: Host) { + pane.setHost(host) + } + + override fun validateFields(): Boolean { + return pane.validateFields() + } +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolHostPanelExtension.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolHostPanelExtension.kt new file mode 100644 index 0000000..c214c9c --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolHostPanelExtension.kt @@ -0,0 +1,19 @@ +package app.termora.plugins.s3 + +import app.termora.protocol.ProtocolHostPanel +import app.termora.protocol.ProtocolHostPanelExtension +import app.termora.protocol.ProtocolProvider + +class S3ProtocolHostPanelExtension private constructor() : ProtocolHostPanelExtension { + companion object { + val instance by lazy { S3ProtocolHostPanelExtension() } + } + + override fun getProtocolProvider(): ProtocolProvider { + return S3ProtocolProvider.instance + } + + override fun createProtocolHostPanel(): ProtocolHostPanel { + return S3ProtocolHostPanel() + } +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProvider.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProvider.kt new file mode 100644 index 0000000..6f57a45 --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProvider.kt @@ -0,0 +1,59 @@ +package app.termora.plugins.s3 + +import app.termora.DynamicIcon +import app.termora.Icons +import app.termora.protocol.FileObjectHandler +import app.termora.protocol.FileObjectRequest +import app.termora.protocol.TransferProtocolProvider +import io.minio.MinioClient +import org.apache.commons.lang3.StringUtils +import org.apache.commons.vfs2.FileSystemOptions +import org.apache.commons.vfs2.VFS +import org.apache.commons.vfs2.provider.FileProvider + +class S3ProtocolProvider private constructor() : TransferProtocolProvider { + + companion object { + val instance by lazy { S3ProtocolProvider() } + const val PROTOCOL = "S3" + } + + override fun getProtocol(): String { + return PROTOCOL + } + + override fun getIcon(width: Int, height: Int): DynamicIcon { + return Icons.minio + } + + override fun getFileProvider(): FileProvider { + return S3FileProvider.instance + } + + override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler { + val host = requester.host + val builder = MinioClient.builder() + .endpoint(host.host) + .credentials(host.username, host.authentication.password) + val region = host.options.extras["s3.region"] + if (StringUtils.isNotBlank(region)) { + builder.region(region) + } + val delimiter = host.options.extras["s3.delimiter"] ?: "/" + val options = FileSystemOptions() + val defaultPath = host.options.sftpDefaultDirectory + + S3FileSystemConfigBuilder.instance.setRegion(options, StringUtils.defaultString(region)) + S3FileSystemConfigBuilder.instance.setEndpoint(options, host.host) + S3FileSystemConfigBuilder.instance.setAccessKey(options, host.username) + S3FileSystemConfigBuilder.instance.setSecretKey(options, host.authentication.password) + S3FileSystemConfigBuilder.instance.setDelimiter(options, delimiter) + + val file = VFS.getManager().resolveFile( + "s3://${StringUtils.defaultIfBlank(defaultPath, "/")}", + options + ) + return FileObjectHandler(file) + } + +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProviderExtension.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProviderExtension.kt new file mode 100644 index 0000000..f0068a6 --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProviderExtension.kt @@ -0,0 +1,14 @@ +package app.termora.plugins.s3 + +import app.termora.protocol.ProtocolProvider +import app.termora.protocol.ProtocolProviderExtension + +class S3ProtocolProviderExtension private constructor() : ProtocolProviderExtension { + companion object { + val instance by lazy { S3ProtocolProviderExtension() } + } + + override fun getProtocolProvider(): ProtocolProvider { + return S3ProtocolProvider.Companion.instance + } +} \ No newline at end of file diff --git a/plugins/s3/src/main/resources/META-INF/plugin.xml b/plugins/s3/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..65982c6 --- /dev/null +++ b/plugins/s3/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,24 @@ + + + s3 + + S3 + + + + ${projectVersion} + + + + app.termora.plugins.s3.S3Plugin + + + Connecting to MinIO or AWS or other S3-compliant object storage services + 支持连接到 MinIO 或 AWS 或其他兼容 S3 协议的对象存储 + 支援連接到 MinIO 或 AWS 或其他相容 S3 協定的物件存儲 + + + TermoraDev + + + diff --git a/plugins/s3/src/main/resources/META-INF/pluginIcon.svg b/plugins/s3/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..b1cb045 --- /dev/null +++ b/plugins/s3/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/s3/src/main/resources/META-INF/pluginIcon_dark.svg b/plugins/s3/src/main/resources/META-INF/pluginIcon_dark.svg new file mode 100644 index 0000000..ff17fe2 --- /dev/null +++ b/plugins/s3/src/main/resources/META-INF/pluginIcon_dark.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileProviderTest.kt b/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileProviderTest.kt new file mode 100644 index 0000000..1aee64c --- /dev/null +++ b/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileProviderTest.kt @@ -0,0 +1,112 @@ +package app.termora.plugins.s3 + +import app.termora.Authentication +import app.termora.AuthenticationType +import app.termora.Host +import app.termora.protocol.FileObjectRequest +import app.termora.vfs2.VFSWalker +import io.minio.MakeBucketArgs +import io.minio.MinioClient +import io.minio.PutObjectArgs +import org.apache.commons.vfs2.FileObject +import org.apache.commons.vfs2.VFS +import org.apache.commons.vfs2.cache.WeakRefFilesCache +import org.apache.commons.vfs2.impl.DefaultFileSystemManager +import org.testcontainers.containers.GenericContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.FileVisitor +import java.nio.file.attribute.BasicFileAttributes +import java.util.* +import kotlin.test.Test + +@Testcontainers +class S3FileProviderTest { + + private val ak = UUID.randomUUID().toString() + private val sk = UUID.randomUUID().toString() + + @Container + private val monio: GenericContainer<*> = GenericContainer("minio/minio") + .withEnv("MINIO_ACCESS_KEY", ak) + .withEnv("MINIO_SECRET_KEY", sk) + .withExposedPorts(9000, 9090) + .withCommand("server", "/data", "--console-address", ":9090", "-address", ":9000") + + companion object { + + } + + @Test + fun test() { + val endpoint = "http://127.0.0.1:${monio.getMappedPort(9000)}" + val minioClient = MinioClient.builder() + .endpoint(endpoint) + .credentials(ak, sk) + .build() + + val fileSystemManager = DefaultFileSystemManager() + fileSystemManager.addProvider("s3", S3ProtocolProvider.instance.getFileProvider()) + fileSystemManager.filesCache = WeakRefFilesCache() + fileSystemManager.init() + VFS.setManager(fileSystemManager) + + for (i in 0 until 5) { + val bucket = "bucket-$i" + minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()) + + minioClient.putObject( + PutObjectArgs.builder().bucket(bucket) + .`object`("test-1/test-2/test-3/file-$i") + .stream(ByteArrayInputStream("hello".toByteArray()), -1, 5 * 1024 * 1024) + .build() + ) + } + + val requester = FileObjectRequest( + host = Host( + name = "test", + protocol = S3ProtocolProvider.PROTOCOL, + host = endpoint, + username = ak, + authentication = Authentication.No.copy(type = AuthenticationType.Password, password = sk), + ), + ) + val file = S3ProtocolProvider.instance.getRootFileObject(requester).file + VFSWalker.walk(file, object : FileVisitor { + override fun preVisitDirectory( + dir: FileObject, + attrs: BasicFileAttributes + ): FileVisitResult { + println("preVisitDirectory: ${dir.name}") + return FileVisitResult.CONTINUE + } + + override fun visitFile( + file: FileObject, + attrs: BasicFileAttributes + ): FileVisitResult { + println("visitFile: ${file.name}") + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: FileObject, + exc: IOException + ): FileVisitResult { + return FileVisitResult.TERMINATE + } + + override fun postVisitDirectory( + dir: FileObject, + exc: IOException? + ): FileVisitResult { + return FileVisitResult.CONTINUE + } + + }) + } +} \ No newline at end of file diff --git a/plugins/sync/build.gradle.kts b/plugins/sync/build.gradle.kts new file mode 100644 index 0000000..686e6ae --- /dev/null +++ b/plugins/sync/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + + +project.version = "0.0.1" + + +dependencies { + testImplementation(kotlin("test")) + compileOnly(project(":")) +} + +apply(from = "$rootDir/plugins/common.gradle.kts") diff --git a/plugins/sync/src/main/kotlin/app/termora/plugins/sync/CloudSyncOption.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/CloudSyncOption.kt new file mode 100644 index 0000000..73c27a7 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/CloudSyncOption.kt @@ -0,0 +1,876 @@ +package app.termora.plugins.sync + +import app.termora.* +import app.termora.AES.encodeBase64String +import app.termora.Application.ohMyJson +import app.termora.account.AccountManager +import app.termora.account.AccountOwner +import app.termora.database.DatabaseManager +import app.termora.database.OwnerType +import app.termora.highlight.KeywordHighlight +import app.termora.highlight.KeywordHighlightManager +import app.termora.keymap.Keymap +import app.termora.keymap.KeymapManager +import app.termora.keymgr.KeyManager +import app.termora.keymgr.OhKeyPair +import app.termora.macro.Macro +import app.termora.macro.MacroManager +import app.termora.nv.FileChooser +import app.termora.snippet.Snippet +import app.termora.snippet.SnippetManager +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.extras.components.FlatComboBox +import com.jgoodies.forms.builder.FormBuilder +import com.jgoodies.forms.layout.FormLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.swing.Swing +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.* +import org.apache.commons.codec.binary.Base64 +import org.apache.commons.io.IOUtils +import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.SystemUtils +import org.apache.commons.lang3.exception.ExceptionUtils +import org.apache.commons.lang3.time.DateFormatUtils +import org.slf4j.LoggerFactory +import java.awt.BorderLayout +import java.awt.Component +import java.awt.event.ActionEvent +import java.awt.event.ItemEvent +import java.io.File +import java.net.URI +import java.nio.charset.StandardCharsets +import java.util.* +import java.util.function.Consumer +import javax.swing.* +import javax.swing.event.DocumentEvent + +class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption { + + companion object { + private val log = LoggerFactory.getLogger(CloudSyncOption::class.java) + } + + private val database get() = DatabaseManager.getInstance() + private val hostManager get() = HostManager.getInstance() + private val snippetManager get() = SnippetManager.getInstance() + private val keymapManager get() = KeymapManager.getInstance() + private val macroManager get() = MacroManager.getInstance() + private val keywordHighlightManager get() = KeywordHighlightManager.getInstance() + private val keyManager get() = KeyManager.getInstance() + private val formMargin = "7dlu" + private val accountManager get() = AccountManager.getInstance() + private val accountOwner + get() = AccountOwner( + id = accountManager.getAccountId(), + name = accountManager.getEmail(), + type = OwnerType.User + ) + + val typeComboBox = FlatComboBox() + val tokenTextField = OutlinePasswordField(255) + val gistTextField = OutlineTextField(255) + val policyComboBox = JComboBox() + val domainTextField = OutlineTextField(255) + val syncConfigButton = JButton(I18n.getString("termora.settings.sync"), Icons.settingSync) + val exportConfigButton = JButton(I18n.getString("termora.settings.sync.export"), Icons.export) + val importConfigButton = JButton(I18n.getString("termora.settings.sync.import"), Icons.import) + val lastSyncTimeLabel = JLabel() + val sync get() = SyncProperties.getInstance() + val hostsCheckBox = JCheckBox(I18n.getString("termora.welcome.my-hosts")) + val keysCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keys")) + val snippetsCheckBox = JCheckBox(I18n.getString("termora.snippet.title")) + val keywordHighlightsCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keyword-highlights")) + val macrosCheckBox = JCheckBox(I18n.getString("termora.macro")) + val keymapCheckBox = JCheckBox(I18n.getString("termora.settings.keymap")) + val visitGistBtn = JButton(Icons.externalLink) + val getTokenBtn = JButton(Icons.externalLink) + private val owner get() = SwingUtilities.getWindowAncestor(this) + + init { + initView() + initEvents() + add(getCenterComponent(), BorderLayout.CENTER) + } + + private fun initEvents() { + syncConfigButton.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + if (typeComboBox.selectedItem == SyncType.WebDAV) { + if (tokenTextField.password.isEmpty()) { + tokenTextField.outline = FlatClientProperties.OUTLINE_ERROR + tokenTextField.requestFocusInWindow() + return + } else if (gistTextField.text.isEmpty()) { + gistTextField.outline = FlatClientProperties.OUTLINE_ERROR + gistTextField.requestFocusInWindow() + return + } + } + swingCoroutineScope.launch(Dispatchers.IO) { sync() } + } + }) + + typeComboBox.addItemListener { + if (it.stateChange == ItemEvent.SELECTED) { + sync.type = typeComboBox.selectedItem as SyncType + + if (typeComboBox.selectedItem == SyncType.GitLab) { + if (domainTextField.text.isBlank()) { + domainTextField.text = StringUtils.defaultIfBlank(sync.domain, "https://gitlab.com/api") + } + } + + removeAll() + add(getCenterComponent(), BorderLayout.CENTER) + revalidate() + repaint() + } + } + + policyComboBox.addItemListener { + if (it.stateChange == ItemEvent.SELECTED) { + sync.policy = (policyComboBox.selectedItem as SyncPolicy).name + } + } + + tokenTextField.document.addDocumentListener(object : DocumentAdaptor() { + override fun changedUpdate(e: DocumentEvent) { + sync.token = String(tokenTextField.password) + tokenTextField.trailingComponent = if (tokenTextField.password.isEmpty()) getTokenBtn else null + } + }) + + domainTextField.document.addDocumentListener(object : DocumentAdaptor() { + override fun changedUpdate(e: DocumentEvent) { + sync.domain = domainTextField.text + } + }) + + gistTextField.document.addDocumentListener(object : DocumentAdaptor() { + override fun changedUpdate(e: DocumentEvent) { + sync.gist = gistTextField.text + gistTextField.trailingComponent = if (gistTextField.text.isNotBlank()) visitGistBtn else null + } + }) + + + visitGistBtn.addActionListener { + if (typeComboBox.selectedItem == SyncType.GitLab) { + if (domainTextField.text.isNotBlank()) { + try { + val baseUrl = URI.create(domainTextField.text) + val url = StringBuilder() + url.append(baseUrl.scheme).append("://") + url.append(baseUrl.host) + if (baseUrl.port > 0) { + url.append(":").append(baseUrl.port) + } + url.append("/-/snippets/").append(gistTextField.text) + Application.browse(URI.create(url.toString())) + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + } + } + } else if (typeComboBox.selectedItem == SyncType.GitHub) { + Application.browse(URI.create("https://gist.github.com/${gistTextField.text}")) + } + } + + getTokenBtn.addActionListener { + when (typeComboBox.selectedItem) { + SyncType.GitLab -> { + val uri = URI.create(domainTextField.text) + Application.browse(URI.create("${uri.scheme}://${uri.host}/-/user_settings/personal_access_tokens?name=Termora%20Sync%20Config&scopes=api")) + } + + SyncType.GitHub -> Application.browse(URI.create("https://github.com/settings/tokens")) + SyncType.Gitee -> Application.browse(URI.create("https://gitee.com/profile/personal_access_tokens")) + } + } + + exportConfigButton.addActionListener { export() } + importConfigButton.addActionListener { import() } + + keysCheckBox.addActionListener { refreshButtons() } + hostsCheckBox.addActionListener { refreshButtons() } + snippetsCheckBox.addActionListener { refreshButtons() } + keywordHighlightsCheckBox.addActionListener { refreshButtons() } + + } + + private suspend fun sync() { + + // 如果 gist 为空说明要创建一个 gist + if (gistTextField.text.isBlank()) { + if (!pushOrPull(true)) return + } else { + if (!pushOrPull(false)) return + if (!pushOrPull(true)) return + } + + withContext(Dispatchers.Swing) { + OptionPane.showMessageDialog(owner, message = I18n.getString("termora.settings.sync.done")) + } + } + + private fun visit(c: JComponent, consumer: Consumer) { + for (e in c.components) { + if (e is JComponent) { + consumer.accept(e) + visit(e, consumer) + } + } + } + + private fun refreshButtons() { + sync.rangeKeyPairs = keysCheckBox.isSelected + sync.rangeHosts = hostsCheckBox.isSelected + sync.rangeSnippets = snippetsCheckBox.isSelected + sync.rangeKeywordHighlights = keywordHighlightsCheckBox.isSelected + + syncConfigButton.isEnabled = keysCheckBox.isSelected || hostsCheckBox.isSelected + || keywordHighlightsCheckBox.isSelected + exportConfigButton.isEnabled = syncConfigButton.isEnabled + importConfigButton.isEnabled = syncConfigButton.isEnabled + } + + private fun export() { + + assertEventDispatchThread() + + val passwordField = OutlinePasswordField() + val panel = object : JPanel(BorderLayout()) { + override fun requestFocusInWindow(): Boolean { + return passwordField.requestFocusInWindow() + } + } + + val label = JLabel(I18n.getString("termora.settings.sync.export-encrypt") + StringUtils.SPACE.repeat(25)) + label.border = BorderFactory.createEmptyBorder(0, 0, 8, 0) + panel.add(label, BorderLayout.NORTH) + panel.add(passwordField, BorderLayout.CENTER) + + var password = StringUtils.EMPTY + + if (OptionPane.showConfirmDialog( + owner, + panel, + optionType = JOptionPane.YES_NO_OPTION, + initialValue = passwordField + ) == JOptionPane.YES_OPTION + ) { + password = String(passwordField.password).trim() + } + + + val fileChooser = FileChooser() + fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY + fileChooser.win32Filters.add(Pair("All Files", listOf("*"))) + fileChooser.win32Filters.add(Pair("JSON files", listOf("json"))) + fileChooser.showSaveDialog(owner, "${Application.getName()}.json").thenAccept { file -> + if (file != null) { + SwingUtilities.invokeLater { exportText(file, password) } + } + } + } + + private fun import() { + val fileChooser = FileChooser() + fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY + fileChooser.osxAllowedFileTypes = listOf("json") + fileChooser.win32Filters.add(Pair("JSON files", listOf("json"))) + fileChooser.showOpenDialog(owner).thenAccept { files -> + if (files.isNotEmpty()) { + SwingUtilities.invokeLater { importFromFile(files.first()) } + } + } + } + + @Suppress("DuplicatedCode") + private fun importFromFile(file: File) { + if (!file.exists()) { + return + } + + val ranges = getSyncConfig().ranges + if (ranges.isEmpty()) { + return + } + + // 最大 100MB + if (file.length() >= 1024 * 1024 * 100) { + OptionPane.showMessageDialog( + owner, I18n.getString("termora.settings.sync.import.file-too-large"), + messageType = JOptionPane.ERROR_MESSAGE + ) + return + } + + val text = file.readText() + val jsonResult = ohMyJson.runCatching { decodeFromString(text) } + if (jsonResult.isFailure) { + val e = jsonResult.exceptionOrNull() ?: return + OptionPane.showMessageDialog( + owner, ExceptionUtils.getRootCauseMessage(e), + messageType = JOptionPane.ERROR_MESSAGE + ) + return + } + + var json = jsonResult.getOrNull() ?: return + + // 如果加密了 则解密数据 + if (json["encryption"]?.jsonPrimitive?.booleanOrNull == true) { + val data = json["data"]?.jsonPrimitive?.content ?: StringUtils.EMPTY + if (data.isBlank()) { + OptionPane.showMessageDialog( + owner, "Data file corruption", + messageType = JOptionPane.ERROR_MESSAGE + ) + return + } + + while (true) { + val passwordField = OutlinePasswordField() + val panel = object : JPanel(BorderLayout()) { + override fun requestFocusInWindow(): Boolean { + return passwordField.requestFocusInWindow() + } + } + + val label = JLabel("Please enter the password" + StringUtils.SPACE.repeat(25)) + label.border = BorderFactory.createEmptyBorder(0, 0, 8, 0) + panel.add(label, BorderLayout.NORTH) + panel.add(passwordField, BorderLayout.CENTER) + + if (OptionPane.showConfirmDialog( + owner, + panel, + optionType = JOptionPane.YES_NO_OPTION, + initialValue = passwordField + ) != JOptionPane.YES_OPTION + ) { + return + } + + if (passwordField.password.isEmpty()) { + OptionPane.showMessageDialog( + owner, I18n.getString("termora.doorman.unlock-data"), + messageType = JOptionPane.ERROR_MESSAGE + ) + continue + } + + val password = String(passwordField.password) + val key = PBKDF2.generateSecret( + password.toCharArray(), + password.toByteArray(), keyLength = 128 + ) + + try { + val dataText = AES.ECB.decrypt(key, Base64.decodeBase64(data)).toString(Charsets.UTF_8) + val dataJsonResult = ohMyJson.runCatching { decodeFromString(dataText) } + if (dataJsonResult.isFailure) { + val e = dataJsonResult.exceptionOrNull() ?: return + OptionPane.showMessageDialog( + owner, ExceptionUtils.getRootCauseMessage(e), + messageType = JOptionPane.ERROR_MESSAGE + ) + return + } + json = dataJsonResult.getOrNull() ?: return + break + } catch (_: Exception) { + OptionPane.showMessageDialog( + owner, I18n.getString("termora.doorman.password-wrong"), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + + } + } + + if (ranges.contains(SyncRange.Hosts)) { + val hosts = json["hosts"] + if (hosts is JsonArray) { + ohMyJson.runCatching { decodeFromJsonElement>(hosts.jsonArray) }.onSuccess { + for (host in it) { + hostManager.addHost(host) + } + } + } + } + + if (ranges.contains(SyncRange.Snippets)) { + val snippets = json["snippets"] + if (snippets is JsonArray) { + ohMyJson.runCatching { decodeFromJsonElement>(snippets.jsonArray) }.onSuccess { + for (snippet in it) { + snippetManager.addSnippet(snippet) + } + } + } + } + + if (ranges.contains(SyncRange.KeyPairs)) { + val keyPairs = json["keyPairs"] + if (keyPairs is JsonArray) { + ohMyJson.runCatching { decodeFromJsonElement>(keyPairs.jsonArray) }.onSuccess { + for (keyPair in it) { + keyManager.addOhKeyPair(keyPair, accountOwner) + } + } + } + } + + if (ranges.contains(SyncRange.KeywordHighlights)) { + val keywordHighlights = json["keywordHighlights"] + if (keywordHighlights is JsonArray) { + ohMyJson.runCatching { decodeFromJsonElement>(keywordHighlights.jsonArray) } + .onSuccess { + for (keyPair in it) { + keywordHighlightManager.addKeywordHighlight(keyPair, accountOwner) + } + } + } + } + + if (ranges.contains(SyncRange.Macros)) { + val macros = json["macros"] + if (macros is JsonArray) { + ohMyJson.runCatching { decodeFromJsonElement>(macros.jsonArray) }.onSuccess { + for (macro in it) { + macroManager.addMacro(macro) + } + } + } + } + + if (ranges.contains(SyncRange.Keymap)) { + val keymaps = json["keymaps"] + if (keymaps is JsonArray) { + for (keymap in keymaps.jsonArray.mapNotNull { Keymap.fromJSON(it.jsonObject) }) { + keymapManager.addKeymap(keymap) + } + } + } + + OptionPane.showMessageDialog( + owner, I18n.getString("termora.settings.sync.import.successful"), + messageType = JOptionPane.INFORMATION_MESSAGE + ) + } + + private fun exportText(file: File, password: String) { + val syncConfig = getSyncConfig() + var text = ohMyJson.encodeToString(buildJsonObject { + val now = System.currentTimeMillis() + put("exporter", SystemUtils.USER_NAME) + put("version", Application.getVersion()) + put("exportDate", now) + put("os", SystemUtils.OS_NAME) + put("exportDateHuman", DateFormatUtils.ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.format(Date(now))) + if (syncConfig.ranges.contains(SyncRange.Hosts)) { + put("hosts", ohMyJson.encodeToJsonElement(hostManager.hosts())) + } + if (syncConfig.ranges.contains(SyncRange.Snippets)) { + put("snippets", ohMyJson.encodeToJsonElement(snippetManager.snippets())) + } + if (syncConfig.ranges.contains(SyncRange.KeyPairs)) { + put("keyPairs", ohMyJson.encodeToJsonElement(keyManager.getOhKeyPairs())) + } + if (syncConfig.ranges.contains(SyncRange.KeywordHighlights)) { + put( + "keywordHighlights", + ohMyJson.encodeToJsonElement(keywordHighlightManager.getKeywordHighlights()) + ) + } + if (syncConfig.ranges.contains(SyncRange.Macros)) { + put( + "macros", + ohMyJson.encodeToJsonElement(macroManager.getMacros()) + ) + } + if (syncConfig.ranges.contains(SyncRange.Keymap)) { + val keymaps = keymapManager.getKeymaps().filter { !it.isReadonly } + .map { it.toJSONObject() } + put( + "keymaps", + ohMyJson.encodeToJsonElement(keymaps) + ) + } + put("settings", buildJsonObject { + put("appearance", ohMyJson.encodeToJsonElement(database.appearance.getProperties())) + put("sync", ohMyJson.encodeToJsonElement(sync.getProperties())) + put("terminal", ohMyJson.encodeToJsonElement(database.terminal.getProperties())) + }) + }) + + if (password.isNotBlank()) { + val key = PBKDF2.generateSecret( + password.toCharArray(), + password.toByteArray(), keyLength = 128 + ) + + text = ohMyJson.encodeToString(buildJsonObject { + put("encryption", true) + put("data", AES.ECB.encrypt(key, text.toByteArray(Charsets.UTF_8)).encodeBase64String()) + }) + } + + file.outputStream().use { + IOUtils.write(text, it, StandardCharsets.UTF_8) + OptionPane.openFileInFolder( + owner, + file, I18n.getString("termora.settings.sync.export-done-open-folder"), + I18n.getString("termora.settings.sync.export-done") + ) + } + } + + private fun getSyncConfig(): SyncConfig { + val range = mutableSetOf() + if (hostsCheckBox.isSelected) { + range.add(SyncRange.Hosts) + } + if (keysCheckBox.isSelected) { + range.add(SyncRange.KeyPairs) + } + if (keywordHighlightsCheckBox.isSelected) { + range.add(SyncRange.KeywordHighlights) + } + if (macrosCheckBox.isSelected) { + range.add(SyncRange.Macros) + } + if (keymapCheckBox.isSelected) { + range.add(SyncRange.Keymap) + } + if (snippetsCheckBox.isSelected) { + range.add(SyncRange.Snippets) + } + return SyncConfig( + type = typeComboBox.selectedItem as SyncType, + token = String(tokenTextField.password), + gistId = gistTextField.text, + options = mapOf("domain" to domainTextField.text), + ranges = range + ) + } + + /** + * @return true 同步成功 + */ + @Suppress("DuplicatedCode") + private suspend fun pushOrPull(push: Boolean): Boolean { + + if (typeComboBox.selectedItem == SyncType.GitLab) { + if (domainTextField.text.isBlank()) { + withContext(Dispatchers.Swing) { + domainTextField.outline = "error" + domainTextField.requestFocusInWindow() + } + return false + } + } + + if (tokenTextField.password.isEmpty()) { + withContext(Dispatchers.Swing) { + tokenTextField.outline = "error" + tokenTextField.requestFocusInWindow() + } + return false + } + + if (gistTextField.text.isBlank() && !push) { + withContext(Dispatchers.Swing) { + gistTextField.outline = "error" + gistTextField.requestFocusInWindow() + } + return false + } + + withContext(Dispatchers.Swing) { + exportConfigButton.isEnabled = false + importConfigButton.isEnabled = false + syncConfigButton.isEnabled = false + typeComboBox.isEnabled = false + gistTextField.isEnabled = false + tokenTextField.isEnabled = false + keysCheckBox.isEnabled = false + macrosCheckBox.isEnabled = false + keymapCheckBox.isEnabled = false + keywordHighlightsCheckBox.isEnabled = false + hostsCheckBox.isEnabled = false + snippetsCheckBox.isEnabled = false + domainTextField.isEnabled = false + syncConfigButton.text = "${I18n.getString("termora.settings.sync")}..." + } + + val syncConfig = getSyncConfig() + + // sync + val syncResult = runCatching { + val syncer = SyncManager.getInstance() + if (push) { + syncer.push(syncConfig) + } else { + syncer.pull(syncConfig) + } + } + + // 恢复状态 + withContext(Dispatchers.Swing) { + syncConfigButton.isEnabled = true + exportConfigButton.isEnabled = true + importConfigButton.isEnabled = true + keysCheckBox.isEnabled = true + hostsCheckBox.isEnabled = true + snippetsCheckBox.isEnabled = true + typeComboBox.isEnabled = true + macrosCheckBox.isEnabled = true + keymapCheckBox.isEnabled = true + gistTextField.isEnabled = true + tokenTextField.isEnabled = true + domainTextField.isEnabled = true + keywordHighlightsCheckBox.isEnabled = true + syncConfigButton.text = I18n.getString("termora.settings.sync") + } + + // 如果失败,提示错误 + if (syncResult.isFailure) { + val exception = syncResult.exceptionOrNull() + var message = exception?.message ?: "Failed to sync data" + if (exception is ResponseException) { + message = "Server response: ${exception.code}" + } + + if (exception != null) { + if (log.isErrorEnabled) { + log.error(exception.message, exception) + } + } + + withContext(Dispatchers.Swing) { + OptionPane.showMessageDialog(owner, message, messageType = JOptionPane.ERROR_MESSAGE) + } + + } else { + withContext(Dispatchers.Swing) { + val now = System.currentTimeMillis() + sync.lastSyncTime = now + val date = DateFormatUtils.format(Date(now), I18n.getString("termora.date-format")) + lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: $date" + if (push && gistTextField.text.isBlank()) { + gistTextField.text = syncResult.map { it.config }.getOrDefault(syncConfig).gistId + } + } + } + + return syncResult.isSuccess + + } + + private fun initView() { + typeComboBox.addItem(SyncType.GitHub) + typeComboBox.addItem(SyncType.GitLab) + typeComboBox.addItem(SyncType.Gitee) + typeComboBox.addItem(SyncType.WebDAV) + + policyComboBox.addItem(SyncPolicy.Manual) + policyComboBox.addItem(SyncPolicy.OnChange) + + hostsCheckBox.isFocusable = false + snippetsCheckBox.isFocusable = false + keysCheckBox.isFocusable = false + keywordHighlightsCheckBox.isFocusable = false + macrosCheckBox.isFocusable = false + keymapCheckBox.isFocusable = false + + hostsCheckBox.isSelected = sync.rangeHosts + snippetsCheckBox.isSelected = sync.rangeSnippets + keysCheckBox.isSelected = sync.rangeKeyPairs + keywordHighlightsCheckBox.isSelected = sync.rangeKeywordHighlights + macrosCheckBox.isSelected = sync.rangeMacros + keymapCheckBox.isSelected = sync.rangeKeymap + + if (sync.policy == SyncPolicy.Manual.name) { + policyComboBox.selectedItem = SyncPolicy.Manual + } else if (sync.policy == SyncPolicy.OnChange.name) { + policyComboBox.selectedItem = SyncPolicy.OnChange + } + + typeComboBox.selectedItem = sync.type + gistTextField.text = sync.gist + tokenTextField.text = sync.token + domainTextField.trailingComponent = JButton(Icons.externalLink).apply { + addActionListener { + if (typeComboBox.selectedItem == SyncType.GitLab) { + Application.browse(URI.create("https://docs.gitlab.com/ee/api/snippets.html")) + + } else if (typeComboBox.selectedItem == SyncType.WebDAV) { + val url = domainTextField.text + if (url.isNullOrBlank()) { + OptionPane.showMessageDialog( + owner, + I18n.getString("termora.settings.sync.webdav.help") + ) + } else { + val uri = URI.create(url) + val sb = StringBuilder() + sb.append(uri.scheme).append("://") + if (tokenTextField.password.isNotEmpty() && gistTextField.text.isNotBlank()) { + sb.append(String(tokenTextField.password)).append(":").append(gistTextField.text) + sb.append('@') + } + sb.append(uri.authority).append(uri.path) + if (!uri.query.isNullOrBlank()) { + sb.append('?').append(uri.query) + } + Application.browse(URI.create(sb.toString())) + } + } + } + } + + if (typeComboBox.selectedItem != SyncType.Gitee) { + gistTextField.trailingComponent = if (gistTextField.text.isNotBlank()) visitGistBtn else null + } + + tokenTextField.trailingComponent = if (tokenTextField.password.isEmpty()) getTokenBtn else null + + if (domainTextField.text.isBlank()) { + if (typeComboBox.selectedItem == SyncType.GitLab) { + domainTextField.text = StringUtils.defaultIfBlank(sync.domain, "https://gitlab.com/api") + } else if (typeComboBox.selectedItem == SyncType.WebDAV) { + domainTextField.text = sync.domain + } + } + + policyComboBox.renderer = object : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + var text = value?.toString() ?: StringUtils.EMPTY + if (value == SyncPolicy.Manual) { + text = I18n.getString("termora.settings.sync.policy.manual") + } else if (value == SyncPolicy.OnChange) { + text = I18n.getString("termora.settings.sync.policy.on-change") + } + return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus) + } + } + + val lastSyncTime = sync.lastSyncTime + lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${ + if (lastSyncTime > 0) DateFormatUtils.format( + Date(lastSyncTime), I18n.getString("termora.date-format") + ) else "-" + }" + + refreshButtons() + + + } + + override fun getIcon(isSelected: Boolean): Icon { + return Icons.cloud + } + + override fun getTitle(): String { + return I18n.getString("termora.settings.sync") + } + + override fun getJComponent(): JComponent { + return this + } + + private fun getCenterComponent(): JComponent { + val layout = FormLayout( + "left:pref, $formMargin, default:grow, 30dlu", + "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" + ) + + val rangeBox = FormBuilder.create() + .layout( + FormLayout( + "left:pref, $formMargin, left:pref, $formMargin, left:pref", + "pref, 2dlu, pref" + ) + ) + .add(hostsCheckBox).xy(1, 1) + .add(keysCheckBox).xy(3, 1) + .add(keywordHighlightsCheckBox).xy(5, 1) + .add(macrosCheckBox).xy(1, 3) + .add(keymapCheckBox).xy(3, 3) + .add(snippetsCheckBox).xy(5, 3) + .build() + + var rows = 1 + val step = 2 + val builder = FormBuilder.create().layout(layout).debug(false) + val box = Box.createHorizontalBox() + box.add(typeComboBox) + if (typeComboBox.selectedItem == SyncType.GitLab || typeComboBox.selectedItem == SyncType.WebDAV) { + box.add(Box.createHorizontalStrut(4)) + box.add(domainTextField) + } + builder.add("${I18n.getString("termora.settings.sync.type")}:").xy(1, rows) + .add(box).xy(3, rows).apply { rows += step } + + val isWebDAV = typeComboBox.selectedItem == SyncType.WebDAV + + val tokenText = if (isWebDAV) { + I18n.getString("termora.new-host.general.username") + } else { + I18n.getString("termora.settings.sync.token") + } + + val gistText = if (isWebDAV) { + I18n.getString("termora.new-host.general.password") + } else { + I18n.getString("termora.settings.sync.gist") + } + + if (typeComboBox.selectedItem == SyncType.Gitee || isWebDAV) { + gistTextField.trailingComponent = null + } else { + gistTextField.trailingComponent = visitGistBtn + } + + val syncPolicyBox = Box.createHorizontalBox() + syncPolicyBox.add(policyComboBox) + syncPolicyBox.add(Box.createHorizontalGlue()) + syncPolicyBox.add(Box.createHorizontalGlue()) + + builder.add("${tokenText}:").xy(1, rows) + .add(if (isWebDAV) gistTextField else tokenTextField).xy(3, rows).apply { rows += step } + .add("${gistText}:").xy(1, rows) + .add(if (isWebDAV) tokenTextField else gistTextField).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.settings.sync.policy")}:").xy(1, rows) + .add(syncPolicyBox).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows) + .add(rangeBox).xy(3, rows).apply { rows += step } + // Sync buttons + .add( + FormBuilder.create() + .layout(FormLayout("pref, 2dlu, pref, 2dlu, pref", "pref")) + .add(syncConfigButton).xy(1, 1) + .add(exportConfigButton).xy(3, 1) + .add(importConfigButton).xy(5, 1) + .build() + ).xy(3, rows, "center, fill").apply { rows += step } + .add(lastSyncTimeLabel).xy(3, rows, "center, fill").apply { rows += step } + + + return builder.build() + + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sync/GitHubSyncer.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/GitHubSyncer.kt similarity index 97% rename from src/main/kotlin/app/termora/sync/GitHubSyncer.kt rename to plugins/sync/src/main/kotlin/app/termora/plugins/sync/GitHubSyncer.kt index 9b51fce..1095c07 100644 --- a/src/main/kotlin/app/termora/sync/GitHubSyncer.kt +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/GitHubSyncer.kt @@ -1,9 +1,8 @@ -package app.termora.sync +package app.termora.plugins.sync import app.termora.Application.ohMyJson import app.termora.ApplicationScope import app.termora.ResponseException -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.* import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody diff --git a/src/main/kotlin/app/termora/sync/GitLabSyncer.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/GitLabSyncer.kt similarity index 96% rename from src/main/kotlin/app/termora/sync/GitLabSyncer.kt rename to plugins/sync/src/main/kotlin/app/termora/plugins/sync/GitLabSyncer.kt index 50f6c34..a8a751c 100644 --- a/src/main/kotlin/app/termora/sync/GitLabSyncer.kt +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/GitLabSyncer.kt @@ -1,14 +1,14 @@ -package app.termora.sync +package app.termora.plugins.sync import app.termora.Application.ohMyJson import app.termora.ApplicationScope import app.termora.ResponseException -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.* import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import org.apache.commons.io.IOUtils import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -30,7 +30,8 @@ class GitLabSyncer private constructor() : GitSyncer() { .header("PRIVATE-TOKEN", config.token) .build() httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { + if (response.isSuccessful.not()) { + IOUtils.closeQuietly(response) throw ResponseException(response.code, response) } return parseResponse(response) diff --git a/src/main/kotlin/app/termora/sync/GitSyncer.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/GitSyncer.kt similarity index 97% rename from src/main/kotlin/app/termora/sync/GitSyncer.kt rename to plugins/sync/src/main/kotlin/app/termora/plugins/sync/GitSyncer.kt index b43c68c..edc9ea2 100644 --- a/src/main/kotlin/app/termora/sync/GitSyncer.kt +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/GitSyncer.kt @@ -1,4 +1,4 @@ -package app.termora.sync +package app.termora.plugins.sync import app.termora.Application.ohMyJson import app.termora.DeletedData @@ -7,6 +7,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import okhttp3.Request import okhttp3.Response +import org.apache.commons.io.IOUtils import org.apache.commons.lang3.ArrayUtils import org.slf4j.LoggerFactory @@ -22,7 +23,8 @@ abstract class GitSyncer : SafetySyncer() { } val response = httpClient.newCall(newPullRequestBuilder(config).build()).execute() - if (!response.isSuccessful) { + if (response.isSuccessful.not()) { + IOUtils.closeQuietly(response) throw ResponseException(response.code, response) } diff --git a/src/main/kotlin/app/termora/sync/GiteeSyncer.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/GiteeSyncer.kt similarity index 97% rename from src/main/kotlin/app/termora/sync/GiteeSyncer.kt rename to plugins/sync/src/main/kotlin/app/termora/plugins/sync/GiteeSyncer.kt index 717b7c3..fd530ab 100644 --- a/src/main/kotlin/app/termora/sync/GiteeSyncer.kt +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/GiteeSyncer.kt @@ -1,9 +1,8 @@ -package app.termora.sync +package app.termora.plugins.sync import app.termora.Application.ohMyJson import app.termora.ApplicationScope import app.termora.ResponseException -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request diff --git a/plugins/sync/src/main/kotlin/app/termora/plugins/sync/PBKDF2.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/PBKDF2.kt new file mode 100644 index 0000000..d8d39b7 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/PBKDF2.kt @@ -0,0 +1,37 @@ +package app.termora.plugins.sync + +import org.slf4j.LoggerFactory +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import kotlin.time.measureTime + +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 + } + + fun hash(slat: ByteArray, password: CharArray, iterationCount: Int, keyLength: Int): ByteArray { + val spec = PBEKeySpec(password, slat, iterationCount, keyLength) + val secretKeyFactory = SecretKeyFactory.getInstance(ALGORITHM) + return secretKeyFactory.generateSecret(spec).encoded + } + +} diff --git a/src/main/kotlin/app/termora/sync/SafetySyncer.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SafetySyncer.kt similarity index 96% rename from src/main/kotlin/app/termora/sync/SafetySyncer.kt rename to plugins/sync/src/main/kotlin/app/termora/plugins/sync/SafetySyncer.kt index 1d139bd..ca18ea6 100644 --- a/src/main/kotlin/app/termora/sync/SafetySyncer.kt +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SafetySyncer.kt @@ -1,4 +1,4 @@ -package app.termora.sync +package app.termora.plugins.sync import app.termora.* import app.termora.AES.CBC.aesCBCDecrypt @@ -6,6 +6,9 @@ import app.termora.AES.CBC.aesCBCEncrypt import app.termora.AES.decodeBase64 import app.termora.AES.encodeBase64String import app.termora.Application.ohMyJson +import app.termora.account.AccountManager +import app.termora.account.AccountOwner +import app.termora.database.OwnerType import app.termora.highlight.KeywordHighlight import app.termora.highlight.KeywordHighlightManager import app.termora.keymap.Keymap @@ -35,6 +38,13 @@ abstract class SafetySyncer : Syncer { protected val keymapManager get() = KeymapManager.getInstance() protected val snippetManager get() = SnippetManager.getInstance() protected val deleteDataManager get() = DeleteDataManager.getInstance() + protected val accountManager get() = AccountManager.getInstance() + protected val accountOwner + get() = AccountOwner( + id = accountManager.getAccountId(), + name = accountManager.getEmail(), + type = OwnerType.User + ) protected fun decodeHosts(text: String, deletedData: List, config: SyncConfig) { // aes key @@ -58,9 +68,7 @@ abstract class SafetySyncer : Syncer { val host = Host( id = encryptedHost.id, name = encryptedHost.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(), - protocol = Protocol.valueOf( - encryptedHost.protocol.decodeBase64().aesCBCDecrypt(key, iv).decodeToString() - ), + protocol = encryptedHost.protocol.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(), host = encryptedHost.host.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(), port = encryptedHost.port.decodeBase64().aesCBCDecrypt(key, iv) .decodeToString().toIntOrNull() ?: 0, @@ -113,7 +121,7 @@ abstract class SafetySyncer : Syncer { val encryptedHost = EncryptedHost() encryptedHost.id = host.id encryptedHost.name = host.name.aesCBCEncrypt(key, iv).encodeBase64String() - encryptedHost.protocol = host.protocol.name.aesCBCEncrypt(key, iv).encodeBase64String() + encryptedHost.protocol = host.protocol.aesCBCEncrypt(key, iv).encodeBase64String() encryptedHost.host = host.host.aesCBCEncrypt(key, iv).encodeBase64String() encryptedHost.port = "${host.port}".aesCBCEncrypt(key, iv).encodeBase64String() encryptedHost.username = host.username.aesCBCEncrypt(key, iv).encodeBase64String() @@ -237,7 +245,7 @@ abstract class SafetySyncer : Syncer { length = encryptedKey.length, sort = encryptedKey.sort ) - SwingUtilities.invokeLater { keyManager.addOhKeyPair(keyPair) } + SwingUtilities.invokeLater { keyManager.addOhKeyPair(keyPair, accountOwner) } } catch (e: Exception) { if (log.isWarnEnabled) { log.warn("Decode key: ${encryptedKey.id} failed. error: {}", e.message, e) @@ -298,7 +306,7 @@ abstract class SafetySyncer : Syncer { e.copy( keyword = e.keyword.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(), description = e.description.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(), - ) + ), accountOwner ) } catch (ex: Exception) { if (log.isWarnEnabled) { diff --git a/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SettingsOptionExtension.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SettingsOptionExtension.kt new file mode 100644 index 0000000..aadac46 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SettingsOptionExtension.kt @@ -0,0 +1,14 @@ +package app.termora.plugins.sync + +import app.termora.OptionsPane +import app.termora.SettingsOptionExtension + +class SyncSettingsOptionExtension private constructor() : SettingsOptionExtension { + companion object { + val instance by lazy { SyncSettingsOptionExtension() } + } + + override fun createSettingsOption(): OptionsPane.Option { + return CloudSyncOption() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sync/SyncConfig.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncConfig.kt similarity index 95% rename from src/main/kotlin/app/termora/sync/SyncConfig.kt rename to plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncConfig.kt index 54a41ae..36639c5 100644 --- a/src/main/kotlin/app/termora/sync/SyncConfig.kt +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncConfig.kt @@ -1,4 +1,4 @@ -package app.termora.sync +package app.termora.plugins.sync enum class SyncType { GitLab, diff --git a/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncDatabaseChangedExtension.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncDatabaseChangedExtension.kt new file mode 100644 index 0000000..1b99e85 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncDatabaseChangedExtension.kt @@ -0,0 +1,19 @@ +package app.termora.plugins.sync + +import app.termora.database.DatabaseChangedExtension + +class SyncDatabaseChangedExtension : DatabaseChangedExtension { + companion object { + val instance by lazy { SyncDatabaseChangedExtension() } + } + + + override fun onDataChanged( + id: String, + type: String, + action: DatabaseChangedExtension.Action, + source: DatabaseChangedExtension.Source + ) { + SyncManager.getInstance().triggerOnChanged() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sync/SyncManager.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncManager.kt similarity index 97% rename from src/main/kotlin/app/termora/sync/SyncManager.kt rename to plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncManager.kt index 50be144..819cc4b 100644 --- a/src/main/kotlin/app/termora/sync/SyncManager.kt +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncManager.kt @@ -1,7 +1,6 @@ -package app.termora.sync +package app.termora.plugins.sync import app.termora.ApplicationScope -import app.termora.Database import app.termora.Disposable import kotlinx.coroutines.* import org.slf4j.LoggerFactory @@ -18,7 +17,7 @@ class SyncManager private constructor() : Disposable { } } - private val sync get() = Database.getDatabase().sync + private val sync get() = SyncProperties.getInstance() private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var job: Job? = null private var disableTrigger = false diff --git a/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncPlugin.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncPlugin.kt new file mode 100644 index 0000000..6464045 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncPlugin.kt @@ -0,0 +1,31 @@ +package app.termora.plugins.sync + +import app.termora.SettingsOptionExtension +import app.termora.database.DatabaseChangedExtension +import app.termora.plugin.Extension +import app.termora.plugin.ExtensionSupport +import app.termora.plugin.Plugin + +class SyncPlugin : Plugin { + private val support = ExtensionSupport() + + init { + support.addExtension(SettingsOptionExtension::class.java) { SyncSettingsOptionExtension.instance } + support.addExtension(DatabaseChangedExtension::class.java) { SyncDatabaseChangedExtension.instance } + } + + override fun getAuthor(): String { + return "TermoraDev" + } + + + override fun getName(): String { + return "Sync" + } + + override fun getExtensions(clazz: Class): List { + return support.getExtensions(clazz) + } + + +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncProperties.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncProperties.kt new file mode 100644 index 0000000..f3b3ba0 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/SyncProperties.kt @@ -0,0 +1,70 @@ +package app.termora.plugins.sync + +import app.termora.ApplicationScope +import app.termora.database.DatabaseManager +import org.apache.commons.lang3.StringUtils + +/** + * 同步配置 + */ +class SyncProperties private constructor(databaseManager: DatabaseManager) : + DatabaseManager.IProperties(databaseManager, "Setting.Sync") { + + companion object { + fun getInstance(): SyncProperties { + return ApplicationScope.forApplicationScope() + .getOrCreate(SyncProperties::class) { SyncProperties(DatabaseManager.getInstance()) } + } + } + + private inner class SyncTypePropertyDelegate(defaultValue: SyncType) : + PropertyDelegate(defaultValue) { + override fun convertValue(value: String): SyncType { + return try { + SyncType.valueOf(value) + } catch (_: Exception) { + initializer.invoke() + } + } + } + + /** + * 同步类型 + */ + var type by SyncTypePropertyDelegate(SyncType.GitHub) + + /** + * 范围 + */ + var rangeHosts by BooleanPropertyDelegate(true) + var rangeKeyPairs by BooleanPropertyDelegate(true) + var rangeSnippets by BooleanPropertyDelegate(true) + var rangeKeywordHighlights by BooleanPropertyDelegate(true) + var rangeMacros by BooleanPropertyDelegate(true) + var rangeKeymap by BooleanPropertyDelegate(true) + + /** + * Token + */ + var token by StringPropertyDelegate(String()) + + /** + * Gist ID + */ + var gist by StringPropertyDelegate(String()) + + /** + * Domain + */ + var domain by StringPropertyDelegate(String()) + + /** + * 最后同步时间 + */ + var lastSyncTime by LongPropertyDelegate(0L) + + /** + * 同步策略,为空就是默认手动 + */ + var policy by StringPropertyDelegate(StringUtils.EMPTY) +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sync/Syncer.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/Syncer.kt similarity index 77% rename from src/main/kotlin/app/termora/sync/Syncer.kt rename to plugins/sync/src/main/kotlin/app/termora/plugins/sync/Syncer.kt index 945c7d8..4675d1f 100644 --- a/src/main/kotlin/app/termora/sync/Syncer.kt +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/Syncer.kt @@ -1,4 +1,4 @@ -package app.termora.sync +package app.termora.plugins.sync interface Syncer { fun pull(config: SyncConfig): GistResponse diff --git a/src/main/kotlin/app/termora/sync/WebDAVSyncer.kt b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/WebDAVSyncer.kt similarity index 96% rename from src/main/kotlin/app/termora/sync/WebDAVSyncer.kt rename to plugins/sync/src/main/kotlin/app/termora/plugins/sync/WebDAVSyncer.kt index 36fd361..01e48cb 100644 --- a/src/main/kotlin/app/termora/sync/WebDAVSyncer.kt +++ b/plugins/sync/src/main/kotlin/app/termora/plugins/sync/WebDAVSyncer.kt @@ -1,9 +1,8 @@ -package app.termora.sync +package app.termora.plugins.sync import app.termora.Application.ohMyJson import app.termora.ApplicationScope import app.termora.DeletedData -import app.termora.PBKDF2 import app.termora.ResponseException import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject @@ -13,6 +12,7 @@ import okhttp3.Credentials import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import org.apache.commons.io.IOUtils import org.slf4j.LoggerFactory class WebDAVSyncer private constructor() : SafetySyncer() { @@ -27,7 +27,8 @@ class WebDAVSyncer private constructor() : SafetySyncer() { override fun pull(config: SyncConfig): GistResponse { val response = httpClient.newCall(newRequestBuilder(config).get().build()).execute() - if (!response.isSuccessful) { + if (response.isSuccessful.not()) { + IOUtils.closeQuietly(response) if (response.code == 404) { return GistResponse(config, emptyList()) } @@ -159,7 +160,8 @@ class WebDAVSyncer private constructor() : SafetySyncer() { ).build() ).execute() - if (!response.isSuccessful) { + if (response.isSuccessful.not()) { + IOUtils.closeQuietly(response) throw ResponseException(response.code, response) } diff --git a/plugins/sync/src/main/resources/META-INF/plugin.xml b/plugins/sync/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..38640c0 --- /dev/null +++ b/plugins/sync/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,22 @@ + + + sync + + Sync + + ${projectVersion} + + + + app.termora.plugins.sync.SyncPlugin + + + Data sync to Gist or WebDAV + 支持将配置同步到 Gist 或 WebDAV + 支援將設定同步到 Gist 或 WebDAV + + + TermoraDev + + + diff --git a/plugins/sync/src/main/resources/META-INF/pluginIcon.svg b/plugins/sync/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..1c31e6b --- /dev/null +++ b/plugins/sync/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/plugins/sync/src/main/resources/META-INF/pluginIcon_dark.svg b/plugins/sync/src/main/resources/META-INF/pluginIcon_dark.svg new file mode 100644 index 0000000..eae91db --- /dev/null +++ b/plugins/sync/src/main/resources/META-INF/pluginIcon_dark.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 5509a9c..65ea550 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,3 +3,13 @@ plugins { } rootProject.name = "termora" +include("plugins:s3") +//include("plugins:oss") +//include("plugins:cos") +//include("plugins:obs") +//include("plugins:ftp") +include("plugins:bg") +include("plugins:sync") +include("plugins:migration") +include("plugins:editor") +include("plugins:geo") diff --git a/src/main/java/app/termora/AntPathMatcher.java b/src/main/java/app/termora/AntPathMatcher.java new file mode 100644 index 0000000..9b6bb16 --- /dev/null +++ b/src/main/java/app/termora/AntPathMatcher.java @@ -0,0 +1,994 @@ +/* + * Copyright 2002-2024 the original author or 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. + */ + +package app.termora; + +import org.apache.commons.lang3.ArrayUtils; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.StringTokenizer; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + *

Part of this mapping code has been kindly borrowed from Apache Ant. + * + *

The mapping matches URLs using the following rules:
+ *

    + *
  • {@code ?} matches one character
  • + *
  • {@code *} matches zero or more characters
  • + *
  • {@code **} matches zero or more directories in a path
  • + *
  • {@code {spring:[a-z]+}} matches the regexp {@code [a-z]+} as a path variable named "spring"
  • + *
+ * + *

Examples

+ *
    + *
  • {@code com/t?st.jsp} — matches {@code com/test.jsp} but also + * {@code com/tast.jsp} or {@code com/txst.jsp}
  • + *
  • {@code com/*.jsp} — matches all {@code .jsp} files in the + * {@code com} directory
  • + *
  • com/**/test.jsp — matches all {@code test.jsp} + * files underneath the {@code com} path
  • + *
  • org/springframework/**/*.jsp — matches all + * {@code .jsp} files underneath the {@code org/springframework} path
  • + *
  • org/**/servlet/bla.jsp — matches + * {@code org/springframework/servlet/bla.jsp} but also + * {@code org/springframework/testing/servlet/bla.jsp} and {@code org/servlet/bla.jsp}
  • + *
  • {@code com/{filename:\\w+}.jsp} will match {@code com/test.jsp} and assign the value {@code test} + * to the {@code filename} variable
  • + *
+ * + *

Note: a pattern and a path must both be absolute or must + * both be relative in order for the two to match. Therefore, it is recommended + * that users of this implementation to sanitize patterns in order to prefix + * them with "/" as it makes sense in the context in which they're used. + * + * @author Alef Arendsen + * @author Juergen Hoeller + * @author Rob Harrop + * @author Arjen Poutsma + * @author Rossen Stoyanchev + * @author Sam Brannen + * @author Vladislav Kisel + * @since 16.07.2003 + */ +public class AntPathMatcher { + + /** + * Default path separator: "/". + */ + public static final String DEFAULT_PATH_SEPARATOR = "/"; + + private static final int CACHE_TURNOFF_THRESHOLD = 65536; + + private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{[^/]+?\\}"); + + private static final char[] WILDCARD_CHARS = {'*', '?', '{'}; + + + private String pathSeparator; + + private PathSeparatorPatternCache pathSeparatorPatternCache; + + private boolean caseSensitive = true; + + private boolean trimTokens = false; + + private volatile @Nullable Boolean cachePatterns; + + private final Map tokenizedPatternCache = new ConcurrentHashMap<>(256); + + final Map stringMatcherCache = new ConcurrentHashMap<>(256); + + + /** + * Create a new instance with the {@link #DEFAULT_PATH_SEPARATOR}. + */ + public AntPathMatcher() { + this.pathSeparator = DEFAULT_PATH_SEPARATOR; + this.pathSeparatorPatternCache = new PathSeparatorPatternCache(DEFAULT_PATH_SEPARATOR); + } + + /** + * A convenient, alternative constructor to use with a custom path separator. + * + * @param pathSeparator the path separator to use, must not be {@code null}. + * @since 4.1 + */ + public AntPathMatcher(String pathSeparator) { + this.pathSeparator = pathSeparator; + this.pathSeparatorPatternCache = new PathSeparatorPatternCache(pathSeparator); + } + + + /** + * Set the path separator to use for pattern parsing. + *

Default is "/", as in Ant. + */ + public void setPathSeparator(@Nullable String pathSeparator) { + this.pathSeparator = (pathSeparator != null ? pathSeparator : DEFAULT_PATH_SEPARATOR); + this.pathSeparatorPatternCache = new PathSeparatorPatternCache(this.pathSeparator); + } + + /** + * Specify whether to perform pattern matching in a case-sensitive fashion. + *

Default is {@code true}. Switch this to {@code false} for case-insensitive matching. + * + * @since 4.2 + */ + public void setCaseSensitive(boolean caseSensitive) { + this.caseSensitive = caseSensitive; + } + + /** + * Specify whether to trim tokenized paths and patterns. + *

Default is {@code false}. + */ + public void setTrimTokens(boolean trimTokens) { + this.trimTokens = trimTokens; + } + + /** + * Specify whether to cache parsed pattern metadata for patterns passed + * into this matcher's {@link #match} method. A value of {@code true} + * activates an unlimited pattern cache; a value of {@code false} turns + * the pattern cache off completely. + *

Default is for the cache to be on, but with the variant to automatically + * turn it off when encountering too many patterns to cache at runtime + * (the threshold is 65536), assuming that arbitrary permutations of patterns + * are coming in, with little chance for encountering a recurring pattern. + * + * @see #getStringMatcher(String) + * @since 4.0.1 + */ + public void setCachePatterns(boolean cachePatterns) { + this.cachePatterns = cachePatterns; + } + + private void deactivatePatternCache() { + this.cachePatterns = false; + this.tokenizedPatternCache.clear(); + this.stringMatcherCache.clear(); + } + + + public boolean isPattern(@Nullable String path) { + if (path == null) { + return false; + } + boolean uriVar = false; + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c == '*' || c == '?') { + return true; + } + if (c == '{') { + uriVar = true; + continue; + } + if (c == '}' && uriVar) { + return true; + } + } + return false; + } + + + public boolean match(String pattern, String path) { + return doMatch(pattern, path, true, null); + } + + + public boolean matchStart(String pattern, String path) { + return doMatch(pattern, path, false, null); + } + + /** + * Actually match the given {@code path} against the given {@code pattern}. + * + * @param pattern the pattern to match against + * @param path the path to test + * @param fullMatch whether a full pattern match is required (else a pattern match + * as far as the given base path goes is sufficient) + * @return {@code true} if the supplied {@code path} matched, {@code false} if it didn't + */ + protected boolean doMatch(String pattern, @Nullable String path, boolean fullMatch, + @Nullable Map uriTemplateVariables) { + + if (path == null || path.startsWith(this.pathSeparator) != pattern.startsWith(this.pathSeparator)) { + return false; + } + + String[] pattDirs = tokenizePattern(pattern); + if (fullMatch && this.caseSensitive && !isPotentialMatch(path, pattDirs)) { + return false; + } + + String[] pathDirs = tokenizePath(path); + int pattIdxStart = 0; + int pattIdxEnd = pattDirs.length - 1; + int pathIdxStart = 0; + int pathIdxEnd = pathDirs.length - 1; + + // Match all elements up to the first ** + while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) { + String pattDir = pattDirs[pattIdxStart]; + if ("**".equals(pattDir)) { + break; + } + if (!matchStrings(pattDir, pathDirs[pathIdxStart], uriTemplateVariables)) { + return false; + } + pattIdxStart++; + pathIdxStart++; + } + + if (pathIdxStart > pathIdxEnd) { + // Path is exhausted, only match if rest of pattern is * or **'s + if (pattIdxStart > pattIdxEnd) { + return (pattern.endsWith(this.pathSeparator) == path.endsWith(this.pathSeparator)); + } + if (!fullMatch) { + return true; + } + if (pattIdxStart == pattIdxEnd && pattDirs[pattIdxStart].equals("*") && path.endsWith(this.pathSeparator)) { + return true; + } + for (int i = pattIdxStart; i <= pattIdxEnd; i++) { + if (!pattDirs[i].equals("**")) { + return false; + } + } + return true; + } else if (pattIdxStart > pattIdxEnd) { + // String not exhausted, but pattern is. Failure. + return false; + } else if (!fullMatch && "**".equals(pattDirs[pattIdxStart])) { + // Path start definitely matches due to "**" part in pattern. + return true; + } + + // up to last '**' + while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) { + String pattDir = pattDirs[pattIdxEnd]; + if (pattDir.equals("**")) { + break; + } + if (!matchStrings(pattDir, pathDirs[pathIdxEnd], uriTemplateVariables)) { + return false; + } + if (pattIdxEnd == (pattDirs.length - 1) && + pattern.endsWith(this.pathSeparator) != path.endsWith(this.pathSeparator)) { + return false; + } + pattIdxEnd--; + pathIdxEnd--; + } + if (pathIdxStart > pathIdxEnd) { + // String is exhausted + for (int i = pattIdxStart; i <= pattIdxEnd; i++) { + if (!pattDirs[i].equals("**")) { + return false; + } + } + return true; + } + + while (pattIdxStart != pattIdxEnd && pathIdxStart <= pathIdxEnd) { + int patIdxTmp = -1; + for (int i = pattIdxStart + 1; i <= pattIdxEnd; i++) { + if (pattDirs[i].equals("**")) { + patIdxTmp = i; + break; + } + } + if (patIdxTmp == pattIdxStart + 1) { + // '**/**' situation, so skip one + pattIdxStart++; + continue; + } + // Find the pattern between padIdxStart & padIdxTmp in str between + // strIdxStart & strIdxEnd + int patLength = (patIdxTmp - pattIdxStart - 1); + int strLength = (pathIdxEnd - pathIdxStart + 1); + int foundIdx = -1; + + strLoop: + for (int i = 0; i <= strLength - patLength; i++) { + for (int j = 0; j < patLength; j++) { + String subPat = pattDirs[pattIdxStart + j + 1]; + String subStr = pathDirs[pathIdxStart + i + j]; + if (!matchStrings(subPat, subStr, uriTemplateVariables)) { + continue strLoop; + } + } + foundIdx = pathIdxStart + i; + break; + } + + if (foundIdx == -1) { + return false; + } + + pattIdxStart = patIdxTmp; + pathIdxStart = foundIdx + patLength; + } + + for (int i = pattIdxStart; i <= pattIdxEnd; i++) { + if (!pattDirs[i].equals("**")) { + return false; + } + } + + return true; + } + + private boolean isPotentialMatch(String path, String[] pattDirs) { + if (!this.trimTokens) { + int pos = 0; + for (String pattDir : pattDirs) { + int skipped = skipSeparator(path, pos, this.pathSeparator); + pos += skipped; + skipped = skipSegment(path, pos, pattDir); + if (skipped < pattDir.length()) { + return (skipped > 0 || (pattDir.length() > 0 && isWildcardChar(pattDir.charAt(0)))); + } + pos += skipped; + } + } + return true; + } + + private int skipSegment(String path, int pos, String prefix) { + int skipped = 0; + for (int i = 0; i < prefix.length(); i++) { + char c = prefix.charAt(i); + if (isWildcardChar(c)) { + return skipped; + } + int currPos = pos + skipped; + if (currPos >= path.length()) { + return 0; + } + if (c == path.charAt(currPos)) { + skipped++; + } + } + return skipped; + } + + private int skipSeparator(String path, int pos, String separator) { + int skipped = 0; + while (path.startsWith(separator, pos + skipped)) { + skipped += separator.length(); + } + return skipped; + } + + private boolean isWildcardChar(char c) { + for (char candidate : WILDCARD_CHARS) { + if (c == candidate) { + return true; + } + } + return false; + } + + /** + * Tokenize the given path pattern into parts, based on this matcher's settings. + *

Performs caching based on {@link #setCachePatterns}, delegating to + * {@link #tokenizePath(String)} for the actual tokenization algorithm. + * + * @param pattern the pattern to tokenize + * @return the tokenized pattern parts + */ + protected String[] tokenizePattern(String pattern) { + String[] tokenized = null; + Boolean cachePatterns = this.cachePatterns; + if (cachePatterns == null || cachePatterns) { + tokenized = this.tokenizedPatternCache.get(pattern); + } + if (tokenized == null) { + tokenized = tokenizePath(pattern); + if (cachePatterns == null && this.tokenizedPatternCache.size() >= CACHE_TURNOFF_THRESHOLD) { + // Try to adapt to the runtime situation that we're encountering: + // There are obviously too many different patterns coming in here... + // So let's turn off the cache since the patterns are unlikely to be reoccurring. + deactivatePatternCache(); + return tokenized; + } + if (cachePatterns == null || cachePatterns) { + this.tokenizedPatternCache.put(pattern, tokenized); + } + } + return tokenized; + } + + /** + * Tokenize the given path into parts, based on this matcher's settings. + * + * @param path the path to tokenize + * @return the tokenized path parts + */ + protected String[] tokenizePath(String path) { + return tokenizeToStringArray(path, this.pathSeparator, this.trimTokens, true); + } + + /** + * Test whether a string matches against a pattern. + * + * @param pattern the pattern to match against (never {@code null}) + * @param str the String which must be matched against the pattern (never {@code null}) + * @return {@code true} if the string matches against the pattern, or {@code false} otherwise + */ + private boolean matchStrings(String pattern, String str, + @Nullable Map uriTemplateVariables) { + + return getStringMatcher(pattern).matchStrings(str, uriTemplateVariables); + } + + /** + * Build or retrieve an {@link AntPathStringMatcher} for the given pattern. + *

The default implementation checks this AntPathMatcher's internal cache + * (see {@link #setCachePatterns}), creating a new AntPathStringMatcher instance + * if no cached copy is found. + *

When encountering too many patterns to cache at runtime (the threshold is 65536), + * it turns the default cache off, assuming that arbitrary permutations of patterns + * are coming in, with little chance for encountering a recurring pattern. + *

This method may be overridden to implement a custom cache strategy. + * + * @param pattern the pattern to match against (never {@code null}) + * @return a corresponding AntPathStringMatcher (never {@code null}) + * @see #setCachePatterns + */ + protected AntPathStringMatcher getStringMatcher(String pattern) { + AntPathStringMatcher matcher = null; + Boolean cachePatterns = this.cachePatterns; + if (cachePatterns == null || cachePatterns) { + matcher = this.stringMatcherCache.get(pattern); + } + if (matcher == null) { + matcher = new AntPathStringMatcher(pattern, this.pathSeparator, this.caseSensitive); + if (cachePatterns == null && this.stringMatcherCache.size() >= CACHE_TURNOFF_THRESHOLD) { + // Try to adapt to the runtime situation that we're encountering: + // There are obviously too many different patterns coming in here... + // So let's turn off the cache since the patterns are unlikely to be reoccurring. + deactivatePatternCache(); + return matcher; + } + if (cachePatterns == null || cachePatterns) { + this.stringMatcherCache.put(pattern, matcher); + } + } + return matcher; + } + + /** + * Given a pattern and a full path, determine the pattern-mapped part.

For example:

    + *
  • '{@code /docs/cvs/commit.html}' and '{@code /docs/cvs/commit.html} → ''
  • + *
  • '{@code /docs/*}' and '{@code /docs/cvs/commit} → '{@code cvs/commit}'
  • + *
  • '{@code /docs/cvs/*.html}' and '{@code /docs/cvs/commit.html} → '{@code commit.html}'
  • + *
  • '{@code /docs/**}' and '{@code /docs/cvs/commit} → '{@code cvs/commit}'
  • + *
  • '{@code /docs/**\/*.html}' and '{@code /docs/cvs/commit.html} → '{@code cvs/commit.html}'
  • + *
  • '{@code /*.html}' and '{@code /docs/cvs/commit.html} → '{@code docs/cvs/commit.html}'
  • + *
  • '{@code *.html}' and '{@code /docs/cvs/commit.html} → '{@code /docs/cvs/commit.html}'
  • + *
  • '{@code *}' and '{@code /docs/cvs/commit.html} → '{@code /docs/cvs/commit.html}'
+ *

Assumes that {@link #match} returns {@code true} for '{@code pattern}' and '{@code path}', but + * does not enforce this. + */ + + public String extractPathWithinPattern(String pattern, String path) { + String[] patternParts = tokenizeToStringArray(pattern, this.pathSeparator, this.trimTokens, true); + String[] pathParts = tokenizeToStringArray(path, this.pathSeparator, this.trimTokens, true); + StringBuilder builder = new StringBuilder(); + boolean pathStarted = false; + + for (int segment = 0; segment < patternParts.length; segment++) { + String patternPart = patternParts[segment]; + if (patternPart.indexOf('*') > -1 || patternPart.indexOf('?') > -1) { + for (; segment < pathParts.length; segment++) { + if (pathStarted || (segment == 0 && !pattern.startsWith(this.pathSeparator))) { + builder.append(this.pathSeparator); + } + builder.append(pathParts[segment]); + pathStarted = true; + } + } + } + + return builder.toString(); + } + + + public Map extractUriTemplateVariables(String pattern, String path) { + Map variables = new LinkedHashMap<>(); + boolean result = doMatch(pattern, path, true, variables); + if (!result) { + throw new IllegalStateException("Pattern \"" + pattern + "\" is not a match for \"" + path + "\""); + } + return variables; + } + + /** + * Combine two patterns into a new pattern. + *

This implementation simply concatenates the two patterns, unless + * the first pattern contains a file extension match (for example, {@code *.html}). + * In that case, the second pattern will be merged into the first. Otherwise, + * an {@code IllegalArgumentException} will be thrown. + *

Examples

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Pattern 1Pattern 2Result
{@code null}{@code null} 
/hotels{@code null}/hotels
{@code null}/hotels/hotels
/hotels/bookings/hotels/bookings
/hotelsbookings/hotels/bookings
/hotels/*/bookings/hotels/bookings
/hotels/**/bookings/hotels/**/bookings
/hotels{hotel}/hotels/{hotel}
/hotels/*{hotel}/hotels/{hotel}
/hotels/**{hotel}/hotels/**/{hotel}
/*.html/hotels.html/hotels.html
/*.html/hotels/hotels.html
/*.html/*.txt{@code IllegalArgumentException}
+ * + * @param pattern1 the first pattern + * @param pattern2 the second pattern + * @return the combination of the two patterns + * @throws IllegalArgumentException if the two patterns cannot be combined + */ + + public String combine(String pattern1, String pattern2) { + if (!hasText(pattern1) && !hasText(pattern2)) { + return ""; + } + if (!hasText(pattern1)) { + return pattern2; + } + if (!hasText(pattern2)) { + return pattern1; + } + + boolean pattern1ContainsUriVar = (pattern1.indexOf('{') != -1); + if (!pattern1.equals(pattern2) && !pattern1ContainsUriVar && match(pattern1, pattern2)) { + // /* + /hotel -> /hotel ; "/*.*" + "/*.html" -> /*.html + // However /user + /user -> /usr/user ; /{foo} + /bar -> /{foo}/bar + return pattern2; + } + + // /hotels/* + /booking -> /hotels/booking + // /hotels/* + booking -> /hotels/booking + if (pattern1.endsWith(this.pathSeparatorPatternCache.getEndsOnWildCard())) { + return concat(pattern1.substring(0, pattern1.length() - 2), pattern2); + } + + // /hotels/** + /booking -> /hotels/**/booking + // /hotels/** + booking -> /hotels/**/booking + if (pattern1.endsWith(this.pathSeparatorPatternCache.getEndsOnDoubleWildCard())) { + return concat(pattern1, pattern2); + } + + int starDotPos1 = pattern1.indexOf("*."); + if (pattern1ContainsUriVar || starDotPos1 == -1 || this.pathSeparator.equals(".")) { + // simply concatenate the two patterns + return concat(pattern1, pattern2); + } + + String ext1 = pattern1.substring(starDotPos1 + 1); + int dotPos2 = pattern2.indexOf('.'); + String file2 = (dotPos2 == -1 ? pattern2 : pattern2.substring(0, dotPos2)); + String ext2 = (dotPos2 == -1 ? "" : pattern2.substring(dotPos2)); + boolean ext1All = (ext1.equals(".*") || ext1.isEmpty()); + boolean ext2All = (ext2.equals(".*") || ext2.isEmpty()); + if (!ext1All && !ext2All) { + throw new IllegalArgumentException("Cannot combine patterns: " + pattern1 + " vs " + pattern2); + } + String ext = (ext1All ? ext2 : ext1); + return file2 + ext; + } + + public static boolean hasText(@Nullable String str) { + return (str != null && !str.isBlank()); + } + + public static String[] tokenizeToStringArray( + @Nullable String str, String delimiters, boolean trimTokens, boolean ignoreEmptyTokens) { + + if (str == null) { + return ArrayUtils.EMPTY_STRING_ARRAY; + } + + StringTokenizer st = new StringTokenizer(str, delimiters); + List tokens = new ArrayList<>(); + while (st.hasMoreTokens()) { + String token = st.nextToken(); + if (trimTokens) { + token = token.trim(); + } + if (!ignoreEmptyTokens || !token.isEmpty()) { + tokens.add(token); + } + } + return toStringArray(tokens); + } + + public static String[] toStringArray(@Nullable Collection collection) { + return (!(collection == null || collection.isEmpty()) ? collection.toArray(ArrayUtils.EMPTY_STRING_ARRAY) : ArrayUtils.EMPTY_STRING_ARRAY); + } + + private String concat(String path1, String path2) { + boolean path1EndsWithSeparator = path1.endsWith(this.pathSeparator); + boolean path2StartsWithSeparator = path2.startsWith(this.pathSeparator); + + if (path1EndsWithSeparator && path2StartsWithSeparator) { + return path1 + path2.substring(1); + } else if (path1EndsWithSeparator || path2StartsWithSeparator) { + return path1 + path2; + } else { + return path1 + this.pathSeparator + path2; + } + } + + /** + * Given a full path, returns a {@link Comparator} suitable for sorting patterns in order of + * explicitness. + *

This {@code Comparator} will {@linkplain java.util.List#sort(Comparator) sort} + * a list so that more specific patterns (without URI templates or wild cards) come before + * generic patterns. So given a list with the following patterns, the returned comparator + * will sort this list so that the order will be as indicated. + *

    + *
  1. {@code /hotels/new}
  2. + *
  3. {@code /hotels/{hotel}}
  4. + *
  5. {@code /hotels/*}
  6. + *
+ *

The full path given as parameter is used to test for exact matches. So when the given path + * is {@code /hotels/2}, the pattern {@code /hotels/2} will be sorted before {@code /hotels/1}. + * + * @param path the full path to use for comparison + * @return a comparator capable of sorting patterns in order of explicitness + */ + + public Comparator getPatternComparator(String path) { + return new AntPatternComparator(path, this.pathSeparator); + } + + + /** + * Tests whether a string matches against a pattern via a {@link Pattern}. + *

The pattern may contain special characters: '*' means zero or more characters; '?' means one and + * only one character; '{' and '}' indicate a URI template pattern. For example {@code /users/{user}}. + */ + protected static class AntPathStringMatcher { + + private static final String DEFAULT_VARIABLE_PATTERN = "((?s).*)"; + + private final String rawPattern; + + private final boolean caseSensitive; + + private final boolean exactMatch; + + private final @Nullable Pattern pattern; + + private final List variableNames = new ArrayList<>(); + + protected AntPathStringMatcher(String pattern, String pathSeparator, boolean caseSensitive) { + this.rawPattern = pattern; + this.caseSensitive = caseSensitive; + StringBuilder patternBuilder = new StringBuilder(); + Matcher matcher = getGlobPattern(pathSeparator).matcher(pattern); + int end = 0; + while (matcher.find()) { + patternBuilder.append(quote(pattern, end, matcher.start())); + String match = matcher.group(); + if ("?".equals(match)) { + patternBuilder.append('.'); + } else if ("*".equals(match)) { + patternBuilder.append(".*"); + } else if (match.startsWith("{") && match.endsWith("}")) { + int colonIdx = match.indexOf(':'); + if (colonIdx == -1) { + patternBuilder.append(DEFAULT_VARIABLE_PATTERN); + this.variableNames.add(matcher.group(1)); + } else { + String variablePattern = match.substring(colonIdx + 1, match.length() - 1); + patternBuilder.append('('); + patternBuilder.append(variablePattern); + patternBuilder.append(')'); + String variableName = match.substring(1, colonIdx); + this.variableNames.add(variableName); + } + } + end = matcher.end(); + } + // No glob pattern was found, this is an exact String match + if (end == 0) { + this.exactMatch = true; + this.pattern = null; + } else { + this.exactMatch = false; + patternBuilder.append(quote(pattern, end, pattern.length())); + this.pattern = Pattern.compile(patternBuilder.toString(), + Pattern.DOTALL | (this.caseSensitive ? 0 : Pattern.CASE_INSENSITIVE)); + } + } + + private static Pattern getGlobPattern(String pathSeparator) { + String pattern = "\\?|\\*|\\{((?:\\{[^" + pathSeparator + "]+?\\}|[^" + pathSeparator + "{}]|\\\\[{}])+?)\\}"; + return Pattern.compile(pattern); + } + + private String quote(String s, int start, int end) { + if (start == end) { + return ""; + } + return Pattern.quote(s.substring(start, end)); + } + + /** + * Main entry point. + * + * @return {@code true} if the string matches against the pattern, or {@code false} otherwise. + */ + public boolean matchStrings(String str, @Nullable Map uriTemplateVariables) { + if (this.exactMatch) { + return this.caseSensitive ? this.rawPattern.equals(str) : this.rawPattern.equalsIgnoreCase(str); + } else if (this.pattern != null) { + Matcher matcher = this.pattern.matcher(str); + if (matcher.matches()) { + if (uriTemplateVariables != null) { + if (this.variableNames.size() != matcher.groupCount()) { + throw new IllegalArgumentException("The number of capturing groups in the pattern segment " + + this.pattern + " does not match the number of URI template variables it defines, " + + "which can occur if capturing groups are used in a URI template regex. " + + "Use non-capturing groups instead."); + } + for (int i = 1; i <= matcher.groupCount(); i++) { + String name = this.variableNames.get(i - 1); + if (name.startsWith("*")) { + throw new IllegalArgumentException("Capturing patterns (" + name + ") are not " + + "supported by the AntPathMatcher. Use the PathPatternParser instead."); + } + String value = matcher.group(i); + uriTemplateVariables.put(name, value); + } + } + return true; + } + } + return false; + } + + } + + + /** + * The default {@link Comparator} implementation returned by + * {@link #getPatternComparator(String)}. + *

In order, the most "generic" pattern is determined by the following: + *

    + *
  • if it's null or a capture all pattern (i.e. it is equal to "/**")
  • + *
  • if the other pattern is an actual match
  • + *
  • if it's a catch-all pattern (i.e. it ends with "**"
  • + *
  • if it's got more "*" than the other pattern
  • + *
  • if it's got more "{foo}" than the other pattern
  • + *
  • if it's shorter than the other pattern
  • + *
+ */ + protected static class AntPatternComparator implements Comparator { + + private final String path; + + private final String pathSeparator; + + public AntPatternComparator(String path) { + this(path, DEFAULT_PATH_SEPARATOR); + } + + public AntPatternComparator(String path, String pathSeparator) { + this.path = path; + this.pathSeparator = pathSeparator; + } + + /** + * Compare two patterns to determine which should match first, i.e. which + * is the most specific regarding the current path. + * + * @return a negative integer, zero, or a positive integer as pattern1 is + * more specific, equally specific, or less specific than pattern2. + */ + + public int compare(String pattern1, String pattern2) { + PatternInfo info1 = new PatternInfo(pattern1, this.pathSeparator); + PatternInfo info2 = new PatternInfo(pattern2, this.pathSeparator); + + if (info1.isLeastSpecific() && info2.isLeastSpecific()) { + return 0; + } else if (info1.isLeastSpecific()) { + return 1; + } else if (info2.isLeastSpecific()) { + return -1; + } + + boolean pattern1EqualsPath = pattern1.equals(this.path); + boolean pattern2EqualsPath = pattern2.equals(this.path); + if (pattern1EqualsPath && pattern2EqualsPath) { + return 0; + } else if (pattern1EqualsPath) { + return -1; + } else if (pattern2EqualsPath) { + return 1; + } + + if (info1.isPrefixPattern() && info2.isPrefixPattern()) { + return info2.getLength() - info1.getLength(); + } else if (info1.isPrefixPattern() && info2.getDoubleWildcards() == 0) { + return 1; + } else if (info2.isPrefixPattern() && info1.getDoubleWildcards() == 0) { + return -1; + } + + if (info1.getTotalCount() != info2.getTotalCount()) { + return info1.getTotalCount() - info2.getTotalCount(); + } + + if (info1.getLength() != info2.getLength()) { + return info2.getLength() - info1.getLength(); + } + + if (info1.getSingleWildcards() < info2.getSingleWildcards()) { + return -1; + } else if (info2.getSingleWildcards() < info1.getSingleWildcards()) { + return 1; + } + + if (info1.getUriVars() < info2.getUriVars()) { + return -1; + } else if (info2.getUriVars() < info1.getUriVars()) { + return 1; + } + + return 0; + } + + + /** + * Value class that holds information about the pattern, for example, number of + * occurrences of "*", "**", and "{" pattern elements. + */ + private static class PatternInfo { + + + private final @Nullable String pattern; + + private int uriVars; + + private int singleWildcards; + + private int doubleWildcards; + + private boolean catchAllPattern; + + private boolean prefixPattern; + + private @Nullable Integer length; + + PatternInfo(@Nullable String pattern, String pathSeparator) { + this.pattern = pattern; + if (this.pattern != null) { + initCounters(); + this.catchAllPattern = this.pattern.equals(pathSeparator + "**"); + this.prefixPattern = !this.catchAllPattern && this.pattern.endsWith(pathSeparator + "**"); + } + if (this.uriVars == 0) { + this.length = (this.pattern != null ? this.pattern.length() : 0); + } + } + + protected void initCounters() { + int pos = 0; + if (this.pattern != null) { + while (pos < this.pattern.length()) { + if (this.pattern.charAt(pos) == '{') { + this.uriVars++; + pos++; + } else if (this.pattern.charAt(pos) == '*') { + if (pos + 1 < this.pattern.length() && this.pattern.charAt(pos + 1) == '*') { + this.doubleWildcards++; + pos += 2; + } else if (pos > 0 && !this.pattern.substring(pos - 1).equals(".*")) { + this.singleWildcards++; + pos++; + } else { + pos++; + } + } else { + pos++; + } + } + } + } + + public int getUriVars() { + return this.uriVars; + } + + public int getSingleWildcards() { + return this.singleWildcards; + } + + public int getDoubleWildcards() { + return this.doubleWildcards; + } + + public boolean isLeastSpecific() { + return (this.pattern == null || this.catchAllPattern); + } + + public boolean isPrefixPattern() { + return this.prefixPattern; + } + + public int getTotalCount() { + return this.uriVars + this.singleWildcards + (2 * this.doubleWildcards); + } + + /** + * Returns the length of the given pattern, where template variables are considered to be 1 long. + */ + public int getLength() { + if (this.length == null) { + this.length = (this.pattern != null ? + VARIABLE_PATTERN.matcher(this.pattern).replaceAll("#").length() : 0); + } + return this.length; + } + } + } + + + /** + * A simple cache for patterns that depend on the configured path separator. + */ + private static class PathSeparatorPatternCache { + + private final String endsOnWildCard; + + private final String endsOnDoubleWildCard; + + public PathSeparatorPatternCache(String pathSeparator) { + this.endsOnWildCard = pathSeparator + "*"; + this.endsOnDoubleWildCard = pathSeparator + "**"; + } + + public String getEndsOnWildCard() { + return this.endsOnWildCard; + } + + public String getEndsOnDoubleWildCard() { + return this.endsOnDoubleWildCard; + } + } + +} \ No newline at end of file diff --git a/src/main/java/app/termora/CombinedKeyIdentityProvider.java b/src/main/java/app/termora/CombinedKeyIdentityProvider.java deleted file mode 100644 index 3711469..0000000 --- a/src/main/java/app/termora/CombinedKeyIdentityProvider.java +++ /dev/null @@ -1,70 +0,0 @@ -package app.termora; - -import org.apache.sshd.common.keyprovider.KeyIdentityProvider; -import org.apache.sshd.common.session.SessionContext; - -import java.io.IOException; -import java.security.GeneralSecurityException; -import java.security.KeyPair; -import java.util.*; - -@Deprecated -public class CombinedKeyIdentityProvider implements KeyIdentityProvider { - - private final List providers = new ArrayList<>(); - - @Override - public Iterable loadKeys(SessionContext context) { - return () -> new Iterator<>() { - - private final Iterator factories = providers - .iterator(); - private Iterator current; - - private Boolean hasElement; - - @Override - public boolean hasNext() { - if (hasElement != null) { - return hasElement; - } - while (current == null || !current.hasNext()) { - if (factories.hasNext()) { - try { - current = factories.next().loadKeys(context) - .iterator(); - } catch (IOException | GeneralSecurityException e) { - throw new RuntimeException(e); - } - } else { - current = null; - hasElement = Boolean.FALSE; - return false; - } - } - hasElement = Boolean.TRUE; - return true; - } - - @Override - public KeyPair next() { - if ((hasElement == null && !hasNext()) || !hasElement) { - throw new NoSuchElementException(); - } - hasElement = null; - KeyPair result; - try { - result = current.next(); - } catch (NoSuchElementException e) { - result = null; - } - return result; - } - - }; - } - - public void addKeyKeyIdentityProvider(KeyIdentityProvider provider) { - providers.add(Objects.requireNonNull(provider)); - } -} \ No newline at end of file diff --git a/src/main/java/app/termora/MyFlatTabbedPaneUI.java b/src/main/java/app/termora/MyFlatTabbedPaneUI.java deleted file mode 100644 index f3b4b6e..0000000 --- a/src/main/java/app/termora/MyFlatTabbedPaneUI.java +++ /dev/null @@ -1,140 +0,0 @@ -package app.termora; - -import com.formdev.flatlaf.ui.FlatTabbedPaneUI; -import com.formdev.flatlaf.ui.FlatUIUtils; - -import javax.swing.*; -import java.awt.*; -import java.awt.geom.Path2D; -import java.awt.geom.Rectangle2D; - -import static com.formdev.flatlaf.FlatClientProperties.*; -import static com.formdev.flatlaf.util.UIScale.scale; - -/** - * 如果要升级 FlatLaf 需要检查是否兼容 - */ -@Deprecated -public class MyFlatTabbedPaneUI extends FlatTabbedPaneUI { - @Override - protected void paintContentBorder(Graphics g, int tabPlacement, int selectedIndex) { - if (tabPane.getTabCount() <= 0 || - contentSeparatorHeight == 0 || - !clientPropertyBoolean(tabPane, TABBED_PANE_SHOW_CONTENT_SEPARATOR, showContentSeparator)) - return; - - Insets insets = tabPane.getInsets(); - Insets tabAreaInsets = getTabAreaInsets(tabPlacement); - - int x = insets.left; - int y = insets.top; - int w = tabPane.getWidth() - insets.right - insets.left; - int h = tabPane.getHeight() - insets.top - insets.bottom; - - // remove tabs from bounds - switch (tabPlacement) { - case BOTTOM: - h -= calculateTabAreaHeight(tabPlacement, runCount, maxTabHeight); - h += tabAreaInsets.top; - break; - - case LEFT: - x += calculateTabAreaWidth(tabPlacement, runCount, maxTabWidth); - x -= tabAreaInsets.right; - w -= (x - insets.left); - break; - - case RIGHT: - w -= calculateTabAreaWidth(tabPlacement, runCount, maxTabWidth); - w += tabAreaInsets.left; - break; - - case TOP: - default: - y += calculateTabAreaHeight(tabPlacement, runCount, maxTabHeight); - y -= tabAreaInsets.bottom; - h -= (y - insets.top); - break; - } - - // compute insets for separator or full border - boolean hasFullBorder = clientPropertyBoolean(tabPane, TABBED_PANE_HAS_FULL_BORDER, this.hasFullBorder); - int sh = scale(contentSeparatorHeight * 100); // multiply by 100 because rotateInsets() does not use floats - Insets ci = new Insets(0, 0, 0, 0); - rotateInsets(hasFullBorder ? new Insets(sh, sh, sh, sh) : new Insets(sh, 0, 0, 0), ci, tabPlacement); - - // create path for content separator or full border - Path2D path = new Path2D.Float(Path2D.WIND_EVEN_ODD); - path.append(new Rectangle2D.Float(x, y, w, h), false); - path.append(new Rectangle2D.Float(x + (ci.left / 100f), y + (ci.top / 100f), - w - (ci.left / 100f) - (ci.right / 100f), h - (ci.top / 100f) - (ci.bottom / 100f)), false); - - // add gap for selected tab to path - if (getTabType() == TAB_TYPE_CARD && selectedIndex >= 0) { - float csh = scale((float) contentSeparatorHeight); - - Rectangle tabRect = getTabBounds(tabPane, selectedIndex); - boolean componentHasFullBorder = false; - if (tabPane.getComponentAt(selectedIndex) instanceof JComponent c) { - componentHasFullBorder = c.getClientProperty(TABBED_PANE_HAS_FULL_BORDER) == Boolean.TRUE; - } - Rectangle2D.Float innerTabRect = new Rectangle2D.Float(tabRect.x + csh, tabRect.y + csh, - componentHasFullBorder ? 0 : tabRect.width - (csh * 2), tabRect.height - (csh * 2)); - - // Ensure that the separator outside the tabViewport is present (doesn't get cutoff by the active tab) - // If left unsolved the active tab is "visible" in the separator (the gap) even when outside the viewport - if (tabViewport != null) - Rectangle2D.intersect(tabViewport.getBounds(), innerTabRect, innerTabRect); - - Rectangle2D.Float gap = null; - if (isHorizontalTabPlacement(tabPlacement)) { - if (innerTabRect.width > 0) { - float y2 = (tabPlacement == TOP) ? y : y + h - csh; - gap = new Rectangle2D.Float(innerTabRect.x, y2, innerTabRect.width, csh); - } - } else { - if (innerTabRect.height > 0) { - float x2 = (tabPlacement == LEFT) ? x : x + w - csh; - gap = new Rectangle2D.Float(x2, innerTabRect.y, csh, innerTabRect.height); - } - } - - if (gap != null) { - path.append(gap, false); - - // fill gap in case that the tab is colored (e.g. focused or hover) - Color background = getTabBackground(tabPlacement, selectedIndex, true); - g.setColor(FlatUIUtils.deriveColor(background, tabPane.getBackground())); - ((Graphics2D) g).fill(gap); - } - } - - // paint content separator or full border - g.setColor(contentAreaColor); - ((Graphics2D) g).fill(path); - - // repaint selection in scroll-tab-layout because it may be painted before - // the content border was painted (from BasicTabbedPaneUI$ScrollableTabPanel) - if (isScrollTabLayout() && selectedIndex >= 0 && tabViewport != null) { - Rectangle tabRect = getTabBounds(tabPane, selectedIndex); - - // clip to "scrolling sides" of viewport - // (left and right if horizontal, top and bottom if vertical) - Shape oldClip = g.getClip(); - Rectangle vr = tabViewport.getBounds(); - if (isHorizontalTabPlacement(tabPlacement)) - g.clipRect(vr.x, 0, vr.width, tabPane.getHeight()); - else - g.clipRect(0, vr.y, tabPane.getWidth(), vr.height); - - paintTabSelection(g, tabPlacement, selectedIndex, tabRect.x, tabRect.y, tabRect.width, tabRect.height); - g.setClip(oldClip); - } - } - - - private boolean isScrollTabLayout() { - return tabPane.getTabLayoutPolicy() == JTabbedPane.SCROLL_TAB_LAYOUT; - } - -} diff --git a/src/main/java/app/termora/VerticalFlowLayout.java b/src/main/java/app/termora/VerticalFlowLayout.java new file mode 100644 index 0000000..37511db --- /dev/null +++ b/src/main/java/app/termora/VerticalFlowLayout.java @@ -0,0 +1,463 @@ +package app.termora; + +import javax.swing.JComponent; +import java.awt.Component; +import java.awt.Container; +import java.awt.Dimension; +import java.awt.Insets; +import java.awt.LayoutManager; + +/** + * A vertical flow layout arranges components in a top-to-bottom flow, much + * like lines of text in a paragraph. Flow layouts are typically used + * to arrange buttons in a panel. It will arrange + * buttons top to bottom until no more buttons fit on the same line. + * Each line is centered. + */ +public class VerticalFlowLayout implements LayoutManager, java.io.Serializable { + + /** + * This value indicates that each row of components + * should be left-justified. + */ + public static final int TOP = 0; + + /** + * This value indicates that each row of components + * should be centered. + */ + public static final int CENTER = 1; + + /** + * This value indicates that each row of components + * should be right-justified. + */ + public static final int BOTTOM = 2; + + /** + * align is the property that determines + * how each row distributes empty space. + * It can be one of the following values: + *
    + * TOP + * BOTTOM + * CENTER + * LEADING + * TRAILING + *
+ * + * @serial + * @see #getAlignment + * @see #setAlignment + */ + int alisgn; // This is for 1.1 serialization compatibility + + /** + * newAlign is the property that determines + * how each row distributes empty space for the Java 2 platform, + * v1.2 and greater. + * It can be one of the following three values: + *
    + * TOP + * BOTTOM + * CENTER + * LEADING + * TRAILING + *
+ * + * @serial + * @see #getAlignment + * @see #setAlignment + * @since 1.2 + */ + int newAlign; // This is the one we actually use + + /** + * The flow layout manager allows a seperation of + * components with gaps. The horizontal gap will + * specify the space between components. + * + * @serial + * @see #getHgap() + * @see #setHgap(int) + */ + protected int hgap; + + /** + * The flow layout manager allows a seperation of + * components with gaps. The vertical gap will + * specify the space between rows. + * + * @serial + * @see #getHgap() + * @see #setHgap(int) + */ + protected int vgap; + + /** + * Constructs a new FlowLayout with a centered alignment and a + * default 5-unit horizontal and vertical gap. + */ + public VerticalFlowLayout() { + this(CENTER, 5, 5); + } + + /** + * Constructs a new FlowLayout with the specified + * alignment and a default 5-unit horizontal and vertical gap. + * The value of the alignment argument must be one of + * FlowLayout.TOP, FlowLayout.BOTTOM, + * or FlowLayout.CENTER. + * + * @param align the alignment value + */ + public VerticalFlowLayout(int align) { + this(align, 5, 5); + } + + /** + * Creates a new flow layout manager with the indicated alignment + * and the indicated horizontal and vertical gaps. + *

+ * The value of the alignment argument must be one of + * FlowLayout.TOP, FlowLayout.BOTTOM, + * or FlowLayout.CENTER. + * + * @param align the alignment value + * @param hgap the horizontal gap between components + * @param vgap the vertical gap between components + */ + public VerticalFlowLayout(int align, int hgap, int vgap) { + this.hgap = hgap; + this.vgap = vgap; + + setAlignment(align); + } + + /** + * Gets the alignment for this layout. + * Possible values are FlowLayout.TOP, + * FlowLayout.BOTTOM, FlowLayout.CENTER, + * FlowLayout.LEADING, + * or FlowLayout.TRAILING. + * + * @return the alignment value for this layout + * @see java.awt.FlowLayout#setAlignment + * @since JDK1.1 + */ + public int getAlignment() { + return newAlign; + } + + /** + * Sets the alignment for this layout. + * Possible values are + *

    + *
  • FlowLayout.TOP + *
  • FlowLayout.BOTTOM + *
  • FlowLayout.CENTER + *
  • FlowLayout.LEADING + *
  • FlowLayout.TRAILING + *
+ * + * @param align one of the alignment values shown above + * @see #getAlignment() + * @since JDK1.1 + */ + public void setAlignment(int align) { + this.newAlign = align; + } + + /** + * Gets the horizontal gap between components. + * + * @return the horizontal gap between components + * @see java.awt.FlowLayout#setHgap + * @since JDK1.1 + */ + public int getHgap() { + return hgap; + } + + /** + * Sets the horizontal gap between components. + * + * @param hgap the horizontal gap between components + * @see java.awt.FlowLayout#getHgap + * @since JDK1.1 + */ + public void setHgap(int hgap) { + this.hgap = hgap; + } + + /** + * Gets the vertical gap between components. + * + * @return the vertical gap between components + * @see java.awt.FlowLayout#setVgap + * @since JDK1.1 + */ + public int getVgap() { + return vgap; + } + + /** + * Sets the vertical gap between components. + * + * @param vgap the vertical gap between components + * @see java.awt.FlowLayout#getVgap + * @since JDK1.1 + */ + public void setVgap(int vgap) { + this.vgap = vgap; + } + + /** + * Adds the specified component to the layout. Not used by this class. + * + * @param name the name of the component + * @param comp the component to be added + */ + public void addLayoutComponent(String name, Component comp) { + } + + /** + * Removes the specified component from the layout. Not used by + * this class. + * + * @param comp the component to remove + * @see java.awt.Container#removeAll + */ + public void removeLayoutComponent(Component comp) { + } + + /** + * Returns the preferred dimensions for this layout given the + * visible components in the specified target container. + * + * @param target the component which needs to be laid out + * @return the preferred dimensions to lay out the + * subcomponents of the specified container + * @see java.awt.Container + * @see #minimumLayoutSize + * @see java.awt.Container#getPreferredSize + */ + public Dimension preferredLayoutSize(Container target) { + synchronized (target.getTreeLock()) { + Dimension dim = new Dimension(0, 0); + int nmembers = target.getComponentCount(); + Boolean firstVisibleComponent = true; + + for (int i = 0; i < nmembers; i++) { + Component m = target.getComponent(i); + if (m.isVisible()) { + Dimension d = m.getPreferredSize(); + + firstVisibleComponent = dialWithDim4PreferredLayoutSize(dim, d, firstVisibleComponent); + } + } + Insets insets = target.getInsets(); + dim.width += insets.left + insets.right + hgap * 2; + dim.height += insets.top + insets.bottom + vgap * 2; + return dim; + } + } + + protected boolean dialWithDim4PreferredLayoutSize(Dimension dim, Dimension d, boolean firstVisibleComponent) { + dim.width = Math.max(dim.width, d.width); + if (firstVisibleComponent) { + firstVisibleComponent = false; + } else { + dim.height += vgap; + } + + dim.height += d.height; + + return firstVisibleComponent; + } + + /** + * Returns the minimum dimensions needed to layout the visible + * components contained in the specified target container. + * + * @param target the component which needs to be laid out + * @return the minimum dimensions to lay out the + * subcomponents of the specified container + * @see #preferredLayoutSize + * @see java.awt.Container + * @see java.awt.Container#doLayout + */ + public Dimension minimumLayoutSize(Container target) { + synchronized (target.getTreeLock()) { + Dimension dim = new Dimension(0, 0); + int nmembers = target.getComponentCount(); + boolean firstVisibleComponent = true; + + for (int i = 0; i < nmembers; i++) { + Component m = target.getComponent(i); + if (m.isVisible()) { + Dimension d = m.getMinimumSize(); + + firstVisibleComponent = dialWithDim4MinimumLayoutSize(dim, d, i, firstVisibleComponent); + } + } + Insets insets = target.getInsets(); + dim.width += insets.left + insets.right + hgap * 2; + dim.height += insets.top + insets.bottom + vgap * 2; + return dim; + } + } + + protected boolean dialWithDim4MinimumLayoutSize(Dimension dim, Dimension d, int i, boolean firstVisibleComponent) { + dim.width = Math.max(dim.width, d.width); + if (i > 0) { + dim.height += vgap; + } + dim.height += d.height; + + return firstVisibleComponent; + } + + /** + * Centers the elements in the specified row, if there is any slack. + * + * @param target the component which needs to be moved + * @param x the x coordinate + * @param y the y coordinate + * @param width the width dimensions + * @param height the height dimensions + * @param rowStart the beginning of the row + * @param rowEnd the the ending of the row + */ + private void moveComponents(Container target, int x, int y, int width, int height, + int rowStart, int rowEnd, boolean ltr) { + synchronized (target.getTreeLock()) { + switch (newAlign) { + case TOP: + y += ltr ? 0 : height; + break; + case CENTER: + y += height / 2; + break; + case BOTTOM: + y += ltr ? height : 0; + break; + } + for (int i = rowStart; i < rowEnd; i++) { + Component m = target.getComponent(i); + if (m.isVisible()) { + if (ltr) { + m.setLocation(x + (width - m.getWidth()) / 2, y); + } else { + m.setLocation(x + (width - m.getWidth()) / 2, target.getHeight() - y - m.getHeight()); + } + y += m.getHeight() + vgap; + } + } + } + } + + /** + * Lays out the container. This method lets each component take + * its preferred size by reshaping the components in the + * target container in order to satisfy the alignment of + * this FlowLayout object. + * + * @param target the specified component being laid out + * @see java.awt.Container + * @see java.awt.Container#doLayout + */ + public void layoutContainer(Container target) { + synchronized (target.getTreeLock()) { + Insets insets = target.getInsets(); + + int maxlen = getMaxLen4LayoutContainer(target, insets); + int nmembers = target.getComponentCount(); + int x = getX4LayoutContainer(insets), y = getY4LayoutContainer(insets); + int roww = 0, start = 0; + + boolean ltr = target.getComponentOrientation().isLeftToRight(); + + int[] rs; + for (int i = 0; i < nmembers; i++) { + Component m = target.getComponent(i); + if (m.isVisible()) { + Dimension d = getPreferredSize(target, m); + if (target instanceof JComponent t) { + m.setSize(t.getWidth(), d.height); + d.width = m.getWidth(); + } else { + m.setSize(d.width, d.height); + } + + rs = dealWithDim4LayoutContainer(target, insets, d, x, y, roww, start, maxlen, i, ltr); + x = rs[0]; + y = rs[1]; + roww = rs[2]; + start = rs[3]; + } + } + + dealWithMC4LayoutContainer(target, insets, x, y, roww, start, maxlen, nmembers, ltr); + } + } + + protected Dimension getPreferredSize(Container target, Component m) { + return m.getPreferredSize(); + } + + protected void dealWithMC4LayoutContainer(Container target, Insets insets, int x, int y, int roww, int start, int maxlen, int nmembers, boolean ltr) { + moveComponents(target, x, insets.top + vgap, roww, maxlen - y, start, nmembers, ltr); + } + + protected int[] dealWithDim4LayoutContainer(Container target, Insets insets, Dimension d, int x, int y, int roww, int start, int maxlen, int i, boolean ltr) { + if ((y == 0) || ((y + d.height) <= maxlen)) { + if (y > 0) y += vgap; + y += d.height; + roww = Math.max(roww, d.width); + } else { + moveComponents(target, x, insets.top + vgap, roww, maxlen - y, start, i, ltr); + y = d.height; + x += hgap + roww; + roww = d.width; + start = i; + } + return new int[]{x, y, roww, start}; + } + + protected int getMaxLen4LayoutContainer(Container target, Insets insets) { + return target.getHeight() - (insets.top + insets.bottom + vgap * 2); + } + + protected int getX4LayoutContainer(Insets insets) { + return insets.left + hgap; + } + + protected int getY4LayoutContainer(Insets insets) { + return 0; + } + + + /** + * Returns a string representation of this FlowLayout + * object and its values. + * + * @return a string representation of this layout + */ + public String toString() { + String str = ""; + switch (this.newAlign) { + case TOP: + str = ",align=top"; + break; + case CENTER: + str = ",align=center"; + break; + case BOTTOM: + str = ",align=bottom"; + break; + } + + return getClass().getName() + "[hgap=" + hgap + ",vgap=" + vgap + str + "]"; + } +} \ No newline at end of file diff --git a/src/main/java/app/termora/plugin/ExtensionProxy.java b/src/main/java/app/termora/plugin/ExtensionProxy.java new file mode 100644 index 0000000..1e25af2 --- /dev/null +++ b/src/main/java/app/termora/plugin/ExtensionProxy.java @@ -0,0 +1,43 @@ +package app.termora.plugin; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.SwingUtilities; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +record ExtensionProxy(Plugin plugin, Extension extension) implements InvocationHandler { + private static final Logger log = LoggerFactory.getLogger(ExtensionProxy.class); + + public Object getProxy() { + return Proxy.newProxyInstance(extension.getClass().getClassLoader(), extension.getClass().getInterfaces(), this); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if (extension.getDispatchThread() == DispatchThread.EDT) { + if (!SwingUtilities.isEventDispatchThread()) { + if (log.isErrorEnabled()) { + log.error("Event Dispatch Thread", new WrongThreadException("Event Dispatch Thread")); + } + } + } + + try { + return method.invoke(extension, args); + } catch (InvocationTargetException e) { + final Throwable target = e.getTargetException(); + // 尽可能避免抛出致命性错误 + if (target instanceof Error && !(target instanceof VirtualMachineError)) { + if (log.isErrorEnabled()) { + log.error("Error Invoking method {}", method.getName(), target); + } + throw new IllegalCallerException(target.getMessage(), target); + } + throw e; + } + } +} diff --git a/src/main/kotlin/app/termora/AbstractI18n.kt b/src/main/kotlin/app/termora/AbstractI18n.kt new file mode 100644 index 0000000..c0cc79c --- /dev/null +++ b/src/main/kotlin/app/termora/AbstractI18n.kt @@ -0,0 +1,37 @@ +package app.termora + +import org.apache.commons.text.StringSubstitutor +import org.slf4j.Logger +import java.text.MessageFormat +import java.util.* + +abstract class AbstractI18n { + private val log get() = getLogger() + + private val substitutor by lazy { StringSubstitutor { key -> getString(key) } } + + fun getString(key: String, vararg args: Any): String { + val text = getString(key) + if (args.isNotEmpty()) { + return MessageFormat.format(text, *args) + } + return text + } + + + fun getString(key: String): String { + try { + return substitutor.replace(getBundle().getString(key)) + } catch (e: MissingResourceException) { + if (log.isWarnEnabled) { + log.warn(e.message, e) + } + return key + } + } + + + protected abstract fun getBundle(): ResourceBundle + + protected abstract fun getLogger(): Logger +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Application.kt b/src/main/kotlin/app/termora/Application.kt index 1ca2785..7b543c2 100644 --- a/src/main/kotlin/app/termora/Application.kt +++ b/src/main/kotlin/app/termora/Application.kt @@ -10,6 +10,7 @@ import okhttp3.logging.HttpLoggingInterceptor import org.apache.commons.io.FileUtils import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.SystemUtils +import org.apache.commons.lang3.time.DateUtils import org.slf4j.LoggerFactory import java.awt.Desktop import java.io.File @@ -17,6 +18,7 @@ import java.net.URI import java.nio.file.Files import java.nio.file.Path import java.time.Duration +import java.util.* import kotlin.math.ln import kotlin.math.pow @@ -95,25 +97,55 @@ object Application { return dir } - fun getDatabaseFile(): File { - return FileUtils.getFile(getBaseDataDir(), "storage") - } - fun getVersion(): String { - var version = System.getProperty("jpackage.app-version") + var version = System.getProperty("app-version") + if (version.isNullOrBlank()) { - version = System.getProperty("app-version") + version = System.getProperty("jpackage.app-version") } + if (version.isNullOrBlank()) { - version = "unknown" + if (getAppPath().isBlank()) { + val versionFile = File("VERSION") + if (versionFile.exists() && versionFile.isFile) { + version = versionFile.readText().trim() + } + } + + if (version.isNullOrBlank()) { + version = "unknown" + } } + return version } + /** + * 未知版本通常是开发版本 + */ fun isUnknownVersion(): Boolean { return getVersion().contains("unknown") } + fun getUserAgent(): String { + return "${getName()}/${getVersion()}; ${SystemUtils.OS_NAME}/${SystemUtils.OS_VERSION}(${SystemUtils.OS_ARCH}); ${SystemUtils.JAVA_VM_NAME}/${SystemUtils.JAVA_VERSION}" + } + + /** + * 是否是测试版 + */ + fun isBetaVersion(): Boolean { + return getVersion().contains("beta") + } + + fun getReleaseDate(): Date { + val releaseDate = System.getProperty("release-date") + if (releaseDate.isNullOrBlank()) { + return Date() + } + return runCatching { DateUtils.parseDate(releaseDate, "yyyy-MM-dd") }.getOrNull() ?: Date() + } + fun getAppPath(): String { return StringUtils.defaultString(System.getProperty("jpackage.app-path")) } diff --git a/src/main/kotlin/app/termora/ApplicationInitializr.kt b/src/main/kotlin/app/termora/ApplicationInitializr.kt index ec66994..2f9b928 100644 --- a/src/main/kotlin/app/termora/ApplicationInitializr.kt +++ b/src/main/kotlin/app/termora/ApplicationInitializr.kt @@ -1,13 +1,16 @@ package app.termora +import com.formdev.flatlaf.FlatSystemProperties import com.formdev.flatlaf.util.SystemInfo import com.pty4j.util.PtyUtil import org.apache.commons.io.FileUtils import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.SystemUtils +import org.slf4j.LoggerFactory import org.tinylog.configuration.Configuration import java.io.File import kotlin.system.exitProcess +import kotlin.system.measureTimeMillis class ApplicationInitializr { @@ -29,7 +32,11 @@ class ApplicationInitializr { checkSingleton() // 启动 - ApplicationRunner().run() + val runtime = measureTimeMillis { ApplicationRunner().run() } + val log = LoggerFactory.getLogger(javaClass) + if (log.isInfoEnabled) { + log.info("Application initialization ${runtime}ms") + } } @@ -69,6 +76,17 @@ class ApplicationInitializr { if (restart4j.exists()) { System.setProperty("restarter.path", restart4j.absolutePath) } + + val sqlite = FileUtils.getFile(dylib, "sqlite-jdbc") + if (sqlite.exists()) { + System.setProperty("org.sqlite.lib.path", sqlite.absolutePath) + } + + val flatlaf = FileUtils.getFile(dylib, "flatlaf") + if (flatlaf.exists()) { + System.setProperty(FlatSystemProperties.NATIVE_LIBRARY_PATH, flatlaf.absolutePath) + } + } /** diff --git a/src/main/kotlin/app/termora/ApplicationRunner.kt b/src/main/kotlin/app/termora/ApplicationRunner.kt index 0e797df..ed97287 100644 --- a/src/main/kotlin/app/termora/ApplicationRunner.kt +++ b/src/main/kotlin/app/termora/ApplicationRunner.kt @@ -1,8 +1,12 @@ package app.termora import app.termora.actions.ActionManager +import app.termora.database.DatabaseManager import app.termora.keymap.KeymapManager -import app.termora.vfs2.sftp.MySftpFileProvider +import app.termora.plugin.ExtensionManager +import app.termora.plugin.PluginManager +import app.termora.protocol.ProtocolProvider +import app.termora.protocol.TransferProtocolProvider import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatSystemProperties import com.formdev.flatlaf.extras.FlatDesktop @@ -21,7 +25,6 @@ import org.apache.commons.lang3.SystemUtils import org.apache.commons.vfs2.VFS import org.apache.commons.vfs2.cache.WeakRefFilesCache import org.apache.commons.vfs2.impl.DefaultFileSystemManager -import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider import org.json.JSONObject import org.slf4j.LoggerFactory import java.awt.MenuItem @@ -38,68 +41,59 @@ import java.util.concurrent.CountDownLatch import javax.imageio.ImageIO import javax.swing.* import kotlin.system.exitProcess -import kotlin.system.measureTimeMillis class ApplicationRunner { private val log by lazy { LoggerFactory.getLogger(ApplicationRunner::class.java) } fun run() { - measureTimeMillis { - // 打印系统信息 - val printSystemInfo = measureTimeMillis { printSystemInfo() } + // 异步初始化 + val loadPluginThread = Thread.ofVirtual().start { PluginManager.getInstance() } - // 打开数据库 - val openDatabase = measureTimeMillis { openDatabase() } + // 打印系统信息 + printSystemInfo() - // 加载设置 - val loadSettings = measureTimeMillis { loadSettings() } + // 打开数据库 + openDatabase() - // 统计 - val enableAnalytics = measureTimeMillis { enableAnalytics() } + // 加载设置 + loadSettings() - // init ActionManager、KeymapManager、VFS - swingCoroutineScope.launch(Dispatchers.IO) { - ActionManager.getInstance() - KeymapManager.getInstance() + // 统计 + enableAnalytics() - val fileSystemManager = DefaultFileSystemManager() - fileSystemManager.addProvider("sftp", MySftpFileProvider()) - fileSystemManager.addProvider("file", DefaultLocalFileProvider()) - fileSystemManager.filesCache = WeakRefFilesCache() - fileSystemManager.init() - VFS.setManager(fileSystemManager) - - // async init - BackgroundManager.getInstance().getBackgroundImage() - } - - // 设置 LAF - val setupLaf = measureTimeMillis { setupLaf() } - - // 解密数据 - val openDoor = measureTimeMillis { openDoor() } - - // clear temporary - clearTemporary() - - // 启动主窗口 - val startMainFrame = measureTimeMillis { startMainFrame() } - - if (log.isDebugEnabled) { - log.debug("printSystemInfo: {}ms", printSystemInfo) - log.debug("openDatabase: {}ms", openDatabase) - log.debug("loadSettings: {}ms", loadSettings) - log.debug("enableAnalytics: {}ms", enableAnalytics) - log.debug("setupLaf: {}ms", setupLaf) - log.debug("openDoor: {}ms", openDoor) - log.debug("startMainFrame: {}ms", startMainFrame) - } - }.let { - if (log.isDebugEnabled) { - log.debug("run: {}ms", it) - } + // init ActionManager、KeymapManager、VFS + swingCoroutineScope.launch(Dispatchers.IO) { + ActionManager.getInstance() + KeymapManager.getInstance() } + + // 设置 LAF + setupLaf() + + // clear temporary + clearTemporary() + + // 等待插件加载完成 + loadPluginThread.join() + + // 初始化 VFS + val fileSystemManager = DefaultFileSystemManager() + for (provider in ProtocolProvider.providers.filterIsInstance()) { + fileSystemManager.addProvider(provider.getProtocol().lowercase(), provider.getFileProvider()) + } + fileSystemManager.filesCache = WeakRefFilesCache() + fileSystemManager.init() + VFS.setManager(fileSystemManager) + + // 准备就绪 + for (extension in ExtensionManager.getInstance().getExtensions(ApplicationRunnerExtension::class.java)) { + extension.ready() + } + + // 启动主窗口 + SwingUtilities.invokeLater { startMainFrame() } + } private fun clearTemporary() { @@ -110,14 +104,6 @@ class ApplicationRunner { } - private fun openDoor() { - if (Doorman.getInstance().isWorking()) { - if (!DoormanDialog(null).open()) { - exitProcess(1) - } - } - } - private fun startMainFrame() { @@ -130,8 +116,8 @@ class ApplicationRunner { // 设置 Dock setupMacOSDock() } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) + if (log.isWarnEnabled) { + log.warn(e.message, e) } } @@ -142,6 +128,9 @@ class ApplicationRunner { // 设置托盘 SwingUtilities.invokeLater { setupSystemTray() } } + + // 初始化 Scheme + OpenURIHandlers.getInstance() } private fun setupSystemTray() { @@ -186,7 +175,7 @@ class ApplicationRunner { } private fun loadSettings() { - val language = Database.getDatabase().appearance.language + val language = DatabaseManager.getInstance().appearance.language val locale = runCatching { LocaleUtils.toLocale(language) }.getOrElse { Locale.getDefault() } if (log.isInfoEnabled) { log.info("Language: {} , Locale: {}", language, locale) @@ -206,7 +195,7 @@ class ApplicationRunner { } val themeManager = ThemeManager.getInstance() - val appearance = Database.getDatabase().appearance + val appearance = DatabaseManager.getInstance().appearance var theme = appearance.theme // 如果是跟随系统 if (appearance.followSystem) { @@ -317,7 +306,8 @@ class ApplicationRunner { private fun openDatabase() { try { - Database.getDatabase() + // 初始化数据库 + DatabaseManager.getInstance() } catch (e: Exception) { if (log.isErrorEnabled) { log.error(e.message, e) @@ -365,10 +355,11 @@ class ApplicationRunner { } private fun getAnalyticsUserID(): String { - var id = Database.getDatabase().properties.getString("AnalyticsUserID") + val properties = DatabaseManager.getInstance().properties + var id = properties.getString("AnalyticsUserID") if (id.isNullOrBlank()) { - id = UUID.randomUUID().toSimpleString() - Database.getDatabase().properties.putString("AnalyticsUserID", id) + id = randomUUID() + properties.putString("AnalyticsUserID", id) } return id } diff --git a/src/main/kotlin/app/termora/ApplicationRunnerExtension.kt b/src/main/kotlin/app/termora/ApplicationRunnerExtension.kt new file mode 100644 index 0000000..238d90e --- /dev/null +++ b/src/main/kotlin/app/termora/ApplicationRunnerExtension.kt @@ -0,0 +1,15 @@ +package app.termora + +import app.termora.plugin.DispatchThread +import app.termora.plugin.Extension + +interface ApplicationRunnerExtension : Extension { + /** + * 准备就绪,说明数据库、插件、i18n 等一切数据准备就绪,下一步就是启动窗口。 + * + * 插件可以在这里初始化自己的数据 + */ + fun ready() + + override fun getDispatchThread() = DispatchThread.BGT +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/ApplicationSingleton.kt b/src/main/kotlin/app/termora/ApplicationSingleton.kt index 66f5250..5ee68e5 100644 --- a/src/main/kotlin/app/termora/ApplicationSingleton.kt +++ b/src/main/kotlin/app/termora/ApplicationSingleton.kt @@ -34,12 +34,12 @@ class ApplicationSingleton private constructor() : Disposable { try { synchronized(this) { singleton = this.isSingleton - if (singleton != null) return singleton as Boolean + if (singleton != null) return singleton if (SystemInfo.isWindows) { val handle = Kernel32.INSTANCE.CreateMutex(null, false, Application.getName()) singleton = handle != null && Kernel32.INSTANCE.GetLastError() != WinError.ERROR_ALREADY_EXISTS - if (singleton == true) { + if (singleton) { // 启动监听器,方便激活窗口 Thread.ofVirtual().start(Win32HelperWindow.getInstance()) } else { diff --git a/src/main/kotlin/app/termora/BackgroundManager.kt b/src/main/kotlin/app/termora/BackgroundManager.kt deleted file mode 100644 index ca8ab60..0000000 --- a/src/main/kotlin/app/termora/BackgroundManager.kt +++ /dev/null @@ -1,88 +0,0 @@ -package app.termora - -import org.apache.commons.lang3.StringUtils -import org.slf4j.LoggerFactory -import java.awt.image.BufferedImage -import java.io.File -import javax.imageio.ImageIO -import javax.swing.JPopupMenu -import javax.swing.SwingUtilities - -class BackgroundManager private constructor() { - companion object { - private val log = LoggerFactory.getLogger(BackgroundManager::class.java) - fun getInstance(): BackgroundManager { - return ApplicationScope.forApplicationScope().getOrCreate(BackgroundManager::class) { BackgroundManager() } - } - } - - private val appearance get() = Database.getDatabase().appearance - private var bufferedImage: BufferedImage? = null - private var imageFilepath = StringUtils.EMPTY - - fun setBackgroundImage(file: File) { - synchronized(this) { - try { - bufferedImage = file.inputStream().use { ImageIO.read(it) } - imageFilepath = file.absolutePath - appearance.backgroundImage = file.absolutePath - - SwingUtilities.invokeLater { - for (window in TermoraFrameManager.getInstance().getWindows()) { - SwingUtilities.updateComponentTreeUI(window) - } - } - - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - } - } - } - - fun getBackgroundImage(): BufferedImage? { - val bg = doGetBackgroundImage() - if (bg == null) { - if (JPopupMenu.getDefaultLightWeightPopupEnabled()) { - return null - } else { - JPopupMenu.setDefaultLightWeightPopupEnabled(true) - } - } else { - if (JPopupMenu.getDefaultLightWeightPopupEnabled()) { - JPopupMenu.setDefaultLightWeightPopupEnabled(false) - } - } - return bg - } - - private fun doGetBackgroundImage(): BufferedImage? { - synchronized(this) { - if (bufferedImage == null || imageFilepath.isEmpty()) { - if (appearance.backgroundImage.isBlank()) { - return null - } - val file = File(appearance.backgroundImage) - if (file.exists()) { - setBackgroundImage(file) - } - } - - return bufferedImage - } - } - - fun clearBackgroundImage() { - synchronized(this) { - bufferedImage = null - imageFilepath = StringUtils.EMPTY - appearance.backgroundImage = StringUtils.EMPTY - SwingUtilities.invokeLater { - for (window in TermoraFrameManager.getInstance().getWindows()) { - SwingUtilities.updateComponentTreeUI(window) - } - } - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/CheckBoxMenuItemColorIcon.kt b/src/main/kotlin/app/termora/CheckBoxMenuItemColorIcon.kt new file mode 100644 index 0000000..4a8d7a8 --- /dev/null +++ b/src/main/kotlin/app/termora/CheckBoxMenuItemColorIcon.kt @@ -0,0 +1,29 @@ +package app.termora + +import java.awt.Component +import java.awt.Graphics +import javax.swing.Icon +import javax.swing.UIManager + +class CheckBoxMenuItemColorIcon( + private val colorIcon: ColorIcon, + private val selected: Boolean, +) : Icon { + private val checkIcon = UIManager.getIcon("CheckBoxMenuItem.checkIcon") + + override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { + if (selected) { + checkIcon.paintIcon(c, g, x, y) + } + colorIcon.paintIcon(c, g, x + checkIcon.iconWidth + 6, y) + } + + override fun getIconWidth(): Int { + return colorIcon.iconWidth + checkIcon.iconWidth + 6 + } + + override fun getIconHeight(): Int { + return colorIcon.iconHeight + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/ColorHash.kt b/src/main/kotlin/app/termora/ColorHash.kt new file mode 100644 index 0000000..7e2a8f0 --- /dev/null +++ b/src/main/kotlin/app/termora/ColorHash.kt @@ -0,0 +1,19 @@ +package app.termora + +import org.apache.commons.codec.digest.MurmurHash3 +import java.awt.Color +import kotlin.math.absoluteValue + +object ColorHash { + fun hash(text: String): Color { + val hash = MurmurHash3.hash32x86(text.toByteArray()) + + val r = (hash shr 16) and 0xFF + val g = (hash shr 8) and 0xFF + val b = hash and 0xFF + + val color = Color(r, g, b) + + return color.darker() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/ColorIcon.kt b/src/main/kotlin/app/termora/ColorIcon.kt new file mode 100644 index 0000000..df1328f --- /dev/null +++ b/src/main/kotlin/app/termora/ColorIcon.kt @@ -0,0 +1,36 @@ +package app.termora + +import java.awt.Color +import java.awt.Component +import java.awt.Graphics +import java.awt.Graphics2D +import javax.swing.Icon + +class ColorIcon( + private val width: Int = 16, + private val height: Int = 16, + private val color: Color, + private val circle: Boolean = true +) : Icon { + override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { + if (g is Graphics2D) { + g.save() + setupAntialiasing(g) + g.color = color + if (circle) { + g.fillRoundRect(x, y, width, width, width, width) + } else { + g.fillRect(x, y, iconWidth, iconHeight) + } + g.restore() + } + } + + override fun getIconWidth(): Int { + return width + } + + override fun getIconHeight(): Int { + return height + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Crypto.kt b/src/main/kotlin/app/termora/Crypto.kt index f419e9b..8d87cb0 100644 --- a/src/main/kotlin/app/termora/Crypto.kt +++ b/src/main/kotlin/app/termora/Crypto.kt @@ -1,21 +1,54 @@ package app.termora +import com.fasterxml.uuid.Generators import org.apache.commons.codec.binary.Base64 import org.apache.commons.lang3.RandomUtils -import org.slf4j.LoggerFactory +import org.apache.commons.lang3.StringUtils +import java.io.InputStream import java.security.* +import java.security.spec.MGF1ParameterSpec 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 +import javax.crypto.spec.* + +private val jug = Generators.timeBasedEpochRandomGenerator(SecureRandom.getInstanceStrong()) + + +fun randomUUID(): String { + return jug.generate().toString().replace("-", StringUtils.EMPTY) +} object AES { private const val ALGORITHM = "AES" + + object GCM { + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val GCM_TAG_LENGTH = 128 + + fun encrypt(key: ByteArray, iv: ByteArray, data: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, ALGORITHM), + GCMParameterSpec(GCM_TAG_LENGTH, 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), + GCMParameterSpec(GCM_TAG_LENGTH, iv) + ) + return cipher.doFinal(data) + } + } + /** * ECB 没有 IV */ @@ -86,24 +119,11 @@ object AES { 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 + fun hash(slat: ByteArray, password: CharArray, iterationCount: Int, keyLength: Int): ByteArray { + val spec = PBEKeySpec(password, slat, iterationCount, keyLength) + val secretKeyFactory = SecretKeyFactory.getInstance(ALGORITHM) + return secretKeyFactory.generateSecret(spec).encoded } } @@ -111,45 +131,113 @@ object PBKDF2 { object RSA { - private const val TRANSFORMATION = "RSA" + + private const val TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" fun encrypt(publicKey: PublicKey, data: ByteArray): ByteArray { val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, publicKey) + cipher.init(Cipher.ENCRYPT_MODE, publicKey, getOAEPParameterSpec()) return cipher.doFinal(data) } fun decrypt(privateKey: PrivateKey, data: ByteArray): ByteArray { val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.DECRYPT_MODE, privateKey) + cipher.init(Cipher.DECRYPT_MODE, privateKey, getOAEPParameterSpec()) return cipher.doFinal(data) } fun encrypt(privateKey: PrivateKey, data: ByteArray): ByteArray { val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, privateKey) + cipher.init(Cipher.ENCRYPT_MODE, privateKey, getOAEPParameterSpec()) return cipher.doFinal(data) } fun decrypt(publicKey: PublicKey, data: ByteArray): ByteArray { val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.DECRYPT_MODE, publicKey) + cipher.init(Cipher.DECRYPT_MODE, publicKey, getOAEPParameterSpec()) return cipher.doFinal(data) } + private fun getOAEPParameterSpec(): OAEPParameterSpec { + return OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA256, + PSource.PSpecified.DEFAULT + ) + + } + fun generatePublic(publicKey: ByteArray): PublicKey { - return KeyFactory.getInstance(TRANSFORMATION) + return KeyFactory.getInstance("RSA") .generatePublic(X509EncodedKeySpec(publicKey)) } fun generatePrivate(privateKey: ByteArray): PrivateKey { - return KeyFactory.getInstance(TRANSFORMATION) + return KeyFactory.getInstance("RSA") .generatePrivate(PKCS8EncodedKeySpec(privateKey)) } - fun generateKeyPair(keySize: Int = 2048): KeyPair { - val generator = KeyPairGenerator.getInstance(TRANSFORMATION) + fun generateKeyPair(keySize: Int): KeyPair { + val generator = KeyPairGenerator.getInstance("RSA") generator.initialize(keySize) return generator.generateKeyPair() } + + fun sign(privateKey: PrivateKey, data: ByteArray): ByteArray { + val rsa = Signature.getInstance("SHA256withRSA") + rsa.initSign(privateKey) + rsa.update(data) + return rsa.sign() + } + + fun verify(publicKey: PublicKey, data: ByteArray, signature: ByteArray): Boolean { + val rsa = Signature.getInstance("SHA256withRSA") + rsa.initVerify(publicKey) + rsa.update(data) + return rsa.verify(signature) + } +} + + +object Ed25519 { + fun sign(privateKey: PrivateKey, data: ByteArray): ByteArray { + val signer = Signature.getInstance("Ed25519") + signer.initSign(privateKey) + signer.update(data) + return signer.sign() + } + + fun verify(publicKey: PublicKey, data: ByteArray, signature: ByteArray): Boolean { + return verify(publicKey, data.inputStream(), signature) + } + + fun verify(publicKey: PublicKey, input: InputStream, signature: ByteArray): Boolean { + return runCatching { + val verifier = Signature.getInstance("Ed25519") + verifier.initVerify(publicKey) + val buffer = ByteArray(1024) + var len = 0 + while ((input.read(buffer).also { len = it }) != -1) { + verifier.update(buffer, 0, len) + } + verifier.verify(signature) + }.getOrNull() ?: false + } + + + fun generatePublic(publicKey: ByteArray): PublicKey { + return KeyFactory.getInstance("Ed25519") + .generatePublic(X509EncodedKeySpec(publicKey)) + } + + fun generatePrivate(privateKey: ByteArray): PrivateKey { + return KeyFactory.getInstance("Ed25519") + .generatePrivate(PKCS8EncodedKeySpec(privateKey)) + } + + fun generateKeyPair(): KeyPair { + val generator = KeyPairGenerator.getInstance("Ed25519") + return generator.generateKeyPair() + } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/CustomizeToolBarDialog.kt b/src/main/kotlin/app/termora/CustomizeToolBarDialog.kt index fb54b6b..8b73b73 100644 --- a/src/main/kotlin/app/termora/CustomizeToolBarDialog.kt +++ b/src/main/kotlin/app/termora/CustomizeToolBarDialog.kt @@ -2,6 +2,7 @@ package app.termora import app.termora.Application.ohMyJson import app.termora.actions.MultipleAction +import app.termora.database.DatabaseManager import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.layout.FormLayout import org.apache.commons.lang3.StringUtils @@ -364,7 +365,7 @@ class CustomizeToolBarDialog( actions.add(ToolBarAction(leftList.model.getElementAt(i).id, false)) } - Database.getDatabase() + DatabaseManager.getInstance() .properties.putString("Termora.ToolBar.Actions", ohMyJson.encodeToString(actions)) super.doOKAction() diff --git a/src/main/kotlin/app/termora/DeleteDataManager.kt b/src/main/kotlin/app/termora/DeleteDataManager.kt index e5a28a1..07835d1 100644 --- a/src/main/kotlin/app/termora/DeleteDataManager.kt +++ b/src/main/kotlin/app/termora/DeleteDataManager.kt @@ -1,5 +1,7 @@ package app.termora +import app.termora.database.DatabaseManager + /** * 仅标记 */ @@ -11,7 +13,7 @@ class DeleteDataManager private constructor() { } private val data = mutableMapOf() - private val database get() = Database.getDatabase() + private val database get() = DatabaseManager.getInstance() fun removeHost(id: String, deleteDate: Long = System.currentTimeMillis()) { addDeletedData(DeletedData(id, "Host", deleteDate)) @@ -40,12 +42,12 @@ class DeleteDataManager private constructor() { private fun addDeletedData(deletedData: DeletedData) { if (data.containsKey(deletedData.id)) return data[deletedData.id] = deletedData - database.addDeletedData(deletedData) + // TODO database.addDeletedData(deletedData) } fun getDeletedData(): List { if (data.isEmpty()) { - data.putAll(database.getDeletedData().associateBy { it.id }) + // TODO data.putAll(database.getDeletedData().associateBy { it.id }) } return data.values.sortedBy { it.deleteDate } } diff --git a/src/main/kotlin/app/termora/DialogWrapper.kt b/src/main/kotlin/app/termora/DialogWrapper.kt index 9d625b9..f0b1419 100644 --- a/src/main/kotlin/app/termora/DialogWrapper.kt +++ b/src/main/kotlin/app/termora/DialogWrapper.kt @@ -2,7 +2,7 @@ package app.termora import app.termora.actions.AnAction import app.termora.actions.AnActionEvent -import app.termora.native.osx.NativeMacLibrary +import app.termora.nv.osx.NativeMacLibrary import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.util.SystemInfo import com.jetbrains.JBR @@ -147,13 +147,17 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) { } protected open fun createActions(): List { - return listOf(createOkAction(), CancelAction()) + return listOf(createOkAction(), createCancelAction()) } protected open fun createOkAction(): AbstractAction { return OkAction() } + protected open fun createCancelAction(): AbstractAction { + return CancelAction() + } + protected open fun createJButtonForAction(action: Action): JButton { val button = JButton(action) val value = action.getValue(DEFAULT_ACTION) @@ -196,30 +200,32 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) { rootPane.actionMap.put("close", object : AnAction() { override fun actionPerformed(evt: AnActionEvent) { val c = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner - val popups: List = SwingUtils.getDescendantsOfType( - JPopupMenu::class.java, - c as Container, true - ) + if (c != null) { + val popups: List = SwingUtils.getDescendantsOfType( + JPopupMenu::class.java, + c as Container, true + ) - var openPopup = false - for (p in popups) { - p.isVisible = false - openPopup = true - } + var openPopup = false + for (p in popups) { + p.isVisible = false + openPopup = true + } - val window = c as? Window ?: SwingUtilities.windowForComponent(c) - if (window != null) { - val windows = window.ownedWindows - for (w in windows) { - if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) { - openPopup = true - w.dispose() + val window = c as? Window ?: SwingUtilities.windowForComponent(c) + if (window != null) { + val windows = window.ownedWindows + for (w in windows) { + if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) { + openPopup = true + w.dispose() + } } } - } - if (openPopup) { - return + if (openPopup) { + return + } } SwingUtilities.invokeLater { doCancelAction() } diff --git a/src/main/kotlin/app/termora/DynamicColor.kt b/src/main/kotlin/app/termora/DynamicColor.kt index 31faa2a..36f8dc3 100644 --- a/src/main/kotlin/app/termora/DynamicColor.kt +++ b/src/main/kotlin/app/termora/DynamicColor.kt @@ -13,7 +13,11 @@ open class DynamicColor : Color { val r = regular val d = dark if (r == null || d == null) { - return UIManager.getColor(colorKey) + try { + return UIManager.getColor(colorKey) + } catch (e: Exception) { + TODO("Not yet implemented") + } } return if (FlatLaf.isLafDark()) d else r } diff --git a/src/main/kotlin/app/termora/EditHostOptionsPane.kt b/src/main/kotlin/app/termora/EditHostOptionsPane.kt deleted file mode 100644 index e8b6f58..0000000 --- a/src/main/kotlin/app/termora/EditHostOptionsPane.kt +++ /dev/null @@ -1,77 +0,0 @@ -package app.termora - -import org.apache.commons.lang3.StringUtils - -@Suppress("CascadeIf") -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.remarkTextArea.text = host.remark - generalOption.authenticationTypeComboBox.selectedItem = host.authentication.type - if (host.authentication.type == AuthenticationType.Password) { - generalOption.passwordTextField.text = host.authentication.password - } else if (host.authentication.type == AuthenticationType.PublicKey) { - generalOption.publicKeyComboBox.selectedItem = host.authentication.password - } else if (host.authentication.type == AuthenticationType.SSHAgent) { - generalOption.sshAgentComboBox.selectedItem = host.authentication.password - } - - 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 - terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval - - tunnelingOption.tunnelings.addAll(host.tunnelings) - tunnelingOption.x11ForwardingCheckBox.isSelected = host.options.enableX11Forwarding - tunnelingOption.x11ServerTextField.text = StringUtils.defaultIfBlank(host.options.x11Forwarding, "localhost:0") - - if (host.options.jumpHosts.isNotEmpty()) { - val hosts = HostManager.getInstance().hosts().associateBy { it.id } - for (id in host.options.jumpHosts) { - jumpHostsOption.jumpHosts.add(hosts[id] ?: continue) - } - } - - jumpHostsOption.filter = { it.id != host.id } - - val serialComm = host.options.serialComm - if (serialComm.port.isNotBlank()) { - serialCommOption.serialPortComboBox.selectedItem = serialComm.port - } - serialCommOption.baudRateComboBox.selectedItem = serialComm.baudRate - serialCommOption.dataBitsComboBox.selectedItem = serialComm.dataBits - serialCommOption.parityComboBox.selectedItem = serialComm.parity - serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits - serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl - - sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory - } - - 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/EnableManager.kt b/src/main/kotlin/app/termora/EnableManager.kt new file mode 100644 index 0000000..06ba632 --- /dev/null +++ b/src/main/kotlin/app/termora/EnableManager.kt @@ -0,0 +1,70 @@ +package app.termora + +import app.termora.database.DatabaseManager +import app.termora.tree.NewHostTree +import javax.swing.SwingUtilities + +class EnableManager private constructor() { + + companion object { + fun getInstance(): EnableManager { + return ApplicationScope.forApplicationScope() + .getOrCreate(EnableManager::class) { EnableManager() } + } + } + + private val properties get() = DatabaseManager.getInstance().properties + + /** + * [NewHostTree] 是否显示标签 + */ + fun isShowTags() = getFlag("HostTree.showTags", true) + fun setShowTags(value: Boolean) { + setFlag("HostTree.showTags", value) + updateComponentTreeUI() + } + + /** + * [NewHostTree] 是否显示更多信息 + */ + fun isShowMoreInfo() = getFlag("HostTree.showMoreInfo", false) + fun setShowMoreInfo(value: Boolean) { + setFlag("HostTree.showMoreInfo", value) + updateComponentTreeUI() + } + + fun setFlag(key: String, value: Boolean) { + setFlag(key, value.toString()) + } + + fun getFlag(key: String, defaultValue: Boolean): Boolean { + return getFlag(key, defaultValue.toString()).toBooleanStrictOrNull() ?: defaultValue + } + + + fun setFlag(key: String, value: Int) { + setFlag(key, value.toString()) + } + + fun getFlag(key: String, defaultValue: Int): Int { + return getFlag(key, defaultValue.toString()).toIntOrNull() ?: defaultValue + } + + fun setFlag(key: String, value: String) { + properties.putString(key, value) + } + + + fun getFlag(key: String, defaultValue: String): String { + return properties.getString(key, defaultValue) + } + + private fun updateComponentTreeUI() { + // reload all tree + for (frame in TermoraFrameManager.getInstance().getWindows()) { + for (tree in SwingUtils.getDescendantsOfClass(NewHostTree::class.java, frame)) { + SwingUtilities.updateComponentTreeUI(tree) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/FrameExtension.kt b/src/main/kotlin/app/termora/FrameExtension.kt new file mode 100644 index 0000000..c415f87 --- /dev/null +++ b/src/main/kotlin/app/termora/FrameExtension.kt @@ -0,0 +1,10 @@ +package app.termora + +import app.termora.plugin.Extension + +interface FrameExtension : Extension { + /** + * 自定义 + */ + fun customize(frame: TermoraFrame) +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/GlassPaneAwareExtension.kt b/src/main/kotlin/app/termora/GlassPaneAwareExtension.kt new file mode 100644 index 0000000..32017dc --- /dev/null +++ b/src/main/kotlin/app/termora/GlassPaneAwareExtension.kt @@ -0,0 +1,9 @@ +package app.termora + +import app.termora.plugin.Extension +import java.awt.Window +import javax.swing.JComponent + +interface GlassPaneAwareExtension : Extension { + fun setGlassPane(window: Window, glassPane: JComponent) +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/GlassPaneExtension.kt b/src/main/kotlin/app/termora/GlassPaneExtension.kt new file mode 100644 index 0000000..99a1723 --- /dev/null +++ b/src/main/kotlin/app/termora/GlassPaneExtension.kt @@ -0,0 +1,19 @@ +package app.termora + +import app.termora.plugin.Extension +import java.awt.Graphics2D +import javax.swing.JComponent + +/** + * 玻璃面板扩展 + */ +interface GlassPaneExtension : Extension { + + /** + * 渲染背景,如果返回 true 会立即退出。(当有多个扩展的时候,只会执行一个) + * + * @return true:渲染了背景,false:没有渲染背景 + */ + fun paint(c: JComponent, g2d: Graphics2D): Boolean + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Graphics2D.kt b/src/main/kotlin/app/termora/Graphics2D.kt new file mode 100644 index 0000000..55ba024 --- /dev/null +++ b/src/main/kotlin/app/termora/Graphics2D.kt @@ -0,0 +1,47 @@ +package app.termora + +import java.awt.* +import java.awt.geom.AffineTransform + + +private val states = ArrayDeque() + +private data class State( + val stroke: Stroke, + val composite: Composite, + val color: Color, + val transform: AffineTransform, + val clip: Shape, + val font: Font, + val renderingHints: RenderingHints +) + +fun Graphics2D.save() { + states.addFirst( + State( + stroke = this.stroke, + composite = this.composite, + color = this.color, + transform = this.transform, + clip = this.clip, + font = this.font, + renderingHints = this.renderingHints + ) + ) +} + +fun Graphics2D.restore() { + val state = states.removeFirst() + this.stroke = state.stroke + this.composite = state.composite + this.color = state.color + this.transform = state.transform + this.clip = state.clip + this.font = state.font + this.setRenderingHints(state.renderingHints) +} + +fun setupAntialiasing(graphics: Graphics2D) { + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) +} + diff --git a/src/main/kotlin/app/termora/Host.kt b/src/main/kotlin/app/termora/Host.kt index fcb769c..4f26354 100644 --- a/src/main/kotlin/app/termora/Host.kt +++ b/src/main/kotlin/app/termora/Host.kt @@ -2,7 +2,6 @@ package app.termora import kotlinx.serialization.Serializable import org.apache.commons.lang3.StringUtils -import java.util.* fun Map<*, *>.toPropertiesString(): String { @@ -16,24 +15,6 @@ fun Map<*, *>.toPropertiesString(): String { return env.toString() } -fun UUID.toSimpleString(): String { - return toString().replace("-", StringUtils.EMPTY) -} - -enum class Protocol { - Folder, - SSH, - Local, - Serial, - RDP, - - /** - * 交互式的 SFTP,此协议只在系统内部交互不应该暴露给用户也不应该持久化 - */ - @Transient - SFTPPty -} - enum class AuthenticationType { No, @@ -106,6 +87,9 @@ data class SerialComm( val flowControl: SerialCommFlowControl = SerialCommFlowControl.None, ) +@Serializable +data class HostTag(val text: String) + @Serializable data class Options( @@ -149,6 +133,16 @@ data class Options( * X11 Server,Format: host.port. default: localhost:0 */ val x11Forwarding: String = StringUtils.EMPTY, + + /** + * 标签 [app.termora.tag.Tag.id] + */ + val tags: List = emptyList(), + + /** + * 扩展,如果要使用此 + */ + val extras: Map = emptyMap(), ) { companion object { val Default = Options() @@ -253,7 +247,7 @@ data class Host( /** * 唯一ID */ - val id: String = UUID.randomUUID().toSimpleString(), + val id: String = randomUUID(), /** * 名称 */ @@ -261,7 +255,7 @@ data class Host( /** * 协议 */ - val protocol: Protocol, + val protocol: String, /** * 主机 */ @@ -309,25 +303,23 @@ data class Host( * 所属者 */ val ownerId: String = "0", + /** * 创建者 */ val creatorId: String = "0", - /** - * 创建时间 - */ - val createDate: Long = System.currentTimeMillis(), - /** - * 更新时间 - */ - val updateDate: Long = System.currentTimeMillis(), /** - * 是否已经删除 + * 所属者类型,默认是:用户 */ - val deleted: Boolean = false + val ownerType: String = StringUtils.EMPTY, + val deleted: Boolean = false, + var createDate: Long = 0L, + var updateDate: Long = 0L, ) { + val isFolder get() = StringUtils.equalsIgnoreCase(protocol, "Folder") + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/src/main/kotlin/app/termora/HostDialog.kt b/src/main/kotlin/app/termora/HostDialog.kt deleted file mode 100644 index ca5342e..0000000 --- a/src/main/kotlin/app/termora/HostDialog.kt +++ /dev/null @@ -1,130 +0,0 @@ -package app.termora - -import app.termora.actions.AnAction -import app.termora.actions.AnActionEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.swing.Swing -import kotlinx.coroutines.withContext -import org.apache.commons.lang3.exception.ExceptionUtils -import org.apache.sshd.client.SshClient -import org.apache.sshd.client.session.ClientSession -import java.awt.BorderLayout -import java.awt.Dimension -import java.awt.Window -import java.util.* -import javax.swing.* - -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) - pane.setSelectedIndex(0) - - 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 createActions(): List { - return listOf(createOkAction(), createTestConnectionAction(), CancelAction()) - } - - private fun createTestConnectionAction(): AbstractAction { - return object : AnAction(I18n.getString("termora.new-host.test-connection")) { - override fun actionPerformed(evt: AnActionEvent) { - if (!pane.validateFields()) { - return - } - - putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...") - isEnabled = false - - swingCoroutineScope.launch(Dispatchers.IO) { - // 因为测试连接的时候从数据库读取会导致失效,所以这里生成随机ID - testConnection(pane.getHost().copy(id = UUID.randomUUID().toSimpleString())) - withContext(Dispatchers.Swing) { - putValue(NAME, I18n.getString("termora.new-host.test-connection")) - isEnabled = true - } - } - } - } - } - - - private suspend fun testConnection(host: Host) { - val owner = this - if (host.protocol == Protocol.Local) { - withContext(Dispatchers.Swing) { - OptionPane.showMessageDialog(owner, I18n.getString("termora.new-host.test-connection-successful")) - } - return - } - - try { - if (host.protocol == Protocol.SSH) { - testSSH(host) - } else if (host.protocol == Protocol.Serial) { - testSerial(host) - } - } catch (e: Exception) { - withContext(Dispatchers.Swing) { - OptionPane.showMessageDialog( - owner, ExceptionUtils.getMessage(e), - messageType = JOptionPane.ERROR_MESSAGE - ) - } - return - } - - withContext(Dispatchers.Swing) { - OptionPane.showMessageDialog( - owner, - I18n.getString("termora.new-host.test-connection-successful") - ) - } - - } - - private fun testSSH(host: Host) { - var client: SshClient? = null - var session: ClientSession? = null - try { - client = SshClients.openClient(host, this) - session = SshClients.openSession(host, client) - } finally { - session?.close() - client?.close() - } - } - - private fun testSerial(host: Host) { - Serials.openPort(host).closePort() - } - - 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 index 14f6e75..21ece5c 100644 --- a/src/main/kotlin/app/termora/HostManager.kt +++ b/src/main/kotlin/app/termora/HostManager.kt @@ -1,52 +1,61 @@ package app.termora +import app.termora.Application.ohMyJson +import app.termora.database.Data +import app.termora.database.DataType +import app.termora.database.DatabaseChangedExtension +import app.termora.database.DatabaseManager -class HostManager private constructor() { + +class HostManager private constructor() : Disposable { companion object { fun getInstance(): HostManager { return ApplicationScope.forApplicationScope().getOrCreate(HostManager::class) { HostManager() } } } - private val database get() = Database.getDatabase() - private var hosts = mutableMapOf() + private val databaseManager get() = DatabaseManager.getInstance() /** * 修改缓存并存入数据库 */ - fun addHost(host: Host) { + fun addHost(host: Host, source: DatabaseChangedExtension.Source = DatabaseChangedExtension.Source.User) { assertEventDispatchThread() - if (host.deleted) { - removeHost(host.id) - } else { - database.addHost(host) - hosts[host.id] = host + if (host.ownerType.isBlank()) { + throw IllegalArgumentException("Owner type cannot be null") } + databaseManager.saveAndIncrementVersion( + Data( + id = host.id, + ownerId = host.ownerId, + ownerType = host.ownerType, + type = DataType.Host.name, + data = ohMyJson.encodeToString(host), + ), + source + ) + } fun removeHost(id: String) { - hosts.entries.removeIf { it.value.id == id || it.value.parentId == id } - database.removeHost(id) - DeleteDataManager.getInstance().removeHost(id) + databaseManager.delete(id, DataType.Host.name) } /** * 第一次调用从数据库中获取,后续从缓存中获取 */ fun hosts(): List { - if (hosts.isEmpty()) { - database.getHosts().filter { !it.deleted } - .forEach { hosts[it.id] = it } - } - return hosts.values.filter { !it.deleted } - .sortedWith(compareBy { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort }) + return databaseManager.data(DataType.Host) + .sortedWith(compareBy { if (it.isFolder) 0 else 1 }.thenBy { it.sort }) } /** * 从缓存中获取 */ fun getHost(id: String): Host? { - return hosts[id] + val data = databaseManager.data(id) ?: return null + if (data.type != DataType.Host.name) return null + return ohMyJson.decodeFromString(data.data) } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostTerminalTab.kt b/src/main/kotlin/app/termora/HostTerminalTab.kt index 1adb70c..3613541 100644 --- a/src/main/kotlin/app/termora/HostTerminalTab.kt +++ b/src/main/kotlin/app/termora/HostTerminalTab.kt @@ -55,9 +55,6 @@ abstract class HostTerminalTab( } override fun getIcon(): Icon { - if (host.protocol == Protocol.Local || host.protocol == Protocol.SSH) { - return if (unread) Icons.terminalUnread else Icons.terminal - } return Icons.terminal } diff --git a/src/main/kotlin/app/termora/I18n.kt b/src/main/kotlin/app/termora/I18n.kt index 930e5c4..d3b175b 100644 --- a/src/main/kotlin/app/termora/I18n.kt +++ b/src/main/kotlin/app/termora/I18n.kt @@ -1,14 +1,13 @@ package app.termora import org.apache.commons.lang3.LocaleUtils -import org.apache.commons.text.StringSubstitutor +import org.slf4j.Logger import org.slf4j.LoggerFactory -import java.text.MessageFormat import java.util.* -object I18n { +object I18n : AbstractI18n() { private val log = LoggerFactory.getLogger(I18n::class.java) - private val bundle by lazy { + private val myBundle by lazy { val bundle = ResourceBundle.getBundle("i18n/messages", Locale.getDefault()) if (log.isInfoEnabled) { log.info("I18n: {}", bundle.baseBundleName ?: "null") @@ -16,7 +15,6 @@ object I18n { return@lazy bundle } - private val substitutor by lazy { StringSubstitutor { key -> getString(key) } } private val supportedLanguages = sortedMapOf( "en_US" to "English", "zh_CN" to "简体中文", @@ -39,24 +37,13 @@ object I18n { return supportedLanguages } - fun getString(key: String, vararg args: Any): String { - val text = getString(key) - if (args.isNotEmpty()) { - return MessageFormat.format(text, *args) - } - return text + + override fun getBundle(): ResourceBundle { + return myBundle } - - fun getString(key: String): String { - try { - return substitutor.replace(bundle.getString(key)) - } catch (e: MissingResourceException) { - if (log.isWarnEnabled) { - log.warn(e.message, e) - } - return key - } + override fun getLogger(): Logger { + return log } diff --git a/src/main/kotlin/app/termora/Icons.kt b/src/main/kotlin/app/termora/Icons.kt index db10644..ed6447b 100644 --- a/src/main/kotlin/app/termora/Icons.kt +++ b/src/main/kotlin/app/termora/Icons.kt @@ -2,6 +2,8 @@ package app.termora object Icons { val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") } + val dbms by lazy { DynamicIcon("icons/dbms.svg", "icons/dbms_dark.svg") } + val newUI by lazy { DynamicIcon("icons/newUI.svg", "icons/newUI.svg") } val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") } val closeSmall by lazy { DynamicIcon("icons/closeSmall.svg", "icons/closeSmall_dark.svg") } val closeSmallHovered by lazy { DynamicIcon("icons/closeSmallHovered.svg", "icons/closeSmallHovered_dark.svg") } @@ -34,6 +36,8 @@ object Icons { val percentage by lazy { DynamicIcon("icons/percentage.svg", "icons/percentage_dark.svg") } val text by lazy { DynamicIcon("icons/text.svg", "icons/text_dark.svg") } val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") } + val error by lazy { DynamicIcon("icons/error.svg", "icons/error_dark.svg") } + val cwmUsers by lazy { DynamicIcon("icons/cwmUsers.svg", "icons/cwmUsers_dark.svg") } val warningIntroduction by lazy { DynamicIcon("icons/warningIntroduction.svg", "icons/warningIntroduction_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") } @@ -52,6 +56,8 @@ object Icons { 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 image by lazy { DynamicIcon("icons/image.svg", "icons/image_dark.svg") } + val imageGray by lazy { DynamicIcon("icons/imageGray.svg", "icons/imageGray_dark.svg") } val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") } val chevronRight by lazy { DynamicIcon("icons/chevronRight.svg", "icons/chevronRight_dark.svg") } val homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") } @@ -59,10 +65,16 @@ object Icons { 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 ssh by lazy { DynamicIcon("icons/ssh.svg", "icons/ssh_dark.svg") } + val ftp by lazy { DynamicIcon("icons/ftp.svg", "icons/ftp_dark.svg") } + val minio by lazy { DynamicIcon("icons/minio.svg", "icons/minio_dark.svg") } + val powershell by lazy { DynamicIcon("icons/powershell.svg", "icons/powershell_dark.svg") } + val serial by lazy { DynamicIcon("icons/serial.svg", "icons/serial_dark.svg") } val fileFormat by lazy { DynamicIcon("icons/fileFormat.svg", "icons/fileFormat_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 editFolder by lazy { DynamicIcon("icons/editFolder.svg", "icons/editFolder_dark.svg") } val microsoft by lazy { DynamicIcon("icons/microsoft.svg", "icons/microsoft_dark.svg") } val microsoftWindows by lazy { DynamicIcon("icons/microsoftWindows.svg", "icons/microsoftWindows_dark.svg") } val tencent by lazy { DynamicIcon("icons/tencent.svg") } @@ -78,6 +90,7 @@ object Icons { 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 errorDialog by lazy { DynamicIcon("icons/errorDialog.svg", "icons/errorDialog_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") } diff --git a/src/main/kotlin/app/termora/LocalSecret.kt b/src/main/kotlin/app/termora/LocalSecret.kt new file mode 100644 index 0000000..5194918 --- /dev/null +++ b/src/main/kotlin/app/termora/LocalSecret.kt @@ -0,0 +1,29 @@ +package app.termora + +import org.apache.commons.codec.digest.DigestUtils +import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.SystemUtils + +/** + * 用户需要保证自己的电脑是可信环境 + */ +internal class LocalSecret private constructor() { + + companion object { + fun getInstance(): LocalSecret { + return ApplicationScope.forApplicationScope() + .getOrCreate(LocalSecret::class) { LocalSecret() } + } + } + + /** + * 一个 16 长度的密码 + */ + val password: String = StringUtils.substring(DigestUtils.sha256Hex(SystemUtils.USER_NAME), 0, 16) + + /** + * 一个 12 长度的盐 + */ + val salt: String = StringUtils.substring(password, 0, 16) + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/MyTabbedPane.kt b/src/main/kotlin/app/termora/MyTabbedPane.kt index 89bb763..d61fea0 100644 --- a/src/main/kotlin/app/termora/MyTabbedPane.kt +++ b/src/main/kotlin/app/termora/MyTabbedPane.kt @@ -8,7 +8,10 @@ import java.awt.* import java.awt.event.* import java.awt.image.BufferedImage import java.util.* -import javax.swing.* +import javax.swing.ImageIcon +import javax.swing.JDialog +import javax.swing.JLabel +import javax.swing.SwingUtilities import kotlin.math.abs class MyTabbedPane : FlatTabbedPane() { @@ -23,15 +26,13 @@ class MyTabbedPane : FlatTabbedPane() { init { isFocusable = false - initEvents() - } - override fun updateUI() { styleMap = mapOf( - "focusColor" to UIManager.getColor("TabbedPane.selectedBackground"), - "hoverColor" to UIManager.getColor("TabbedPane.background"), + "focusColor" to DynamicColor("TabbedPane.background"), + "hoverColor" to DynamicColor("TabbedPane.background"), ) - super.updateUI() + + initEvents() } private fun initEvents() { diff --git a/src/main/kotlin/app/termora/NamedI18n.kt b/src/main/kotlin/app/termora/NamedI18n.kt new file mode 100644 index 0000000..7e0ed06 --- /dev/null +++ b/src/main/kotlin/app/termora/NamedI18n.kt @@ -0,0 +1,23 @@ +package app.termora + +import org.slf4j.LoggerFactory +import java.util.* + +abstract class NamedI18n(private val baseName: String) : AbstractI18n() { + companion object { + private val log = LoggerFactory.getLogger(NamedI18n::class.java) + } + + private val myBundle by lazy { + val bundle = + ResourceBundle.getBundle(baseName, Locale.getDefault(), javaClass.classLoader) + if (log.isInfoEnabled) { + log.info("I18n: {}", bundle.baseBundleName ?: "null") + } + return@lazy bundle + } + + override fun getBundle(): ResourceBundle { + return myBundle + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/NewHostDialogV2.kt b/src/main/kotlin/app/termora/NewHostDialogV2.kt new file mode 100644 index 0000000..6fbaa56 --- /dev/null +++ b/src/main/kotlin/app/termora/NewHostDialogV2.kt @@ -0,0 +1,208 @@ +package app.termora + +import app.termora.actions.AnAction +import app.termora.actions.AnActionEvent +import app.termora.protocol.* +import com.formdev.flatlaf.extras.FlatSVGIcon +import com.formdev.flatlaf.extras.components.FlatToolBar +import com.formdev.flatlaf.ui.FlatButtonBorder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.swing.Swing +import kotlinx.coroutines.withContext +import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.exception.ExceptionUtils +import java.awt.BorderLayout +import java.awt.CardLayout +import java.awt.Dimension +import java.awt.Window +import javax.swing.* + +class NewHostDialogV2(owner: Window, private val editHost: Host? = null) : DialogWrapper(owner) { + + private val cardLayout = CardLayout() + private val cardPanel = JPanel(cardLayout) + private val buttonGroup = mutableListOf() + private var currentCard: ProtocolHostPanel? = null + var host: Host? = null + private set + + init { + + size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height")) + isModal = true + title = I18n.getString("termora.new-host.title") + + setLocationRelativeTo(owner) + + init() + } + + + override fun addNotify() { + super.addNotify() + + controlsVisible = false + } + + override fun createCenterPanel(): JComponent { + val toolbar = FlatToolBar() + val panel = JPanel(BorderLayout()) + + toolbar.border = BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 1, 0, DynamicColor.BorderColor), + BorderFactory.createEmptyBorder(4, 0, 4, 0) + ) + panel.add(toolbar, BorderLayout.NORTH) + panel.add(cardPanel, BorderLayout.CENTER) + + toolbar.add(Box.createHorizontalGlue()) + + val extensions = ProtocolHostPanelExtension.extensions + for ((index, extension) in extensions.withIndex()) { + val protocol = extension.getProtocolProvider().getProtocol() + val icon = FlatSVGIcon( + extension.getProtocolProvider().getIcon().name, + 22, 22, extension.javaClass.classLoader + ) + val hostPanel = extension.createProtocolHostPanel() + val button = JToggleButton(protocol, icon).apply { buttonGroup.add(this) } + button.setVerticalTextPosition(SwingConstants.BOTTOM) + button.setHorizontalTextPosition(SwingConstants.CENTER) + button.border = BorderFactory.createCompoundBorder( + FlatButtonBorder(), + BorderFactory.createEmptyBorder(0, 4, 0, 4) + ) + button.addActionListener { show(protocol, hostPanel, button) } + + Disposer.register(disposable, hostPanel) + + cardPanel.add(hostPanel, protocol) + + toolbar.add(button) + + if (extension != extensions.last()) { + toolbar.add(Box.createHorizontalStrut(6)) + } + + if (editHost == null) { + if (index == 0) { + show(protocol, hostPanel, button) + } + } else { + if (StringUtils.equalsIgnoreCase(editHost.protocol, protocol)) { + show(protocol, hostPanel, button) + currentCard?.setHost(editHost) + } + } + + } + + if (editHost != null && currentCard == null) { + SwingUtilities.invokeLater { + OptionPane.showMessageDialog( + this, + "Protocol ${editHost.protocol} not supported", + messageType = JOptionPane.ERROR_MESSAGE + ) + doCancelAction() + } + } + + toolbar.add(Box.createHorizontalGlue()) + + return panel + } + + private fun show(name: String, card: ProtocolHostPanel, button: JToggleButton) { + currentCard?.onBeforeHidden() + card.onBeforeShown() + cardLayout.show(cardPanel, name) + currentCard?.onHidden() + card.onShown() + + currentCard = card + + buttonGroup.forEach { it.isSelected = false } + button.isSelected = true + } + + override fun createActions(): List { + return listOf(createOkAction(), createTestConnectionAction(), CancelAction()) + } + + private fun createTestConnectionAction(): AbstractAction { + return object : AnAction(I18n.getString("termora.new-host.test-connection")) { + override fun actionPerformed(evt: AnActionEvent) { + + val panel = currentCard ?: return + if (panel.validateFields().not()) return + val host = panel.getHost() + val provider = ProtocolProvider.valueOf(host.protocol) ?: return + if (provider !is ProtocolTester) return + + putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...") + isEnabled = false + + swingCoroutineScope.launch(Dispatchers.IO) { + // 因为测试连接的时候从数据库读取会导致失效,所以这里生成随机ID + testConnection(provider, host) + withContext(Dispatchers.Swing) { + putValue(NAME, I18n.getString("termora.new-host.test-connection")) + isEnabled = true + } + } + } + } + } + + private suspend fun testConnection(tester: ProtocolTester, host: Host) { + try { + val request = ProtocolTestRequest(host = host, owner = this) + if (tester.canTestConnection(request)) + tester.testConnection(request) + } catch (e: Exception) { + withContext(Dispatchers.Swing) { + OptionPane.showMessageDialog( + owner, ExceptionUtils.getMessage(e), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + return + } + + withContext(Dispatchers.Swing) { + OptionPane.showMessageDialog( + owner, + I18n.getString("termora.new-host.test-connection-successful") + ) + } + } + + override fun doOKAction() { + val panel = currentCard ?: return + if (panel.validateFields().not()) return + var host = panel.getHost() + + if (editHost != null) { + host = editHost.copy( + name = host.name, + protocol = host.protocol, + host = host.host, + port = host.port, + username = host.username, + authentication = host.authentication, + proxy = host.proxy, + remark = host.remark, + options = host.options, + tunnelings = host.tunnelings, + ) + } + + this.host = host + + super.doOKAction() + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/NewHostTreeModel.kt b/src/main/kotlin/app/termora/NewHostTreeModel.kt deleted file mode 100644 index 9e7abd2..0000000 --- a/src/main/kotlin/app/termora/NewHostTreeModel.kt +++ /dev/null @@ -1,82 +0,0 @@ -package app.termora - -import org.apache.commons.lang3.StringUtils -import javax.swing.tree.MutableTreeNode -import javax.swing.tree.TreeNode - - -class NewHostTreeModel : SimpleTreeModel( - HostTreeNode( - Host( - id = "0", - protocol = Protocol.Folder, - name = I18n.getString("termora.welcome.my-hosts"), - host = StringUtils.EMPTY, - port = 0, - remark = StringUtils.EMPTY, - username = StringUtils.EMPTY - ) - ) -) { - private val Host.isRoot get() = this.parentId == "0" || this.parentId.isBlank() - private val hostManager get() = HostManager.getInstance() - - init { - reload() - } - - - override fun getRoot(): HostTreeNode { - return super.getRoot() as HostTreeNode - } - - - override fun reload(parent: TreeNode) { - - if (parent !is HostTreeNode) { - super.reload(parent) - return - } - - parent.removeAllChildren() - - val hosts = hostManager.hosts() - val nodes = linkedMapOf() - - // 遍历 Host 列表,构建树节点 - for (host in hosts) { - val node = HostTreeNode(host) - nodes[host.id] = node - } - - for (host in hosts) { - val node = nodes[host.id] ?: continue - if (host.isRoot) continue - val p = nodes[host.parentId] ?: continue - p.add(node) - } - - for ((_, v) in nodes.entries) { - if (parent.host.id == v.host.parentId) { - parent.add(v) - } - } - - super.reload(parent) - } - - override fun insertNodeInto(newChild: MutableTreeNode, parent: MutableTreeNode, index: Int) { - super.insertNodeInto(newChild, parent, index) - // 重置所有排序 - if (parent is HostTreeNode) { - for ((i, c) in parent.children().toList().filterIsInstance().withIndex()) { - val sort = i.toLong() - if (c.host.sort == sort) continue - c.host = c.host.copy(sort = sort, updateDate = System.currentTimeMillis()) - hostManager.addHost(c.host) - } - } - } - - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/OpenURIHandlers.kt b/src/main/kotlin/app/termora/OpenURIHandlers.kt new file mode 100644 index 0000000..2668b66 --- /dev/null +++ b/src/main/kotlin/app/termora/OpenURIHandlers.kt @@ -0,0 +1,52 @@ +package app.termora + +import org.apache.commons.lang3.ArrayUtils +import org.slf4j.LoggerFactory +import java.awt.Desktop +import java.awt.desktop.OpenURIEvent +import java.awt.desktop.OpenURIHandler + +class OpenURIHandlers private constructor() { + + companion object { + private val log = LoggerFactory.getLogger(OpenURIHandlers::class.java) + fun getInstance(): OpenURIHandlers { + return ApplicationScope.forApplicationScope() + .getOrCreate(OpenURIHandlers::class) { OpenURIHandlers() } + } + } + + private var handlers = emptyArray() + + init { + // 监听回调 + if (isSupported()) { + Desktop.getDesktop().setOpenURIHandler { e -> trigger(e) } + } + } + + fun isSupported(): Boolean { + return Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI) + } + + internal fun trigger(e: OpenURIEvent) { + for (handler in handlers) { + try { + handler.openURI(e) + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + } + } + } + + fun register(handler: OpenURIHandler) { + handlers += handler + } + + fun unregister(handler: OpenURIHandler) { + handlers = ArrayUtils.removeElement(handlers, handler) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/OptionPane.kt b/src/main/kotlin/app/termora/OptionPane.kt index 74e2836..9dc1960 100644 --- a/src/main/kotlin/app/termora/OptionPane.kt +++ b/src/main/kotlin/app/termora/OptionPane.kt @@ -1,6 +1,6 @@ package app.termora -import app.termora.native.osx.NativeMacLibrary +import app.termora.nv.osx.NativeMacLibrary import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.extras.components.FlatTextPane import com.formdev.flatlaf.util.SystemInfo diff --git a/src/main/kotlin/app/termora/OptionsPane.kt b/src/main/kotlin/app/termora/OptionsPane.kt index 108c3b5..46d925e 100644 --- a/src/main/kotlin/app/termora/OptionsPane.kt +++ b/src/main/kotlin/app/termora/OptionsPane.kt @@ -1,14 +1,18 @@ package app.termora +import app.termora.plugin.internal.extension.DynamicExtensionHandler 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" +abstract class OptionsPane : JPanel(BorderLayout()), Disposable { + companion object { + const val FORM_MARGIN = "7dlu" + } + private val options = mutableListOf