Compare commits

..

17 Commits

Author SHA1 Message Date
hstyi
a4364bcd6a release: 2.0.0-beta.3 2025-07-01 11:00:55 +08:00
hstyi
d0827c3b0c chore: improve connect-with 2025-07-01 10:52:45 +08:00
hstyi
036a04b0b3 feat: support SMB 2025-07-01 10:49:27 +08:00
hstyi
eee016c643 chore: supports retaining file modification date 2025-07-01 10:46:17 +08:00
hstyi
472bf6e81f feat: support login scripts 2025-07-01 10:40:06 +08:00
hstyi
21229e352f chore: do not refresh during installation 2025-06-30 17:34:02 +08:00
hstyi
1138f48a6e fix: host deletion query error 2025-06-30 17:18:05 +08:00
hstyi
f044e0480e chore: password show caps lock 2025-06-30 17:16:52 +08:00
hstyi
7047f17783 fix: quick open transfer failure 2025-06-30 14:33:55 +08:00
dependabot[bot]
9308f15abb chore(deps): bump com.qcloud:cos_api from 5.6.245 to 5.6.247
Bumps [com.qcloud:cos_api](https://github.com/tencentyun/cos-java-sdk-v5) from 5.6.245 to 5.6.247.
- [Release notes](https://github.com/tencentyun/cos-java-sdk-v5/releases)
- [Changelog](https://github.com/tencentyun/cos-java-sdk-v5/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tencentyun/cos-java-sdk-v5/commits)

---
updated-dependencies:
- dependency-name: com.qcloud:cos_api
  dependency-version: 5.6.247
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-30 14:29:41 +08:00
dependabot[bot]
b2672f11fc chore(deps): bump org.testcontainers:testcontainers-bom
Bumps [org.testcontainers:testcontainers-bom](https://github.com/testcontainers/testcontainers-java) from 1.21.2 to 1.21.3.
- [Release notes](https://github.com/testcontainers/testcontainers-java/releases)
- [Changelog](https://github.com/testcontainers/testcontainers-java/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testcontainers/testcontainers-java/compare/1.21.2...1.21.3)

---
updated-dependencies:
- dependency-name: org.testcontainers:testcontainers-bom
  dependency-version: 1.21.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-30 14:29:29 +08:00
dependabot[bot]
f92c6586b2 chore(deps): bump org.semver4j:semver4j from 5.8.0 to 6.0.0
Bumps [org.semver4j:semver4j](https://github.com/semver4j/semver4j) from 5.8.0 to 6.0.0.
- [Release notes](https://github.com/semver4j/semver4j/releases)
- [Commits](https://github.com/semver4j/semver4j/compare/v5.8.0...v6.0.0)

---
updated-dependencies:
- dependency-name: org.semver4j:semver4j
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-30 14:29:20 +08:00
dependabot[bot]
69e07a9bd9 chore(deps): bump com.huaweicloud:esdk-obs-java-bundle
Bumps [com.huaweicloud:esdk-obs-java-bundle](https://github.com/huaweicloud/huaweicloud-sdk-java-obs) from 3.25.4 to 3.25.5.
- [Release notes](https://github.com/huaweicloud/huaweicloud-sdk-java-obs/releases)
- [Commits](https://github.com/huaweicloud/huaweicloud-sdk-java-obs/compare/v3.25.4...v3.25.5)

---
updated-dependencies:
- dependency-name: com.huaweicloud:esdk-obs-java-bundle
  dependency-version: 3.25.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-30 14:29:07 +08:00
dependabot[bot]
cdec60fd25 chore(deps): bump org.xerial:sqlite-jdbc from 3.50.1.0 to 3.50.2.0
Bumps [org.xerial:sqlite-jdbc](https://github.com/xerial/sqlite-jdbc) from 3.50.1.0 to 3.50.2.0.
- [Release notes](https://github.com/xerial/sqlite-jdbc/releases)
- [Changelog](https://github.com/xerial/sqlite-jdbc/blob/master/CHANGELOG)
- [Commits](https://github.com/xerial/sqlite-jdbc/compare/3.50.1.0...3.50.2.0)

---
updated-dependencies:
- dependency-name: org.xerial:sqlite-jdbc
  dependency-version: 3.50.2.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-30 14:28:47 +08:00
hstyi
7c30933794 fix: wsl reg exception 2025-06-30 13:36:35 +08:00
hstyi
b892d2fe13 release: 2.0.0-beta.2 2025-06-30 12:50:10 +08:00
hstyi
91ee463d41 fix: data migration not working 2025-06-30 12:43:31 +08:00
43 changed files with 1119 additions and 82 deletions

View File

@@ -1 +1 @@
2.0.0-beta.1 2.0.0-beta.3

View File

@@ -35,7 +35,7 @@ bip39 = "1.0.9"
colorpicker = "2.0.1" colorpicker = "2.0.1"
rhino = "1.8.0" rhino = "1.8.0"
delight-rhino-sandbox = "0.0.17" delight-rhino-sandbox = "0.0.17"
testcontainers = "1.21.2" testcontainers = "1.21.3"
mixpanel = "1.5.3" mixpanel = "1.5.3"
jSerialComm = "2.11.0" jSerialComm = "2.11.0"
ini4j = "0.5.5-2" ini4j = "0.5.5-2"
@@ -43,9 +43,9 @@ restart4j = "0.0.1"
eddsa = "0.3.0" eddsa = "0.3.0"
exposed = "1.0.0-beta-2" exposed = "1.0.0-beta-2"
h2 = "2.3.232" h2 = "2.3.232"
sqlite = "3.50.1.0" sqlite = "3.50.2.0"
jug = "5.1.0" jug = "5.1.0"
semver4j = "5.8.0" semver4j = "6.0.0"
jsvg = "1.4.0" jsvg = "1.4.0"
dom4j = "2.1.4" dom4j = "2.1.4"

View File

@@ -73,3 +73,7 @@ https://www.apache.org/licenses/LICENSE-2.0.html
GeoLite2 (https://www.maxmind.com) GeoLite2 (https://www.maxmind.com)
Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)
https://creativecommons.org/licenses/by-sa/4.0/ https://creativecommons.org/licenses/by-sa/4.0/
smbj
Apache License, Version 2.0
https://github.com/hierynomus/smbj/blob/master/LICENSE_HEADER

View File

@@ -8,7 +8,7 @@ project.version = "0.0.2"
dependencies { dependencies {
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
implementation("com.qcloud:cos_api:5.6.245") implementation("com.qcloud:cos_api:5.6.247")
compileOnly(project(":")) compileOnly(project(":"))
} }

View File

@@ -8,7 +8,7 @@ project.version = "0.0.1"
dependencies { dependencies {
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
implementation("com.huaweicloud:esdk-obs-java-bundle:3.25.4") implementation("com.huaweicloud:esdk-obs-java-bundle:3.25.5")
compileOnly(project(":")) compileOnly(project(":"))
} }

View File

@@ -0,0 +1,14 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
project.version = "0.0.1"
dependencies {
testImplementation(kotlin("test"))
implementation("com.hierynomus:smbj:0.14.0")
compileOnly(project(":"))
}
apply(from = "$rootDir/plugins/common.gradle.kts")

View File

@@ -0,0 +1,15 @@
package app.termora.plugins.smb
import app.termora.transfer.s3.S3FileSystem
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 close() {
share.close()
super.close()
}
}

View File

@@ -0,0 +1,111 @@
package app.termora.plugins.smb
import app.termora.transfer.s3.S3FileSystemProvider
import app.termora.transfer.s3.S3Path
import com.hierynomus.msdtyp.AccessMask
import com.hierynomus.msfscc.FileAttributes
import com.hierynomus.mssmb2.SMB2CreateDisposition
import com.hierynomus.mssmb2.SMB2CreateOptions
import com.hierynomus.mssmb2.SMB2ShareAccess
import com.hierynomus.smbj.session.Session
import com.hierynomus.smbj.share.DiskShare
import org.apache.commons.io.FilenameUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.AccessMode
import java.nio.file.NoSuchFileException
import java.nio.file.Path
import java.nio.file.attribute.FileAttribute
import kotlin.io.path.absolutePathString
class SMBFileSystemProvider(private val share: DiskShare, private val session: Session) : S3FileSystemProvider() {
override fun getScheme(): String? {
return "smb"
}
override fun getOutputStream(path: S3Path): OutputStream {
val file = share.openFile(
path.absolutePathString(),
setOf(AccessMask.GENERIC_WRITE),
setOf(FileAttributes.FILE_ATTRIBUTE_NORMAL),
setOf(SMB2ShareAccess.FILE_SHARE_READ),
SMB2CreateDisposition.FILE_OVERWRITE_IF,
setOf(SMB2CreateOptions.FILE_NON_DIRECTORY_FILE)
)
val os = file.outputStream
return object : OutputStream() {
override fun write(b: Int) {
os.write(b)
}
override fun close() {
IOUtils.closeQuietly(os)
file.closeNoWait()
}
}
}
override fun getInputStream(path: S3Path): InputStream {
val file = share.openFile(
path.absolutePathString(),
setOf(AccessMask.GENERIC_READ),
setOf(FileAttributes.FILE_ATTRIBUTE_NORMAL),
setOf(SMB2ShareAccess.FILE_SHARE_READ),
SMB2CreateDisposition.FILE_OPEN,
setOf(SMB2CreateOptions.FILE_NON_DIRECTORY_FILE)
)
val input = file.inputStream
return object : InputStream() {
override fun read(): Int = input.read()
override fun close() {
IOUtils.closeQuietly(input)
file.closeNoWait()
}
}
}
override fun fetchChildren(path: S3Path): MutableList<S3Path> {
val paths = mutableListOf<S3Path>()
val absolutePath = FilenameUtils.separatorsToUnix(path.absolutePathString())
for (information in share.list(if (absolutePath == path.fileSystem.separator) StringUtils.EMPTY else absolutePath)) {
if (information.fileName == "." || information.fileName == "..") continue
val isDir = information.fileAttributes and FileAttributes.FILE_ATTRIBUTE_DIRECTORY.value != 0L
val path = path.resolve(information.fileName)
path.attributes = path.attributes.copy(
directory = isDir, regularFile = isDir.not(),
size = information.endOfFile,
lastModifiedTime = information.lastWriteTime.toDate().time,
lastAccessTime = information.lastAccessTime.toDate().time,
)
paths.add(path)
}
return paths
}
override fun createDirectory(dir: Path, vararg attrs: FileAttribute<*>) {
share.mkdir(dir.absolutePathString())
}
override fun delete(path: S3Path, isDirectory: Boolean) {
if (isDirectory) {
share.rmdir(path.absolutePathString(), false)
} else {
share.rm(path.absolutePathString())
}
}
override fun checkAccess(path: S3Path, vararg modes: AccessMode) {
if (share.fileExists(path.absolutePathString()) || share.folderExists(path.absolutePathString())) {
return
}
throw NoSuchFileException(path.absolutePathString())
}
}

View File

@@ -0,0 +1,261 @@
package app.termora.plugins.smb
import app.termora.*
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.ui.FlatTextBorder
import com.hierynomus.smbj.SMBClient
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 SMBHostOptionsPane : OptionsPane() {
private val generalOption = GeneralOption()
private val sftpOption = SFTPOption()
init {
addOption(generalOption)
addOption(sftpOption)
}
fun getHost(): Host {
val name = generalOption.nameTextField.text
val protocol = SMBProtocolProvider.PROTOCOL
val host = generalOption.hostTextField.text
val port = generalOption.portTextField.value as Int
var authentication = Authentication.Companion.No
val authenticationType = AuthenticationType.Password
authentication = authentication.copy(
type = authenticationType,
password = String(generalOption.passwordTextField.password)
)
val options = Options.Default.copy(
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text,
extras = mutableMapOf(
"smb.share" to generalOption.shareTextField.text,
)
)
return Host(
name = name,
protocol = protocol,
host = host,
port = port,
username = generalOption.usernameTextField.selectedItem as String,
authentication = authentication,
sort = System.currentTimeMillis(),
remark = generalOption.remarkTextArea.text,
options = options,
)
}
fun setHost(host: Host) {
generalOption.nameTextField.text = host.name
generalOption.usernameTextField.selectedItem = host.username
generalOption.hostTextField.text = host.host
generalOption.portTextField.value = host.port
generalOption.remarkTextArea.text = host.remark
generalOption.passwordTextField.text = host.authentication.password
generalOption.shareTextField.text = host.options.extras["smb.share"] ?: StringUtils.EMPTY
sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory
}
fun validateFields(): Boolean {
// general
if (validateField(generalOption.nameTextField)
|| validateField(generalOption.hostTextField)
|| validateField(generalOption.shareTextField)
) {
return false
}
val username = generalOption.usernameTextField.selectedItem as String?
if (username.isNullOrBlank()) {
setOutlineError(generalOption.usernameTextField)
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: JComponent) {
selectOptionJComponent(textField)
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
textField.requestFocusInWindow()
}
private inner class GeneralOption : JPanel(BorderLayout()), Option {
val portTextField = PortSpinner(SMBClient.DEFAULT_PORT)
val nameTextField = OutlineTextField(128)
val shareTextField = OutlineTextField(256)
val usernameTextField = OutlineComboBox<String>()
val hostTextField = OutlineTextField(255)
val passwordTextField = OutlinePasswordField(255)
val remarkTextArea = FixedLengthTextArea(512)
init {
initView()
initEvents()
}
private fun initView() {
usernameTextField.isEditable = true
usernameTextField.addItem("Guest")
usernameTextField.addItem("Anonymous")
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("${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.password")}:").xy(1, rows)
.add(passwordTextField).xyw(3, rows, 5).apply { rows += step }
.add("${SMBI18n.getString("termora.plugins.smb.share")}:").xy(1, rows)
.add(shareTextField).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
}
}
}

View File

@@ -0,0 +1,24 @@
package app.termora.plugins.smb
import app.termora.I18n
import app.termora.NamedI18n
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.util.*
object SMBI18n : NamedI18n("i18n/messages") {
private val log = LoggerFactory.getLogger(SMBI18n::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)
}
}
}

View File

@@ -0,0 +1,20 @@
package app.termora.plugins.smb
import app.termora.protocol.PathHandler
import com.hierynomus.smbj.SMBClient
import com.hierynomus.smbj.session.Session
import org.apache.commons.io.IOUtils
import java.nio.file.FileSystem
import java.nio.file.Path
class SMBPathHandler(
private val client: SMBClient,
private val session: Session,
fileSystem: FileSystem, path: Path
) : PathHandler(fileSystem, path) {
override fun dispose() {
super.dispose()
session.close()
IOUtils.closeQuietly(client)
}
}

View File

@@ -0,0 +1,31 @@
package app.termora.plugins.smb
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 SMBPlugin : PaidPlugin {
private val support = ExtensionSupport()
init {
support.addExtension(ProtocolProviderExtension::class.java) { SMBProtocolProviderExtension.instance }
support.addExtension(ProtocolHostPanelExtension::class.java) { SMBProtocolHostPanelExtension.instance }
}
override fun getAuthor(): String {
return "TermoraDev"
}
override fun getName(): String {
return "SMB"
}
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
return support.getExtensions(clazz)
}
}

View File

@@ -0,0 +1,36 @@
package app.termora.plugins.smb
import app.termora.Disposer
import app.termora.Host
import app.termora.protocol.ProtocolHostPanel
import java.awt.BorderLayout
class SMBProtocolHostPanel : ProtocolHostPanel() {
private val pane = SMBHostOptionsPane()
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()
}
}

View File

@@ -0,0 +1,19 @@
package app.termora.plugins.smb
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
class SMBProtocolHostPanelExtension private constructor() : ProtocolHostPanelExtension {
companion object {
val instance by lazy { SMBProtocolHostPanelExtension() }
}
override fun getProtocolProvider(): ProtocolProvider {
return SMBProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
return SMBProtocolHostPanel()
}
}

View File

@@ -0,0 +1,57 @@
package app.termora.plugins.smb
import app.termora.DynamicIcon
import app.termora.Icons
import app.termora.protocol.PathHandler
import app.termora.protocol.PathHandlerRequest
import app.termora.protocol.TransferProtocolProvider
import com.hierynomus.smbj.SMBClient
import com.hierynomus.smbj.auth.AuthenticationContext
import com.hierynomus.smbj.share.DiskShare
import org.apache.commons.io.FilenameUtils
import org.apache.commons.lang3.StringUtils
class SMBProtocolProvider private constructor() : TransferProtocolProvider {
companion object {
val instance by lazy { SMBProtocolProvider() }
const val PROTOCOL = "SMB"
}
override fun getProtocol(): String {
return PROTOCOL
}
override fun getIcon(width: Int, height: Int): DynamicIcon {
return Icons.windows7
}
override fun createPathHandler(requester: PathHandlerRequest): PathHandler {
val client = SMBClient()
val host = requester.host
val connection = client.connect(host.host, host.port)
val session = when (host.username) {
"Guest" -> connection.authenticate(AuthenticationContext.guest())
"Anonymous" -> connection.authenticate(AuthenticationContext.anonymous())
else -> connection.authenticate(
AuthenticationContext(
host.username,
host.authentication.password.toCharArray(),
null
)
)
}
val share = session.connectShare(host.options.extras["smb.share"] ?: StringUtils.EMPTY) as DiskShare
var sftpDefaultDirectory = StringUtils.defaultString(host.options.sftpDefaultDirectory)
sftpDefaultDirectory = if (sftpDefaultDirectory.isNotBlank()) {
FilenameUtils.separatorsToUnix(sftpDefaultDirectory)
} else {
"/"
}
val fs = SMBFileSystem(share, session)
return SMBPathHandler(client, session, fs, fs.getPath(sftpDefaultDirectory))
}
}

View File

@@ -0,0 +1,14 @@
package app.termora.plugins.smb
import app.termora.protocol.ProtocolProvider
import app.termora.protocol.ProtocolProviderExtension
class SMBProtocolProviderExtension private constructor() : ProtocolProviderExtension {
companion object {
val instance by lazy { SMBProtocolProviderExtension() }
}
override fun getProtocolProvider(): ProtocolProvider {
return SMBProtocolProvider.instance
}
}

View File

@@ -0,0 +1,24 @@
<termora-plugin>
<id>smb</id>
<name>SMB</name>
<paid/>
<version>${projectVersion}</version>
<termora-version since=">=${rootProjectVersion}" until=""/>
<entry>app.termora.plugins.smb.SMBPlugin</entry>
<descriptions>
<description>Connecting to SMB</description>
<description language="zh_CN">支持连接到 SMB</description>
<description language="zh_TW">支援連接到 SMB</description>
</descriptions>
<vendor url="https://github.com/TermoraDev">TermoraDev</vendor>
</termora-plugin>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1 @@
termora.plugins.smb.share=Share name

View File

@@ -0,0 +1 @@
termora.plugins.smb.share=共享名称

View File

@@ -0,0 +1 @@
termora.plugins.smb.share=共享名稱

View File

@@ -14,3 +14,4 @@ include("plugins:migration")
include("plugins:editor") include("plugins:editor")
include("plugins:geo") include("plugins:geo")
include("plugins:webdav") include("plugins:webdav")
include("plugins:smb")

View File

@@ -2,7 +2,7 @@ package app.termora
import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.FlatSVGIcon
open class DynamicIcon(name: String, private val darkName: String, val allowColorFilter: Boolean = true) : open class DynamicIcon(name: String, private val darkName: String = name, val allowColorFilter: Boolean = true) :
FlatSVGIcon(name) { FlatSVGIcon(name) {
constructor(name: String) : this(name, name) constructor(name: String) : this(name, name)

View File

@@ -88,7 +88,27 @@ data class SerialComm(
) )
@Serializable @Serializable
data class HostTag(val text: String) data class LoginScript(
/**
* 等待字符串
*/
val expect: String,
/**
* 等待之后发送
*/
val send: String,
/**
* [expect] 是否是正则
*/
val regex: Boolean = false,
/**
* [expect] 是否大小写匹配,如果为 true 表示不忽略大小写,也就是:'A != a';如果为 false 那么 'A == a'
*/
val matchCase: Boolean = false,
)
@Serializable @Serializable
@@ -97,6 +117,10 @@ data class Options(
* 跳板机 * 跳板机
*/ */
val jumpHosts: List<String> = mutableListOf(), val jumpHosts: List<String> = mutableListOf(),
/**
* 登录脚本
*/
val loginScripts: List<LoginScript> = emptyList(),
/** /**
* 编码 * 编码
*/ */

View File

@@ -55,6 +55,7 @@ class HostManager private constructor() : Disposable {
fun getHost(id: String): Host? { fun getHost(id: String): Host? {
val data = databaseManager.data(id) ?: return null val data = databaseManager.data(id) ?: return null
if (data.type != DataType.Host.name) return null if (data.type != DataType.Host.name) return null
if (data.deleted) return null
return ohMyJson.decodeFromString(data.data) return ohMyJson.decodeFromString(data.data)
} }

View File

@@ -80,6 +80,7 @@ object Icons {
val ssh by lazy { DynamicIcon("icons/ssh.svg", "icons/ssh_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 ftp by lazy { DynamicIcon("icons/ftp.svg", "icons/ftp_dark.svg") }
val minio by lazy { DynamicIcon("icons/minio.svg", "icons/minio_dark.svg") } val minio by lazy { DynamicIcon("icons/minio.svg", "icons/minio_dark.svg") }
val windows7 by lazy { DynamicIcon("icons/windows7.svg", allowColorFilter = false) }
val powershell by lazy { DynamicIcon("icons/powershell.svg", "icons/powershell_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 serial by lazy { DynamicIcon("icons/serial.svg", "icons/serial_dark.svg") }
val fileFormat by lazy { DynamicIcon("icons/fileFormat.svg", "icons/fileFormat_dark.svg") } val fileFormat by lazy { DynamicIcon("icons/fileFormat.svg", "icons/fileFormat_dark.svg") }

View File

@@ -37,7 +37,7 @@ abstract class PtyHostTerminalTab(
} }
// 开启 PTY // 开启 PTY
val ptyConnector = openPtyConnector() val ptyConnector = loginScriptsPtyConnector(host, openPtyConnector())
ptyConnectorDelegate.ptyConnector = ptyConnector ptyConnectorDelegate.ptyConnector = ptyConnector
// 开启 reader // 开启 reader
@@ -81,6 +81,73 @@ abstract class PtyHostTerminalTab(
} }
} }
/**
* 登录脚本
*/
open fun loginScriptsPtyConnector(host: Host, ptyConnector: PtyConnector): PtyConnector {
val loginScripts = host.options.loginScripts.toMutableList()
if (loginScripts.isEmpty()) {
return ptyConnector
}
return object : PtyConnectorDelegate(ptyConnector) {
override fun read(buffer: CharArray): Int {
val len = super.read(buffer)
// 获取一个匹配的登录脚本
val scripts = runCatching { popLoginScript(buffer, len) }.getOrNull() ?: return len
if (scripts.isEmpty()) return len
for (script in scripts) {
// send
write(script.send.toByteArray(getCharset()))
// send \r or \n
val enter = terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
.toByteArray(getCharset())
write(enter)
}
return len
}
private fun popLoginScript(buffer: CharArray, len: Int): List<LoginScript> {
if (loginScripts.isEmpty()) return emptyList()
if (len < 1) return emptyList()
val scripts = mutableListOf<LoginScript>()
val text = String(buffer, 0, len)
val iterator = loginScripts.iterator()
while (iterator.hasNext()) {
val script = iterator.next()
if (script.expect.isEmpty()) {
scripts.add(script)
iterator.remove()
continue
} else if (script.regex) {
val regex = if (script.matchCase) script.expect.toRegex()
else script.expect.toRegex(RegexOption.IGNORE_CASE)
if (regex.matches(text)) {
scripts.add(script)
iterator.remove()
continue
}
} else {
if (text.contains(script.expect, script.matchCase.not())) {
scripts.add(script)
iterator.remove()
continue
}
}
break
}
return scripts
}
}
}
open fun sendStartupCommand(ptyConnector: PtyConnector, bytes: ByteArray) { open fun sendStartupCommand(ptyConnector: PtyConnector, bytes: ByteArray) {
ptyConnector.write(bytes) ptyConnector.write(bytes)
} }

View File

@@ -59,6 +59,7 @@ class LoginServerDialog(owner: Window) : DialogWrapper(owner) {
size = Dimension(max(preferredSize.width, UIManager.getInt("Dialog.width") - 250), preferredSize.height) size = Dimension(max(preferredSize.width, UIManager.getInt("Dialog.width") - 250), preferredSize.height)
setLocationRelativeTo(owner) setLocationRelativeTo(owner)
passwordField.putClientProperty(FlatClientProperties.STYLE, mapOf("showCapsLock" to true))
addWindowListener(object : WindowAdapter() { addWindowListener(object : WindowAdapter() {
override fun windowOpened(e: WindowEvent) { override fun windowOpened(e: WindowEvent) {

View File

@@ -5,14 +5,19 @@ import app.termora.Application.ohMyJson
import app.termora.account.Account import app.termora.account.Account
import app.termora.account.AccountExtension import app.termora.account.AccountExtension
import app.termora.account.AccountManager import app.termora.account.AccountManager
import app.termora.account.AccountOwner
import app.termora.database.Data.Companion.toData import app.termora.database.Data.Companion.toData
import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.KeymapManager
import app.termora.keymgr.KeyManager
import app.termora.macro.MacroManager
import app.termora.plugin.ExtensionManager import app.termora.plugin.ExtensionManager
import app.termora.plugin.internal.extension.DynamicExtensionHandler import app.termora.plugin.internal.extension.DynamicExtensionHandler
import app.termora.snippet.SnippetManager
import app.termora.terminal.CursorStyle import app.termora.terminal.CursorStyle
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.eq import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.eq
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.inList
import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.statements.StatementType import org.jetbrains.exposed.v1.core.statements.StatementType
import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.jdbc.*
@@ -402,41 +407,65 @@ class DatabaseManager private constructor() : Disposable {
} }
} }
private fun silentDelete(id: String) {
lock.withLock {
transaction(database) {
DataEntity.deleteWhere { DataEntity.id.eq(id) }
}
}
}
private fun transferData(account: Account) { private fun transferData(account: Account) {
val deleteIds = mutableSetOf<String>() 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 = account.id,
name = account.email,
type = OwnerType.User
)
for (host in hostManager.hosts()) { for (host in hostManager.hosts()) {
// 已经删除,则忽略
if (host.deleted) continue
// 不是用户数据,那么忽略 // 不是用户数据,那么忽略
if (host.ownerType.isNotBlank() && host.ownerType != OwnerType.User.name) continue if (host.ownerType.isNotBlank() && host.ownerType != OwnerType.User.name) continue
// 不是本地用户数据,那么忽略 // 不是本地用户数据,那么忽略
if (AccountManager.isLocally(host.ownerId).not()) continue if (AccountManager.isLocally(host.ownerId).not()) continue
// 转移资产 // 先删除,因为 ID 没有改变,改变的只是 owner 信息
val newHost = host.copy( silentDelete(host.id)
id = randomUUID(), hostManager.addHost(host.copy(ownerId = accountOwner.id, ownerType = accountOwner.type.name))
ownerId = account.id,
ownerType = OwnerType.User.name,
)
// 保存数据
save(
Data(
id = newHost.id,
ownerId = newHost.ownerId,
ownerType = newHost.ownerType,
type = DataType.Host.name,
data = ohMyJson.encodeToString(newHost),
)
)
deleteIds.add(host.id)
} }
if (deleteIds.isNotEmpty()) { for (snippet in snippetManager.snippets()) {
lock.withLock { if (snippet.deleted) continue
transaction(database) { silentDelete(snippet.id)
DataEntity.deleteWhere { DataEntity.id.inList(deleteIds) } snippetManager.addSnippet(snippet)
}
}
} }
for (macro in macroManager.getMacros()) {
silentDelete(macro.id)
macroManager.addMacro(macro)
}
for (keymap in keymapManager.getKeymaps()) {
silentDelete(keymap.id)
keymapManager.addKeymap(keymap)
}
for (keypair in keyManager.getOhKeyPairs()) {
silentDelete(keypair.id)
keyManager.addOhKeyPair(keypair, accountOwner)
}
for (e in highlightManager.getKeywordHighlights()) {
silentDelete(e.id)
highlightManager.addKeywordHighlight(e, accountOwner)
}
} }
override fun ordered(): Long { override fun ordered(): Long {

View File

@@ -45,8 +45,8 @@ class NewKeywordHighlightDialog(
Color(colorPalette.getColor(TerminalColor.Basic.BACKGROUND)), Color(colorPalette.getColor(TerminalColor.Basic.BACKGROUND)),
I18n.getString("termora.highlight.background-color") I18n.getString("termora.highlight.background-color")
) )
val matchCaseBtn = JToggleButton(Icons.matchCase) val matchCaseBtn = JToggleButton(Icons.matchCase).apply { toolTipText = I18n.getString("termora.match-case") }
val regexBtn = JToggleButton(Icons.regex) val regexBtn = JToggleButton(Icons.regex).apply { toolTipText = I18n.getString("termora.regex") }
private val textColorRevert = JButton(Icons.revert) private val textColorRevert = JButton(Icons.revert)

View File

@@ -15,10 +15,17 @@ import java.awt.BorderLayout
import java.awt.CardLayout import java.awt.CardLayout
import java.awt.event.ActionEvent import java.awt.event.ActionEvent
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import javax.swing.* import javax.swing.*
class MarketplacePanel : JPanel(BorderLayout()), Disposable { class MarketplacePanel : JPanel(BorderLayout()), Disposable {
companion object {
/**
* 正在安装的数量
*/
val installing = AtomicInteger(0)
}
private val pluginsPanel = JPanel(VerticalFlowLayout(VerticalFlowLayout.TOP, 0, 8)) private val pluginsPanel = JPanel(VerticalFlowLayout(VerticalFlowLayout.TOP, 0, 8))
private val cardLayout = CardLayout() private val cardLayout = CardLayout()
@@ -93,6 +100,7 @@ class MarketplacePanel : JPanel(BorderLayout()), Disposable {
} }
fun reload() { fun reload() {
if (installing.get() > 0) return
if (isLoading.compareAndSet(false, true)) { if (isLoading.compareAndSet(false, true)) {
coroutineScope.launch { coroutineScope.launch {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {

View File

@@ -20,7 +20,6 @@ import org.apache.commons.net.io.Util
import org.jdesktop.swingx.JXLabel import org.jdesktop.swingx.JXLabel
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.Dimension import java.awt.Dimension
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import javax.swing.* import javax.swing.*
@@ -33,11 +32,6 @@ class PluginPanel(val descriptor: PluginPluginDescriptor) : JPanel(), Disposable
private val log = LoggerFactory.getLogger(PluginPanel::class.java) private val log = LoggerFactory.getLogger(PluginPanel::class.java)
private val installed = mutableSetOf<String>() private val installed = mutableSetOf<String>()
private val uninstalled = mutableSetOf<String>() private val uninstalled = mutableSetOf<String>()
/**
* 正在安装的数量
*/
private val installing = AtomicInteger(0)
private val publicKey = Ed25519.generatePublic( private val publicKey = Ed25519.generatePublic(
Base64.decodeBase64("MCowBQYDK2VwAyEAHPyJ5kt2UHWYUPnWU84DOEhCCUE5FEpzdAbeTCNV31A") Base64.decodeBase64("MCowBQYDK2VwAyEAHPyJ5kt2UHWYUPnWU84DOEhCCUE5FEpzdAbeTCNV31A")
) )
@@ -47,7 +41,7 @@ class PluginPanel(val descriptor: PluginPluginDescriptor) : JPanel(), Disposable
private val updateButton = InstallButton().apply { update = true } private val updateButton = InstallButton().apply { update = true }
private val installButton = InstallButton() private val installButton = InstallButton()
private val uninstallButton = JButton(I18n.getString("termora.settings.plugin.uninstall")) private val uninstallButton = JButton(I18n.getString("termora.settings.plugin.uninstall"))
private val installing get() = MarketplacePanel.installing
private val restarter get() = TermoraRestarter.getInstance() private val restarter get() = TermoraRestarter.getInstance()
private val pluginManager get() = PluginManager.getInstance() private val pluginManager get() = PluginManager.getInstance()
private val owner get() = SwingUtilities.getWindowAncestor(this) private val owner get() = SwingUtilities.getWindowAncestor(this)

View File

@@ -188,6 +188,11 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
// Nothing // Nothing
} }
override fun loginScriptsPtyConnector(host: Host, ptyConnector: PtyConnector): PtyConnector {
// Nothing
return ptyConnector
}
private inner class PasswordReporterDataListener(private val host: Host) : DataListener { private inner class PasswordReporterDataListener(private val host: Host) : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) { override fun onChanged(key: DataKey<*>, data: Any) {
if (key == VisualTerminal.Companion.Written && data is String) { if (key == VisualTerminal.Companion.Written && data is String) {

View File

@@ -9,6 +9,9 @@ import app.termora.tree.HostTreeNode
import app.termora.tree.NewHostTreeDialog import app.termora.tree.NewHostTreeDialog
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.extras.components.FlatTable
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.ui.FlatTextBorder import com.formdev.flatlaf.ui.FlatTextBorder
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.builder.FormBuilder
@@ -23,6 +26,7 @@ import java.nio.charset.Charset
import javax.swing.* import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.DefaultTableCellRenderer
import javax.swing.table.DefaultTableModel import javax.swing.table.DefaultTableModel
import kotlin.math.max
@Suppress("CascadeIf") @Suppress("CascadeIf")
open class SSHHostOptionsPane : OptionsPane() { open class SSHHostOptionsPane : OptionsPane() {
@@ -95,6 +99,7 @@ open class SSHHostOptionsPane : OptionsPane() {
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text, sftpDefaultDirectory = sftpOption.defaultDirectoryField.text,
enableX11Forwarding = tunnelingOption.x11ForwardingCheckBox.isSelected, enableX11Forwarding = tunnelingOption.x11ForwardingCheckBox.isSelected,
x11Forwarding = tunnelingOption.x11ServerTextField.text, x11Forwarding = tunnelingOption.x11ServerTextField.text,
loginScripts = terminalOption.loginScripts,
) )
return Host( return Host(
@@ -138,6 +143,7 @@ open class SSHHostOptionsPane : OptionsPane() {
terminalOption.environmentTextArea.text = host.options.env terminalOption.environmentTextArea.text = host.options.env
terminalOption.startupCommandTextField.text = host.options.startupCommand terminalOption.startupCommandTextField.text = host.options.startupCommand
terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval
terminalOption.loginScripts.addAll(host.options.loginScripts)
tunnelingOption.tunnelings.addAll(host.tunnelings) tunnelingOption.tunnelings.addAll(host.tunnelings)
tunnelingOption.x11ForwardingCheckBox.isSelected = host.options.enableX11Forwarding tunnelingOption.x11ForwardingCheckBox.isSelected = host.options.enableX11Forwarding
@@ -367,11 +373,11 @@ open class SSHHostOptionsPane : OptionsPane() {
private fun chooseKeyPair() { private fun chooseKeyPair() {
val dialog = KeyManagerDialog( val dialog = KeyManagerDialog(
SwingUtilities.getWindowAncestor(this), owner,
selectMode = true, selectMode = true,
) )
dialog.pack() dialog.pack()
dialog.setLocationRelativeTo(null) dialog.setLocationRelativeTo(owner)
dialog.isVisible = true dialog.isVisible = true
val selectedItem = publicKeyComboBox.selectedItem val selectedItem = publicKeyComboBox.selectedItem
@@ -486,15 +492,61 @@ open class SSHHostOptionsPane : OptionsPane() {
val startupCommandTextField = OutlineTextField() val startupCommandTextField = OutlineTextField()
val heartbeatIntervalTextField = IntSpinner(30, minimum = 3, maximum = Int.MAX_VALUE) val heartbeatIntervalTextField = IntSpinner(30, minimum = 3, maximum = Int.MAX_VALUE)
val environmentTextArea = FixedLengthTextArea(2048) val environmentTextArea = FixedLengthTextArea(2048)
val loginScripts = mutableListOf<LoginScript>()
private val addBtn = JButton(I18n.getString("termora.new-host.tunneling.add"))
private val editBtn = JButton(I18n.getString("termora.new-host.tunneling.edit"))
private val deleteBtn = JButton(I18n.getString("termora.new-host.tunneling.delete"))
private val table = FlatTable()
private val model = object : DefaultTableModel() {
override fun getRowCount(): Int {
return loginScripts.size
}
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
fun addRow(loginScript: LoginScript) {
val rowCount = super.getRowCount()
loginScripts.add(loginScript)
super.fireTableRowsInserted(rowCount, rowCount + 1)
}
override fun getValueAt(row: Int, column: Int): Any {
val loginScript = loginScripts[row]
return when (column) {
0 -> loginScript.expect
1 -> loginScript.send
else -> super.getValueAt(row, column)
}
}
}
private val tabbed = FlatTabbedPane()
init { init {
initView() initView()
initEvents() initEvents()
} }
private fun initView() { private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER) addBtn.isFocusable = false
editBtn.isFocusable = false
deleteBtn.isFocusable = false
deleteBtn.isEnabled = false
editBtn.isEnabled = false
tabbed.styleMap = mapOf(
"focusColor" to DynamicColor("TabbedPane.background"),
"hoverColor" to DynamicColor("TabbedPane.background"),
)
tabbed.tabHeight = UIManager.getInt("TabbedPane.tabHeight") - 4
putClientProperty("ContentPanelBorder", BorderFactory.createEmptyBorder())
tabbed.addTab(I18n.getString("termora.new-host.general"), getCenterComponent())
tabbed.addTab(I18n.getString("termora.new-host.terminal.login-scripts"), getLoginScriptsComponent())
add(tabbed, BorderLayout.CENTER)
environmentTextArea.setFocusTraversalKeys( environmentTextArea.setFocusTraversalKeys(
@@ -521,7 +573,39 @@ open class SSHHostOptionsPane : OptionsPane() {
} }
private fun initEvents() { private fun initEvents() {
addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = LoginScriptDialog(owner)
dialog.isVisible = true
model.addRow(dialog.loginScript ?: return)
}
})
editBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = LoginScriptDialog(owner, loginScripts[table.selectedRow])
dialog.isVisible = true
loginScripts[table.selectedRow] = dialog.loginScript ?: return
model.fireTableRowsUpdated(table.selectedRow, table.selectedRow)
}
})
deleteBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val rows = table.selectedRows
if (rows.isEmpty()) return
rows.sortDescending()
for (row in rows) {
loginScripts.removeAt(row)
model.fireTableRowsDeleted(row, row)
}
}
})
table.selectionModel.addListSelectionListener {
deleteBtn.isEnabled = table.selectedRowCount > 0
editBtn.isEnabled = deleteBtn.isEnabled
}
} }
@@ -546,6 +630,7 @@ open class SSHHostOptionsPane : OptionsPane() {
var rows = 1 var rows = 1
val step = 2 val step = 2
val panel = FormBuilder.create().layout(layout) val panel = FormBuilder.create().layout(layout)
.border(BorderFactory.createEmptyBorder(6, 8, 6, 8))
.add("${I18n.getString("termora.new-host.terminal.encoding")}:").xy(1, rows) .add("${I18n.getString("termora.new-host.terminal.encoding")}:").xy(1, rows)
.add(charsetComboBox).xy(3, rows).apply { rows += step } .add(charsetComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.heartbeat-interval")}:").xy(1, rows) .add("${I18n.getString("termora.new-host.terminal.heartbeat-interval")}:").xy(1, rows)
@@ -560,6 +645,124 @@ open class SSHHostOptionsPane : OptionsPane() {
return panel return panel
} }
private fun getLoginScriptsComponent(): JComponent {
val panel = JPanel(BorderLayout())
val scrollPane = JScrollPane(table)
model.addColumn(I18n.getString("termora.new-host.terminal.expect"))
model.addColumn(I18n.getString("termora.new-host.terminal.send"))
table.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"showHorizontalLines" to true,
"showVerticalLines" to true,
)
)
table.model = model
table.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS
table.setDefaultRenderer(
Any::class.java,
DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER })
table.fillsViewportHeight = true
scrollPane.border = BorderFactory.createCompoundBorder(
BorderFactory.createEmptyBorder(4, 0, 4, 0),
BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.Companion.BorderColor)
)
table.border = BorderFactory.createEmptyBorder()
val box = Box.createHorizontalBox()
box.add(addBtn)
box.add(Box.createHorizontalStrut(4))
box.add(editBtn)
box.add(Box.createHorizontalStrut(4))
box.add(deleteBtn)
panel.add(scrollPane, BorderLayout.CENTER)
panel.add(box, BorderLayout.SOUTH)
panel.border = BorderFactory.createEmptyBorder(6, 8, 6, 8)
return panel
}
private inner class LoginScriptDialog(
owner: Window,
var loginScript: LoginScript? = null
) : DialogWrapper(owner) {
private val formMargin = "4dlu"
private val expectTextField = OutlineTextField()
private val sendTextField = OutlineTextField()
private val regexToggleBtn = JToggleButton(Icons.regex)
.apply { toolTipText = I18n.getString("termora.regex") }
private val matchCaseToggleBtn = JToggleButton(Icons.matchCase)
.apply { toolTipText = I18n.getString("termora.match-case") }
init {
isModal = true
title = I18n.getString("termora.new-host.terminal.login-scripts")
controlsVisible = false
init()
pack()
size = Dimension(max(UIManager.getInt("Dialog.width") - 300, 250), preferredSize.height)
setLocationRelativeTo(owner)
val toolbar = FlatToolBar().apply { isFloatable = false }
toolbar.add(regexToggleBtn)
toolbar.add(matchCaseToggleBtn)
expectTextField.trailingComponent = toolbar
expectTextField.placeholderText = I18n.getString("termora.optional")
val script = loginScript
if (script != null) {
expectTextField.text = script.expect
sendTextField.text = script.send
matchCaseToggleBtn.isSelected = script.matchCase
regexToggleBtn.isSelected = script.regex
}
}
override fun doOKAction() {
if (sendTextField.text.isBlank()) {
sendTextField.outline = "error"
sendTextField.requestFocusInWindow()
return
}
loginScript = LoginScript(
expect = expectTextField.text,
send = sendTextField.text,
matchCase = matchCaseToggleBtn.isSelected,
regex = regexToggleBtn.isSelected,
)
super.doOKAction()
}
override fun doCancelAction() {
loginScript = null
super.doCancelAction()
}
override fun createCenterPanel(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref"
)
var rows = 1
val step = 2
return FormBuilder.create().layout(layout).padding("0dlu, $formMargin, $formMargin, $formMargin")
.add("${I18n.getString("termora.new-host.terminal.expect")}:").xy(1, rows)
.add(expectTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.send")}:").xy(1, rows)
.add(sendTextField).xy(3, rows).apply { rows += step }
.build()
}
}
} }
protected inner class SFTPOption : JPanel(BorderLayout()), Option { protected inner class SFTPOption : JPanel(BorderLayout()), Option {
@@ -720,7 +923,7 @@ open class SSHHostOptionsPane : OptionsPane() {
addBtn.addActionListener(object : AbstractAction() { addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent?) { override fun actionPerformed(e: ActionEvent?) {
val dialog = PortForwardingDialog(SwingUtilities.getWindowAncestor(this@SSHHostOptionsPane)) val dialog = PortForwardingDialog(owner)
dialog.isVisible = true dialog.isVisible = true
val tunneling = dialog.tunneling ?: return val tunneling = dialog.tunneling ?: return
model.addRow(tunneling) model.addRow(tunneling)
@@ -735,7 +938,7 @@ open class SSHHostOptionsPane : OptionsPane() {
return return
} }
val dialog = PortForwardingDialog( val dialog = PortForwardingDialog(
SwingUtilities.getWindowAncestor(this@SSHHostOptionsPane), owner,
tunnelings[row] tunnelings[row]
) )
dialog.isVisible = true dialog.isVisible = true
@@ -835,7 +1038,7 @@ open class SSHHostOptionsPane : OptionsPane() {
init() init()
pack() pack()
size = Dimension(UIManager.getInt("Dialog.width") - 300, size.height) size = Dimension(UIManager.getInt("Dialog.width") - 300, size.height)
setLocationRelativeTo(null) setLocationRelativeTo(owner)
} }

View File

@@ -21,34 +21,40 @@ object WSLSupport {
fun getDistributions(): List<WSLDistribution> { fun getDistributions(): List<WSLDistribution> {
if (isSupported.not()) return emptyList() if (isSupported.not()) return emptyList()
val baseKeyPath = "Software\\Microsoft\\Windows\\CurrentVersion\\Lxss"
val guids = Advapi32Util.registryGetKeys(WinReg.HKEY_CURRENT_USER, baseKeyPath)
val distributions = mutableListOf<WSLDistribution>() val distributions = mutableListOf<WSLDistribution>()
for (guid in guids) { try {
val key = baseKeyPath + "\\" + guid val baseKeyPath = "Software\\Microsoft\\Windows\\CurrentVersion\\Lxss"
try { val guids = Advapi32Util.registryGetKeys(WinReg.HKEY_CURRENT_USER, baseKeyPath)
if (Advapi32Util.registryKeyExists(WinReg.HKEY_CURRENT_USER, key)) {
val distroName = for (guid in guids) {
Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "DistributionName") val key = baseKeyPath + "\\" + guid
val basePath = Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "BasePath") try {
val flavor = Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "Flavor") if (Advapi32Util.registryKeyExists(WinReg.HKEY_CURRENT_USER, key)) {
if (StringUtils.isAnyBlank(distroName, guid, basePath, flavor)) continue val distroName =
distributions.add( Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "DistributionName")
WSLDistribution( val basePath = Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "BasePath")
guid = guid, val flavor = Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "Flavor")
flavor = flavor, if (StringUtils.isAnyBlank(distroName, guid, basePath, flavor)) continue
basePath = basePath, distributions.add(
distributionName = distroName WSLDistribution(
guid = guid,
flavor = flavor,
basePath = basePath,
distributionName = distroName
)
) )
) }
} } catch (e: Exception) {
} catch (e: Exception) { if (log.isWarnEnabled) {
if (log.isWarnEnabled) { log.warn(e.message, e)
log.warn(e.message, e) }
} }
} }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn(e.message, e)
}
} }
return distributions return distributions

View File

@@ -1,12 +1,19 @@
package app.termora.transfer package app.termora.transfer
import app.termora.database.DatabaseManager
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.common.SftpConstants
import org.slf4j.LoggerFactory
import java.io.Closeable import java.io.Closeable
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.StandardOpenOption import java.nio.file.StandardOpenOption
import java.nio.file.attribute.BasicFileAttributeView
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.inputStream import kotlin.io.path.inputStream
import kotlin.io.path.outputStream import kotlin.io.path.outputStream
@@ -15,10 +22,13 @@ class FileTransfer(
private val action: TransferAction, private val action: TransferAction,
priority: Transfer.Priority = Transfer.Priority.Normal, priority: Transfer.Priority = Transfer.Priority.Normal,
) : AbstractTransfer(parentId, source, target, false, priority), Closeable { ) : AbstractTransfer(parentId, source, target, false, priority), Closeable {
companion object {
private val log = LoggerFactory.getLogger(FileTransfer::class.java)
}
private lateinit var input: InputStream private lateinit var input: InputStream
private lateinit var output: OutputStream private lateinit var output: OutputStream
private val isPreserveModificationTime get() = DatabaseManager.getInstance().sftp.preserveModificationTime
private val closed = AtomicBoolean(false) private val closed = AtomicBoolean(false)
override suspend fun transfer(bufferSize: Int): Long { override suspend fun transfer(bufferSize: Int): Long {
@@ -31,7 +41,7 @@ class FileTransfer(
if (::output.isInitialized.not()) { if (::output.isInitialized.not()) {
output = if (action == TransferAction.Overwrite) { output = if (action == TransferAction.Overwrite) {
target().outputStream(StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) target().outputStream()
} else { } else {
target().outputStream(StandardOpenOption.WRITE, StandardOpenOption.APPEND) target().outputStream(StandardOpenOption.WRITE, StandardOpenOption.APPEND)
} }
@@ -40,6 +50,7 @@ class FileTransfer(
val buffer = ByteArray(bufferSize) val buffer = ByteArray(bufferSize)
val len = input.read(buffer) val len = input.read(buffer)
if (len <= 0) return 0 if (len <= 0) return 0
output.write(buffer, 0, len) output.write(buffer, 0, len)
return len.toLong() return len.toLong()
} }
@@ -61,6 +72,26 @@ class FileTransfer(
if (::output.isInitialized) { if (::output.isInitialized) {
IOUtils.closeQuietly(output) IOUtils.closeQuietly(output)
} }
if (isPreserveModificationTime) {
runCatching {
val time = source().getLastModifiedTime()
val fs = target().fileSystem
// SFTP 比较特殊
if (fs is SftpFileSystem && fs.version == SftpConstants.SFTP_V3) {
val view = Files.getFileAttributeView(target(), BasicFileAttributeView::class.java)
view.setTimes(time, time, null)
} else {
Files.setLastModifiedTime(target(), time)
}
}.onFailure {
if (log.isWarnEnabled) {
if (it !is UnsupportedOperationException) {
log.warn(it.message, it)
}
}
}
}
} }
} }

View File

@@ -56,17 +56,24 @@ class TransferAnAction : AnAction(I18n.getString("termora.transport.sftp"), Icon
} }
val host = hostManager.getHost(hostId) ?: return val host = hostManager.getHost(hostId) ?: return
var selectionPane: TransportSelectionPanel? = null
for (i in 0 until tabbed.tabCount) { for (i in 0 until tabbed.tabCount) {
val c = tabbed.getComponentAt(i) val c = tabbed.getComponentAt(i)
if (c is TransportSelectionPanel) { if (c is TransportSelectionPanel) {
if (c.state == TransportSelectionPanel.State.Initialized) { if (c.state == TransportSelectionPanel.State.Initialized) {
c.connect(host) selectionPane = c
return break
} }
} }
} }
tabbed.addSelectionTab() if (selectionPane == null) {
selectionPane = tabbed.addSelectionTab()
}
selectionPane.connect(host)
} }
} }

View File

@@ -11,6 +11,7 @@ import com.formdev.flatlaf.extras.components.FlatTextField
import com.formdev.flatlaf.extras.components.FlatToolBar import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.ui.FlatLineBorder import com.formdev.flatlaf.ui.FlatLineBorder
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.FilenameUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.exception.ExceptionUtils
@@ -205,7 +206,7 @@ class TransportNavigationPanel(
if (path.fileSystem.isWindowsFileSystem() && path.pathString == path.fileSystem.separator) { if (path.fileSystem.isWindowsFileSystem() && path.pathString == path.fileSystem.separator) {
textField.text = StringUtils.EMPTY textField.text = StringUtils.EMPTY
} else { } else {
textField.text = path.absolutePathString() textField.text = FilenameUtils.separatorsToUnix(path.absolutePathString())
} }
} }

View File

@@ -332,8 +332,8 @@ class NewHostTree : SimpleTree(), Disposable {
importMenu.isEnabled = lastNode.isFolder importMenu.isEnabled = lastNode.isFolder
// 如果选中了 SSH 服务器,那么才启用 // 如果选中了 SSH 服务器,那么才启用
openWithSFTP.isEnabled = fullNodes.map { it.host }.any { it.protocol == SSHProtocolProvider.PROTOCOL } openWithSFTP.isEnabled = fullNodes.map { it.host }.any { TransferProtocolProvider.valueOf(it.protocol) != null }
openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled openWithSFTPCommand.isEnabled = fullNodes.map { it.host }.any { it.protocol == SSHProtocolProvider.PROTOCOL }
openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled } openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled }

View File

@@ -14,6 +14,9 @@ termora.file=File
termora.explorer=Explorer termora.explorer=Explorer
termora.quit-confirm=Quit {0}? termora.quit-confirm=Quit {0}?
termora.regex=Regex
termora.match-case=Match Case
termora.optional=Optional
# update # update
termora.update.title=New version termora.update.title=New version
@@ -192,6 +195,9 @@ termora.new-host.terminal.encoding=Encoding
termora.new-host.terminal.heartbeat-interval=Heartbeat Interval termora.new-host.terminal.heartbeat-interval=Heartbeat Interval
termora.new-host.terminal.startup-commands=Startup Command termora.new-host.terminal.startup-commands=Startup Command
termora.new-host.terminal.env=Environment termora.new-host.terminal.env=Environment
termora.new-host.terminal.login-scripts=Login Scripts
termora.new-host.terminal.expect=Expect
termora.new-host.terminal.send=Send
termora.new-host.serial=Serial termora.new-host.serial=Serial
termora.new-host.serial.port=Port termora.new-host.serial.port=Port

View File

@@ -13,6 +13,12 @@ termora.file=文件
termora.explorer=文件管理器 termora.explorer=文件管理器
termora.quit-confirm=你要退出 {0} 吗? termora.quit-confirm=你要退出 {0} 吗?
termora.regex=正则表达式
termora.match-case=匹配大小写
termora.optional=可选的
# update # update
termora.update.title=新版本 termora.update.title=新版本
termora.update.update=更新 termora.update.update=更新
@@ -180,6 +186,10 @@ termora.new-host.terminal.encoding=编码
termora.new-host.terminal.heartbeat-interval=心跳间隔 termora.new-host.terminal.heartbeat-interval=心跳间隔
termora.new-host.terminal.startup-commands=启动命令 termora.new-host.terminal.startup-commands=启动命令
termora.new-host.terminal.env=环境 termora.new-host.terminal.env=环境
termora.new-host.terminal.login-scripts=登录脚本
termora.new-host.terminal.expect=预期
termora.new-host.terminal.send=发送
termora.new-host.serial=串口 termora.new-host.serial=串口

View File

@@ -12,6 +12,10 @@ termora.file=文件
termora.explorer=檔案管理器 termora.explorer=檔案管理器
termora.quit-confirm=你要退出 {0} 嗎? termora.quit-confirm=你要退出 {0} 嗎?
termora.regex=正規表示式
termora.match-case=匹配大小寫
termora.optional=可選的
# update # update
termora.update.title=新版本 termora.update.title=新版本
termora.update.update=更新 termora.update.update=更新
@@ -181,6 +185,9 @@ termora.new-host.terminal.encoding=編碼
termora.new-host.terminal.startup-commands=啟動命令 termora.new-host.terminal.startup-commands=啟動命令
termora.new-host.terminal.heartbeat-interval=心跳間隔 termora.new-host.terminal.heartbeat-interval=心跳間隔
termora.new-host.terminal.env=環境 termora.new-host.terminal.env=環境
termora.new-host.terminal.login-scripts=登入腳本
termora.new-host.terminal.expect=預期
termora.new-host.terminal.send=發送
termora.new-host.serial=串口 termora.new-host.serial=串口
termora.new-host.serial.port=端口 termora.new-host.serial.port=端口

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB