feat: support SMB

This commit is contained in:
hstyi
2025-06-30 17:16:39 +08:00
committed by hstyi
parent eee016c643
commit 036a04b0b3
22 changed files with 641 additions and 3 deletions

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

@@ -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

@@ -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

@@ -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())
} }
} }

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB