diff --git a/VERSION b/VERSION index 1653978..cea9c60 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0-beta.5 \ No newline at end of file +2.0.0-beta.6 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 18d3489..7feeb51 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.6.1-SNAPSHOT" kotlinx-serialization-json = "1.9.0" commons-codec = "1.18.0" commons-lang3 = "3.17.0" @@ -46,7 +46,7 @@ h2 = "2.3.232" sqlite = "3.50.2.0" jug = "5.1.0" semver4j = "6.0.0" -jsvg = "1.4.0" +jsvg = "2.0.0" dom4j = "2.2.0" [libraries] diff --git a/plugins/ftp/build.gradle.kts b/plugins/ftp/build.gradle.kts index fe0ee89..04314a5 100644 --- a/plugins/ftp/build.gradle.kts +++ b/plugins/ftp/build.gradle.kts @@ -2,13 +2,13 @@ plugins { alias(libs.plugins.kotlin.jvm) } - project.version = "0.0.1" - dependencies { testImplementation(kotlin("test")) compileOnly(project(":")) + implementation("org.apache.commons:commons-pool2:2.12.1") + testImplementation(project(":")) } 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 deleted file mode 100644 index cae71ec..0000000 --- a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPFileProvider.kt +++ /dev/null @@ -1,41 +0,0 @@ -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/FTPFileSystem.kt b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPFileSystem.kt new file mode 100644 index 0000000..28ab238 --- /dev/null +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPFileSystem.kt @@ -0,0 +1,23 @@ +package app.termora.plugins.ftp + +import app.termora.transfer.s3.S3FileSystem +import app.termora.transfer.s3.S3Path +import org.apache.commons.io.IOUtils +import org.apache.commons.net.ftp.FTPClient +import org.apache.commons.pool2.impl.GenericObjectPool + +class FTPFileSystem(private val pool: GenericObjectPool) : S3FileSystem(FTPSystemProvider(pool)) { + + override fun create(root: String?, names: List): S3Path { + val path = FTPPath(this, root, names) + if (names.isEmpty()) { + path.attributes = path.attributes.copy(directory = true) + } + return path + } + + override fun close() { + IOUtils.closeQuietly(pool) + super.close() + } +} diff --git a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPHostOptionsPane.kt b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPHostOptionsPane.kt new file mode 100644 index 0000000..89384fc --- /dev/null +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPHostOptionsPane.kt @@ -0,0 +1,386 @@ +package app.termora.plugins.ftp + +import app.termora.* +import app.termora.keymgr.KeyManager +import app.termora.plugin.internal.BasicProxyOption +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.extras.components.FlatComboBox +import com.formdev.flatlaf.ui.FlatTextBorder +import com.jgoodies.forms.builder.FormBuilder +import com.jgoodies.forms.layout.FormLayout +import org.apache.commons.lang3.StringUtils +import java.awt.BorderLayout +import java.awt.Component +import java.awt.KeyboardFocusManager +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.nio.charset.Charset +import javax.swing.* + +class FTPHostOptionsPane : OptionsPane() { + private val generalOption = GeneralOption() + private val proxyOption = BasicProxyOption(authenticationTypes = listOf()) + private val sftpOption = SFTPOption() + + init { + addOption(generalOption) + addOption(proxyOption) + addOption(sftpOption) + + } + + fun getHost(): Host { + val name = generalOption.nameTextField.text + val protocol = FTPProtocolProvider.PROTOCOL + val port = generalOption.portTextField.value as Int + 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, + encoding = sftpOption.charsetComboBox.selectedItem as String, + extras = mutableMapOf("passive" to (sftpOption.passiveComboBox.selectedItem as PassiveMode).name) + ) + + return Host( + name = name, + protocol = protocol, + port = port, + host = generalOption.hostTextField.text, + 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.remarkTextArea.text = host.remark + generalOption.passwordTextField.text = host.authentication.password + generalOption.hostTextField.text = host.host + generalOption.portTextField.value = host.port + + 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 + + + val passive = host.options.extras["passive"] ?: PassiveMode.Local.name + sftpOption.charsetComboBox.selectedItem = host.options.encoding + sftpOption.passiveComboBox.selectedItem = runCatching { PassiveMode.valueOf(passive) } + .getOrNull() ?: PassiveMode.Local + sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory + } + + fun validateFields(): Boolean { + val host = getHost() + + // general + if (validateField(generalOption.nameTextField)) { + return false + } + + + if (validateField(generalOption.hostTextField)) { + return false + } + + if (StringUtils.isNotBlank(generalOption.usernameTextField.text) || generalOption.passwordTextField.password.isNotEmpty()) { + if (validateField(generalOption.usernameTextField)) { + return false + } + + 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 && (if (textField is JPasswordField) textField.password.isEmpty() else textField.text.isBlank())) { + setOutlineError(textField) + return true + } + return false + } + + private fun setOutlineError(c: JComponent) { + selectOptionJComponent(c) + c.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR) + c.requestFocusInWindow() + } + + + inner class GeneralOption : JPanel(BorderLayout()), Option { + val portTextField = PortSpinner(21) + val nameTextField = OutlineTextField(128) + val usernameTextField = OutlineTextField(128) + val hostTextField = OutlineTextField(255) + val passwordTextField = OutlinePasswordField(255) + val publicKeyComboBox = OutlineComboBox() + val remarkTextArea = FixedLengthTextArea(512) + val authenticationTypeComboBox = FlatComboBox() + + init { + initView() + initEvents() + } + + private fun initView() { + add(getCenterComponent(), BorderLayout.CENTER) + + publicKeyComboBox.isEditable = false + + publicKeyComboBox.renderer = object : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + var text = StringUtils.EMPTY + if (value is String) { + text = KeyManager.getInstance().getOhKeyPair(value)?.name ?: text + } + return super.getListCellRendererComponent( + list, + text, + index, + isSelected, + cellHasFocus + ) + } + } + + authenticationTypeComboBox.renderer = object : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + var text = value?.toString() ?: "" + when (value) { + AuthenticationType.Password -> { + text = "Password" + } + + AuthenticationType.PublicKey -> { + text = "Public Key" + } + + AuthenticationType.KeyboardInteractive -> { + text = "Keyboard Interactive" + } + } + return super.getListCellRendererComponent( + list, + text, + index, + isSelected, + cellHasFocus + ) + } + } + + authenticationTypeComboBox.addItem(AuthenticationType.No) + authenticationTypeComboBox.addItem(AuthenticationType.Password) + + authenticationTypeComboBox.selectedItem = AuthenticationType.Password + + } + + private fun initEvents() { + 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("${I18n.getString("termora.new-host.general.host")}:").xy(1, rows) + .add(hostTextField).xy(3, rows) + .add("${I18n.getString("termora.new-host.general.port")}:").xy(5, rows) + .add(portTextField).xy(7, rows).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.username")}:").xy(1, rows) + .add(usernameTextField).xyw(3, rows, 5).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, rows) + .add(authenticationTypeComboBox).xyw(3, rows, 5).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.password")}:").xy(1, rows) + .add(passwordTextField).xyw(3, rows, 5).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.remark")}:").xy(1, rows) + .add(JScrollPane(remarkTextArea).apply { border = FlatTextBorder() }) + .xyw(3, rows, 5).apply { rows += step } + + .build() + + + return panel + } + + } + + + private inner class SFTPOption : JPanel(BorderLayout()), Option { + val defaultDirectoryField = OutlineTextField(255) + val charsetComboBox = JComboBox() + val passiveComboBox = JComboBox() + + + init { + initView() + initEvents() + } + + private fun initView() { + + for (e in Charset.availableCharsets()) { + charsetComboBox.addItem(e.key) + } + + charsetComboBox.selectedItem = "UTF-8" + + passiveComboBox.addItem(PassiveMode.Local) + passiveComboBox.addItem(PassiveMode.Remote) + + 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.new-host.terminal.encoding")}:").xy(1, rows) + .add(charsetComboBox).xy(3, rows).apply { rows += step } + .add("${FTPI18n.getString("termora.plugins.ftp.passive")}:").xy(1, rows) + .add(passiveComboBox).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows) + .add(defaultDirectoryField).xy(3, rows).apply { rows += step } + .build() + + + return panel + } + } + + enum class PassiveMode { + Local, + Remote, + } + +} \ No newline at end of file diff --git a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPI18n.kt b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPI18n.kt new file mode 100644 index 0000000..7225099 --- /dev/null +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPI18n.kt @@ -0,0 +1,24 @@ +package app.termora.plugins.ftp + +import app.termora.I18n +import app.termora.NamedI18n +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.util.* + +object FTPI18n : NamedI18n("i18n/messages") { + private val log = LoggerFactory.getLogger(FTPI18n::class.java) + + override fun getLogger(): Logger { + return log + } + + override fun getString(key: String): String { + return try { + substitutor.replace(getBundle().getString(key)) + } catch (_: MissingResourceException) { + I18n.getString(key) + } + } + +} \ No newline at end of file diff --git a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPPath.kt b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPPath.kt new file mode 100644 index 0000000..0cd38e7 --- /dev/null +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPPath.kt @@ -0,0 +1,20 @@ +package app.termora.plugins.ftp + +import app.termora.transfer.s3.S3Path + +class FTPPath(fileSystem: FTPFileSystem, root: String?, names: List) : S3Path(fileSystem, root, names) { + override val isBucket: Boolean + get() = false + + override val bucketName: String + get() = throw UnsupportedOperationException() + + override val objectName: String + get() = throw UnsupportedOperationException() + + override fun getCustomType(): String? { + return null + } + + +} \ 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 index c351e5c..b985be7 100644 --- a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPPlugin.kt +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPPlugin.kt @@ -1,8 +1,5 @@ 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 @@ -27,6 +24,7 @@ class FTPPlugin : PaidPlugin { } + override fun getExtensions(clazz: Class): List { return support.getExtensions(clazz) } 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 index 8ff5955..189597d 100644 --- a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolHostPanel.kt +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolHostPanel.kt @@ -1,22 +1,36 @@ package app.termora.plugins.ftp +import app.termora.Disposer import app.termora.Host import app.termora.protocol.ProtocolHostPanel -import org.apache.commons.lang3.StringUtils +import java.awt.BorderLayout class FTPProtocolHostPanel : ProtocolHostPanel() { + + private val pane = FTPHostOptionsPane() + + init { + initView() + initEvents() + } + + + private fun initView() { + add(pane, BorderLayout.CENTER) + Disposer.register(this, pane) + } + + private fun initEvents() {} + override fun getHost(): Host { - return Host( - name = StringUtils.EMPTY, - protocol = FTPProtocolProvider.PROTOCOL - ) + return pane.getHost() } override fun setHost(host: Host) { - + pane.setHost(host) } override fun validateFields(): Boolean { - return true + return pane.validateFields() } } \ 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 index 4a5bf12..cd4ddd3 100644 --- a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolHostPanelExtension.kt +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolHostPanelExtension.kt @@ -1,19 +1,20 @@ package app.termora.plugins.ftp +import app.termora.account.AccountOwner 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() } + val instance = FTPProtocolHostPanelExtension() } override fun getProtocolProvider(): ProtocolProvider { return FTPProtocolProvider.instance } - override fun createProtocolHostPanel(): ProtocolHostPanel { + override fun createProtocolHostPanel(accountOwner: AccountOwner): 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 index df22eb1..eb3c992 100644 --- a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolProvider.kt +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolProvider.kt @@ -1,16 +1,33 @@ package app.termora.plugins.ftp +import app.termora.AuthenticationType import app.termora.DynamicIcon import app.termora.Icons -import app.termora.protocol.FileObjectHandler -import app.termora.protocol.FileObjectRequest +import app.termora.ProxyType +import app.termora.protocol.PathHandler +import app.termora.protocol.PathHandlerRequest import app.termora.protocol.TransferProtocolProvider -import org.apache.commons.vfs2.provider.FileProvider +import org.apache.commons.lang3.StringUtils +import org.apache.commons.net.ftp.FTPClient +import org.apache.commons.pool2.BasePooledObjectFactory +import org.apache.commons.pool2.PooledObject +import org.apache.commons.pool2.impl.DefaultPooledObject +import org.apache.commons.pool2.impl.GenericObjectPool +import org.apache.commons.pool2.impl.GenericObjectPoolConfig +import org.slf4j.LoggerFactory +import java.net.InetSocketAddress +import java.net.Proxy +import java.nio.charset.Charset +import java.time.Duration + class FTPProtocolProvider private constructor() : TransferProtocolProvider { + companion object { - val instance by lazy { FTPProtocolProvider() } + private val log = LoggerFactory.getLogger(FTPProtocolProvider::class.java) + + val instance = FTPProtocolProvider() const val PROTOCOL = "FTP" } @@ -22,12 +39,82 @@ class FTPProtocolProvider private constructor() : TransferProtocolProvider { return Icons.ftp } - override fun getFileProvider(): FileProvider { - return FTPFileProvider.instance + override fun createPathHandler(requester: PathHandlerRequest): PathHandler { + val host = requester.host + + val config = GenericObjectPoolConfig().apply { + maxTotal = 12 + // 与 transfer 最大传输量匹配 + maxIdle = 6 + minIdle = 1 + testOnBorrow = false + testWhileIdle = true + // 检测空闲对象线程每次运行时检测的空闲对象的数量 + timeBetweenEvictionRuns = Duration.ofSeconds(30) + // 连接空闲的最小时间,达到此值后空闲链接将会被移除,且保留 minIdle 个空闲连接数 + softMinEvictableIdleDuration = Duration.ofSeconds(30) + // 连接的最小空闲时间,达到此值后该空闲连接可能会被移除(还需看是否已达最大空闲连接数) + minEvictableIdleDuration = Duration.ofMinutes(3) + } + + val ftpClientPool = GenericObjectPool(object : BasePooledObjectFactory() { + override fun create(): FTPClient { + val client = FTPClient() + client.charset = Charset.forName(host.options.encoding) + client.controlEncoding = client.charset.name() + client.connect(host.host, host.port) + if (client.isConnected.not()) { + throw IllegalStateException("FTP client is not connected") + } + + if (host.proxy.type == ProxyType.HTTP) { + client.proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(host.proxy.host, host.proxy.port)) + } else if (host.proxy.type == ProxyType.SOCKS5) { + client.proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(host.proxy.host, host.proxy.port)) + } + + val password = if (host.authentication.type == AuthenticationType.Password) + host.authentication.password else StringUtils.EMPTY + if (client.login(host.username, password).not()) { + throw IllegalStateException("Incorrect account or password") + } + + if (host.options.extras["passive"] == FTPHostOptionsPane.PassiveMode.Remote.name) { + client.enterRemotePassiveMode() + } else { + client.enterLocalPassiveMode() + } + + client.listHiddenFiles = true + + return client + } + + override fun wrap(obj: FTPClient): PooledObject { + return DefaultPooledObject(obj) + } + + override fun validateObject(p: PooledObject): Boolean { + val ftp = p.`object` + return ftp.isConnected.not() && ftp.sendNoOp() + } + + override fun destroyObject(p: PooledObject) { + try { + p.`object`.disconnect() + } catch (e: Exception) { + if (log.isWarnEnabled) { + log.warn(e.message, e) + } + } + } + + }, config) + + val defaultPath = host.options.sftpDefaultDirectory + val fs = FTPFileSystem(ftpClientPool) + return PathHandler(fs, fs.getPath(defaultPath)) } - 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 index 2f462c7..9ff9e00 100644 --- a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolProviderExtension.kt +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPProtocolProviderExtension.kt @@ -5,10 +5,10 @@ import app.termora.protocol.ProtocolProviderExtension class FTPProtocolProviderExtension private constructor() : ProtocolProviderExtension { companion object { - val instance by lazy { FTPProtocolProviderExtension() } + val instance = FTPProtocolProviderExtension() } override fun getProtocolProvider(): ProtocolProvider { - return FTPProtocolProvider.Companion.instance + return FTPProtocolProvider.instance } } \ No newline at end of file diff --git a/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPSystemProvider.kt b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPSystemProvider.kt new file mode 100644 index 0000000..10fd24d --- /dev/null +++ b/plugins/ftp/src/main/kotlin/app/termora/plugins/ftp/FTPSystemProvider.kt @@ -0,0 +1,158 @@ +package app.termora.plugins.ftp + +import app.termora.transfer.s3.S3FileSystemProvider +import app.termora.transfer.s3.S3Path +import org.apache.commons.io.IOUtils +import org.apache.commons.net.ftp.FTPClient +import org.apache.commons.net.ftp.FTPFile +import org.apache.commons.pool2.impl.GenericObjectPool +import java.io.InputStream +import java.io.OutputStream +import java.nio.file.AccessMode +import java.nio.file.CopyOption +import java.nio.file.NoSuchFileException +import java.nio.file.Path +import java.nio.file.attribute.FileAttribute +import java.nio.file.attribute.PosixFilePermission +import kotlin.io.path.absolutePathString +import kotlin.io.path.exists + +class FTPSystemProvider(private val pool: GenericObjectPool) : S3FileSystemProvider() { + + + override fun getScheme(): String? { + return "ftp" + } + + override fun getOutputStream(path: S3Path): OutputStream { + return createStreamer(path) + } + + override fun getInputStream(path: S3Path): InputStream { + val ftp = pool.borrowObject() + val fs = ftp.retrieveFileStream(path.absolutePathString()) + return object : InputStream() { + override fun read(): Int { + return fs.read() + } + + override fun close() { + IOUtils.closeQuietly(fs) + ftp.completePendingCommand() + pool.returnObject(ftp) + } + } + } + + private fun createStreamer(path: S3Path): OutputStream { + val ftp = pool.borrowObject() + val os = ftp.storeFileStream(path.absolutePathString()) + return object : OutputStream() { + override fun write(b: Int) { + os.write(b) + } + + override fun close() { + IOUtils.closeQuietly(os) + ftp.completePendingCommand() + pool.returnObject(ftp) + } + } + } + + override fun fetchChildren(path: S3Path): MutableList { + val paths = mutableListOf() + if (path.exists().not()) { + throw NoSuchFileException(path.absolutePathString()) + } + + withFtpClient { + val files = it.listFiles(path.absolutePathString()) + for (file in files) { + val p = path.resolve(file.name) + p.attributes = p.attributes.copy( + directory = file.isDirectory, + regularFile = file.isFile, + size = file.size, + lastModifiedTime = file.timestamp.timeInMillis, + ) + p.attributes.permissions = ftpPermissionsToPosix(file) + paths.add(p) + } + } + + return paths + + } + + + private fun ftpPermissionsToPosix(file: FTPFile): Set { + val perms = mutableSetOf() + + if (file.hasPermission(FTPFile.USER_ACCESS, FTPFile.READ_PERMISSION)) + perms.add(PosixFilePermission.OWNER_READ) + if (file.hasPermission(FTPFile.USER_ACCESS, FTPFile.WRITE_PERMISSION)) + perms.add(PosixFilePermission.OWNER_WRITE) + if (file.hasPermission(FTPFile.USER_ACCESS, FTPFile.EXECUTE_PERMISSION)) + perms.add(PosixFilePermission.OWNER_EXECUTE) + + if (file.hasPermission(FTPFile.GROUP_ACCESS, FTPFile.READ_PERMISSION)) + perms.add(PosixFilePermission.GROUP_READ) + if (file.hasPermission(FTPFile.GROUP_ACCESS, FTPFile.WRITE_PERMISSION)) + perms.add(PosixFilePermission.GROUP_WRITE) + if (file.hasPermission(FTPFile.GROUP_ACCESS, FTPFile.EXECUTE_PERMISSION)) + perms.add(PosixFilePermission.GROUP_EXECUTE) + + if (file.hasPermission(FTPFile.WORLD_ACCESS, FTPFile.READ_PERMISSION)) + perms.add(PosixFilePermission.OTHERS_READ) + if (file.hasPermission(FTPFile.WORLD_ACCESS, FTPFile.WRITE_PERMISSION)) + perms.add(PosixFilePermission.OTHERS_WRITE) + if (file.hasPermission(FTPFile.WORLD_ACCESS, FTPFile.EXECUTE_PERMISSION)) + perms.add(PosixFilePermission.OTHERS_EXECUTE) + + return perms + } + + override fun createDirectory(dir: Path, vararg attrs: FileAttribute<*>) { + withFtpClient { it.mkd(dir.absolutePathString()) } + } + + override fun move(source: Path?, target: Path?, vararg options: CopyOption?) { + if (source != null && target != null) { + withFtpClient { + it.rename(source.absolutePathString(), target.absolutePathString()) + } + } + } + + override fun delete(path: S3Path, isDirectory: Boolean) { + withFtpClient { + if (isDirectory) { + it.rmd(path.absolutePathString()) + } else { + it.deleteFile(path.absolutePathString()) + } + } + } + + override fun checkAccess(path: S3Path, vararg modes: AccessMode) { + withFtpClient { + if (it.cwd(path.absolutePathString()) == 250) { + return + } + if (it.listFiles(path.absolutePathString()).isNotEmpty()) { + return + } + } + throw NoSuchFileException(path.absolutePathString()) + } + + private inline fun withFtpClient(block: (FTPClient) -> T): T { + val client = pool.borrowObject() + return try { + block(client) + } finally { + pool.returnObject(client) + } + } +} \ 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 index 8c32a64..88b3471 100644 --- a/plugins/ftp/src/main/resources/META-INF/plugin.xml +++ b/plugins/ftp/src/main/resources/META-INF/plugin.xml @@ -14,7 +14,7 @@ Connecting to FTP - 支持连接到到 FTP + 支持连接到 FTP 支援連接到 FTP diff --git a/plugins/ftp/src/main/resources/META-INF/pluginIcon.svg b/plugins/ftp/src/main/resources/META-INF/pluginIcon.svg index 65fcd84..38287a4 100644 --- a/plugins/ftp/src/main/resources/META-INF/pluginIcon.svg +++ b/plugins/ftp/src/main/resources/META-INF/pluginIcon.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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 index 138c864..f65003e 100644 --- a/plugins/ftp/src/main/resources/META-INF/pluginIcon_dark.svg +++ b/plugins/ftp/src/main/resources/META-INF/pluginIcon_dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/plugins/ftp/src/main/resources/i18n/messages.properties b/plugins/ftp/src/main/resources/i18n/messages.properties new file mode 100644 index 0000000..8e0dcfd --- /dev/null +++ b/plugins/ftp/src/main/resources/i18n/messages.properties @@ -0,0 +1 @@ +termora.plugins.ftp.passive=Passive Mode diff --git a/plugins/ftp/src/main/resources/i18n/messages_zh_CN.properties b/plugins/ftp/src/main/resources/i18n/messages_zh_CN.properties new file mode 100644 index 0000000..cb8dfff --- /dev/null +++ b/plugins/ftp/src/main/resources/i18n/messages_zh_CN.properties @@ -0,0 +1 @@ +termora.plugins.ftp.passive=被动模式 diff --git a/plugins/ftp/src/main/resources/i18n/messages_zh_TW.properties b/plugins/ftp/src/main/resources/i18n/messages_zh_TW.properties new file mode 100644 index 0000000..31af3f5 --- /dev/null +++ b/plugins/ftp/src/main/resources/i18n/messages_zh_TW.properties @@ -0,0 +1 @@ +termora.plugins.ftp.passive=被動模式 \ No newline at end of file diff --git a/plugins/smb/build.gradle.kts b/plugins/smb/build.gradle.kts index 21138a9..13da9a9 100644 --- a/plugins/smb/build.gradle.kts +++ b/plugins/smb/build.gradle.kts @@ -2,7 +2,7 @@ plugins { alias(libs.plugins.kotlin.jvm) } -project.version = "0.0.2" +project.version = "0.0.3" dependencies { testImplementation(kotlin("test")) diff --git a/plugins/smb/src/main/kotlin/app/termora/plugins/smb/SMBFileSystem.kt b/plugins/smb/src/main/kotlin/app/termora/plugins/smb/SMBFileSystem.kt index 79844b5..546b911 100644 --- a/plugins/smb/src/main/kotlin/app/termora/plugins/smb/SMBFileSystem.kt +++ b/plugins/smb/src/main/kotlin/app/termora/plugins/smb/SMBFileSystem.kt @@ -1,12 +1,22 @@ package app.termora.plugins.smb import app.termora.transfer.s3.S3FileSystem +import app.termora.transfer.s3.S3Path import com.hierynomus.smbj.session.Session import com.hierynomus.smbj.share.DiskShare class SMBFileSystem(private val share: DiskShare, session: Session) : S3FileSystem(SMBFileSystemProvider(share, session)) { + override fun create(root: String?, names: List): S3Path { + val path = SMBPath(this, root, names) + if (names.isEmpty()) { + path.attributes = path.attributes.copy(directory = true) + } + return path + } + + override fun close() { share.close() super.close() diff --git a/plugins/smb/src/main/kotlin/app/termora/plugins/smb/SMBPath.kt b/plugins/smb/src/main/kotlin/app/termora/plugins/smb/SMBPath.kt new file mode 100644 index 0000000..842da49 --- /dev/null +++ b/plugins/smb/src/main/kotlin/app/termora/plugins/smb/SMBPath.kt @@ -0,0 +1,20 @@ +package app.termora.plugins.smb + +import app.termora.transfer.s3.S3Path + +class SMBPath(fileSystem: SMBFileSystem, root: String?, names: List) : S3Path(fileSystem, root, names) { + override val isBucket: Boolean + get() = false + + override val bucketName: String + get() = throw UnsupportedOperationException() + + override val objectName: String + get() = throw UnsupportedOperationException() + + override fun getCustomType(): String? { + return null + } + + +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 4f727d4..7785d4d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,7 +7,7 @@ include("plugins:s3") include("plugins:oss") include("plugins:cos") include("plugins:obs") -//include("plugins:ftp") +include("plugins:ftp") include("plugins:bg") include("plugins:sync") include("plugins:migration") diff --git a/src/main/kotlin/app/termora/TermoraFencePanel.kt b/src/main/kotlin/app/termora/TermoraFencePanel.kt index 7e87192..6c435d7 100644 --- a/src/main/kotlin/app/termora/TermoraFencePanel.kt +++ b/src/main/kotlin/app/termora/TermoraFencePanel.kt @@ -153,7 +153,8 @@ class TermoraFencePanel( override fun dispose() { - enableManager.setFlag("Termora.Fence.dividerLocation", max(splitPane.dividerLocation, 10)) + if (leftTreePanel.isVisible) + enableManager.setFlag("Termora.Fence.dividerLocation", max(splitPane.dividerLocation, 10)) } fun getHostTree(): NewHostTree { diff --git a/src/main/kotlin/app/termora/plugin/internal/BasicProxyOption.kt b/src/main/kotlin/app/termora/plugin/internal/BasicProxyOption.kt index f8ac49f..b01f4b0 100644 --- a/src/main/kotlin/app/termora/plugin/internal/BasicProxyOption.kt +++ b/src/main/kotlin/app/termora/plugin/internal/BasicProxyOption.kt @@ -78,8 +78,6 @@ class BasicProxyOption( proxyAuthenticationTypeComboBox.addItem(type) } - proxyUsernameTextField.text = "root" - refreshStates() } diff --git a/src/main/kotlin/app/termora/plugin/internal/plugin/PluginSVGIcon.kt b/src/main/kotlin/app/termora/plugin/internal/plugin/PluginSVGIcon.kt index bf16026..37f17de 100644 --- a/src/main/kotlin/app/termora/plugin/internal/plugin/PluginSVGIcon.kt +++ b/src/main/kotlin/app/termora/plugin/internal/plugin/PluginSVGIcon.kt @@ -8,6 +8,7 @@ import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.util.UIScale import com.github.weisj.jsvg.SVGDocument import com.github.weisj.jsvg.parser.SVGLoader +import com.github.weisj.jsvg.parser.impl.MutableLoaderContext import java.awt.Component import java.awt.Graphics import java.awt.Graphics2D @@ -21,8 +22,8 @@ class PluginSVGIcon(input: InputStream, dark: InputStream? = null) : Icon { } - private val document = svgLoader.load(input) - private val darkDocument = dark?.let { svgLoader.load(it) } + private val document = svgLoader.load(input, null, MutableLoaderContext.createDefault()) + private val darkDocument = dark?.let { svgLoader.load(it, null, MutableLoaderContext.createDefault()) } override fun getIconHeight(): Int { return 32 diff --git a/src/main/kotlin/app/termora/transfer/TransferTableModel.kt b/src/main/kotlin/app/termora/transfer/TransferTableModel.kt index d9fb8a7..53bdf1d 100644 --- a/src/main/kotlin/app/termora/transfer/TransferTableModel.kt +++ b/src/main/kotlin/app/termora/transfer/TransferTableModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.* import kotlinx.coroutines.swing.Swing import okio.withLock import org.apache.commons.io.IOUtils +import org.apache.commons.lang3.StringUtils import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode import org.jdesktop.swingx.treetable.DefaultTreeTableModel import org.slf4j.LoggerFactory @@ -500,6 +501,10 @@ class TransferTableModel(private val coroutineScope: CoroutineScope) : } + override fun dispose() { + removeTransfer(StringUtils.EMPTY) + } + private class UserCanceledException : RuntimeException() diff --git a/src/main/kotlin/app/termora/transfer/TransportPanel.kt b/src/main/kotlin/app/termora/transfer/TransportPanel.kt index db4c43d..2005fb5 100644 --- a/src/main/kotlin/app/termora/transfer/TransportPanel.kt +++ b/src/main/kotlin/app/termora/transfer/TransportPanel.kt @@ -9,6 +9,7 @@ import app.termora.plugin.ExtensionManager import app.termora.plugin.internal.wsl.WSLHostTerminalTab import app.termora.terminal.DataKey import app.termora.transfer.TransportTableModel.Attributes +import app.termora.transfer.s3.S3FileAttributes import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.extras.components.FlatToolBar import com.formdev.flatlaf.icons.FlatTreeClosedIcon @@ -48,6 +49,7 @@ import java.util.* import java.util.concurrent.CompletableFuture import java.util.concurrent.Future import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong import java.util.stream.Stream import javax.swing.* import javax.swing.TransferHandler @@ -83,6 +85,7 @@ internal class TransportPanel( } + private val mod = AtomicLong(0) private val owner get() = SwingUtilities.getWindowAncestor(this) private val lru = object : LinkedHashMap() { override fun removeEldestEntry(eldest: Map.Entry?): Boolean { @@ -113,7 +116,7 @@ internal class TransportPanel( get() = enableManager.getFlag(showHiddenFilesKey, true) set(value) = enableManager.setFlag(showHiddenFilesKey, value) private val navigator get() = this - private val nextReloadCallbacks = mutableListOf<() -> Unit>() + private val nextReloadCallbacks = Collections.synchronizedMap(mutableMapOf Unit>>()) private val history = linkedSetOf() private val undoManager = MyUndoManager() private val editTransferListener = EditTransferListener() @@ -301,7 +304,7 @@ internal class TransportPanel( } if (target.pathString == workdir?.pathString || target.parent.pathString == workdir?.pathString) { if (loading) { - nextReloadCallbacks.add { reload(requestFocus = false) } + registerNextReloadCallback { reload(requestFocus = false) } } else { reload(requestFocus = false) } @@ -620,7 +623,7 @@ internal class TransportPanel( } fun registerSelectRow(name: String) { - nextReloadCallbacks.add { + registerNextReloadCallback { for (i in 0 until model.rowCount) { if (model.getAttributes(i).name == name) { val c = sorter.convertRowIndexToView(i) @@ -633,6 +636,11 @@ internal class TransportPanel( } } + private fun registerNextReloadCallback(block: () -> Unit) { + nextReloadCallbacks.computeIfAbsent(mod.get()) { mutableListOf() } + .add(block) + } + fun reload( oldPath: String? = workdir?.absolutePathString(), newPath: String? = workdir?.absolutePathString(), @@ -643,6 +651,8 @@ internal class TransportPanel( if (loading) return false loading = true + val mod = mod.getAndAdd(1) + coroutineScope.launch { try { @@ -650,7 +660,7 @@ internal class TransportPanel( withContext(Dispatchers.Swing) { setNewWorkdir(workdir) - nextReloadCallbacks.forEach { runCatching { it.invoke() } } + nextReloadCallbacks[mod]?.forEach { runCatching { it.invoke() } } } } catch (e: Exception) { @@ -665,7 +675,7 @@ internal class TransportPanel( } finally { withContext(Dispatchers.Swing) { loading = false - nextReloadCallbacks.clear() + nextReloadCallbacks.entries.removeIf { it.key <= mod } } } } @@ -730,6 +740,13 @@ internal class TransportPanel( if (files.isNotEmpty()) consume.invoke() + if (first.compareAndSet(false, true)) { + withContext(Dispatchers.Swing) { + model.clear() + table.scrollRectToVisible(Rectangle()) + } + } + if (requestFocus) coroutineScope.launch(Dispatchers.Swing) { table.requestFocusInWindow() } @@ -776,7 +793,9 @@ internal class TransportPanel( .getOrNull() val fileSize = basicAttributes?.size() ?: 0 - val permissions = posixFileAttribute?.permissions() ?: emptySet() + val permissions = posixFileAttribute?.permissions() + ?: if (basicAttributes is S3FileAttributes) basicAttributes.permissions + else emptySet() val owner = fileOwnerAttribute?.name ?: StringUtils.EMPTY val lastModifiedTime = basicAttributes?.lastModifiedTime()?.toMillis() ?: 0 val isDirectory = basicAttributes?.isDirectory ?: false diff --git a/src/main/kotlin/app/termora/transfer/TransportViewer.kt b/src/main/kotlin/app/termora/transfer/TransportViewer.kt index 1132fd0..e097b95 100644 --- a/src/main/kotlin/app/termora/transfer/TransportViewer.kt +++ b/src/main/kotlin/app/termora/transfer/TransportViewer.kt @@ -68,6 +68,7 @@ internal class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposabl } private fun initEvents() { + splitPane.addComponentListener(object : ComponentAdapter() { override fun componentResized(e: ComponentEvent) { removeComponentListener(this) @@ -91,6 +92,8 @@ internal class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposabl } } + Disposer.register(this, transferManager) + Disposer.register(this, transferTable) Disposer.register(this, leftTabbed) Disposer.register(this, rightTabbed) } diff --git a/src/main/kotlin/app/termora/transfer/s3/S3FileAttributes.kt b/src/main/kotlin/app/termora/transfer/s3/S3FileAttributes.kt index 74b1c1d..12d9923 100644 --- a/src/main/kotlin/app/termora/transfer/s3/S3FileAttributes.kt +++ b/src/main/kotlin/app/termora/transfer/s3/S3FileAttributes.kt @@ -2,6 +2,7 @@ package app.termora.transfer.s3 import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.FileTime +import java.nio.file.attribute.PosixFilePermission data class S3FileAttributes( private val lastModifiedTime: Long = 0, @@ -13,7 +14,12 @@ data class S3FileAttributes( private val symbolicLink: Boolean = false, private val other: Boolean = false, private val size: Long = 0, -) : BasicFileAttributes { + + ) : BasicFileAttributes { + + var permissions: Set = emptySet() + + override fun lastModifiedTime(): FileTime { return FileTime.fromMillis(lastModifiedTime) } @@ -49,4 +55,6 @@ data class S3FileAttributes( override fun fileKey(): Any? { return null } + + } \ No newline at end of file diff --git a/src/main/resources/icons/ftp.svg b/src/main/resources/icons/ftp.svg index 65fcd84..6ef7496 100644 --- a/src/main/resources/icons/ftp.svg +++ b/src/main/resources/icons/ftp.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/main/resources/icons/ftp_dark.svg b/src/main/resources/icons/ftp_dark.svg index 138c864..c85ff1a 100644 --- a/src/main/resources/icons/ftp_dark.svg +++ b/src/main/resources/icons/ftp_dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file