Compare commits

...

27 Commits
1.0.6 ... 1.0.7

Author SHA1 Message Date
hstyi
4e690bafed fix: macOS sign 2025-02-12 09:03:41 +08:00
hstyi
28b511e179 release: 1.0.7 2025-02-12 08:47:00 +08:00
hstyi
f010a13abd fix: center the MFA Code dialog (#199) 2025-02-11 16:38:09 +08:00
hstyi
4d80ffafdd fix: SSH password authentication reading local private key (#185) 2025-02-10 14:18:14 +08:00
hstyi
9aecd4d54b chore: browse 2025-02-10 14:01:59 +08:00
hstyi
65091823eb chore: copy-ssh-id i18n 2025-02-10 09:29:06 +08:00
hstyi
d17218bfbd chore: disable jpackage verbose 2025-02-09 17:07:08 +08:00
hstyi
724c5d2632 feat: copy public key display name (#186) 2025-02-09 16:56:10 +08:00
hstyi
6806c26028 feat: deprecate double-click Shift shortcut (#184) 2025-02-09 15:26:36 +08:00
hstyi
dcd89174c9 chore: new version dialog (#182) 2025-02-09 11:10:06 +08:00
hstyi
9a8707b8cb fix: encoding error 2025-02-09 10:25:43 +08:00
hstyi
28f1d05f06 feat: support ssh-copy-id (#177) 2025-02-08 12:32:18 +08:00
hstyi
54b044584e fix: line breaks 2025-02-08 11:01:59 +08:00
hstyi
ed39449a20 feat: GitHub actions macOS sign (#175) 2025-02-08 10:42:41 +08:00
hstyi
2ff3f3a352 chore: improve code 2025-02-08 09:18:14 +08:00
Mystery0 M
91e2e964a5 chore: move terminal disconnection messages to i18n (#168) 2025-02-08 09:15:21 +08:00
Mystery0 M
ca6cc68fed feat: support auto close terminal tab when ssh disconnected normally (#169) 2025-02-08 09:14:57 +08:00
hstyi
0962de7735 feat: winget releaser 2025-02-08 08:52:56 +08:00
hstyi
062b957fdb docs: README 2025-02-07 15:40:27 +08:00
hstyi
4efe4e5663 chore: opengl 2025-02-07 14:43:03 +08:00
hstyi
25eb6966c4 feat: external release to create a new window (#162) 2025-02-07 14:11:07 +08:00
hstyi
7843460020 feat: confirmation required to exit program 2025-02-07 13:50:34 +08:00
hstyi
1cbc6ba4a9 fix: color mismatch issue 2025-02-07 11:15:21 +08:00
hstyi
a43407bee8 feat: support drag and drop transfer (#157) 2025-02-07 11:15:01 +08:00
hstyi
05c4ec9af2 feat: support for turning off beep (#155) 2025-02-07 09:22:01 +08:00
hstyi
9236064293 docs: README 2025-02-06 16:03:52 +08:00
hstyi
e1955a371e feat: support for WebDAV (#150) 2025-02-06 16:03:25 +08:00
41 changed files with 1221 additions and 405 deletions

View File

@@ -10,6 +10,28 @@ jobs:
with:
fetch-depth: 0
- name: Install the Apple certificate
if: github.event_name == 'push'
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# create variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# import certificate from secrets
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# import certificate to keychain
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# download jdk
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b825.69.tar.gz
@@ -23,9 +45,12 @@ jobs:
java-version: '21.0.6'
architecture: aarch64
# dist
- run: |
- name: Dist
env:
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' }}
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
run: |
./gradlew dist --no-daemon
- name: Upload artifact

View File

@@ -10,6 +10,29 @@ jobs:
with:
fetch-depth: 0
- name: Install the Apple certificate
if: github.event_name == 'push'
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# create variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# import certificate from secrets
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# import certificate to keychain
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# download jdk
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b825.69.tar.gz
@@ -24,7 +47,11 @@ jobs:
# dist
- run: |
- name: Dist
env:
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' }}
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
run: |
./gradlew dist --no-daemon
- name: Upload artifact

13
.github/workflows/winget.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Publish to WinGet
on:
release:
types: [ released ]
jobs:
publish:
runs-on: windows-latest
steps:
- uses: vedantmgoyal9/winget-releaser@main
with:
identifier: TermoraDev.Termora
installers-regex: 'x86-64\.msi$' # Only x86-64.msi files
token: ${{ secrets.WINGET_TOKEN }}

View File

@@ -15,6 +15,7 @@
## Features
- SSH and local terminal support
- Serial port protocol support
- [SFTP](./docs/sftp.png?raw=1) file transfer support
- Compatible with Windows, macOS, and Linux
- Zmodem protocol support
@@ -33,6 +34,7 @@
- [Latest release](https://github.com/TermoraDev/termora/releases/latest)
- [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora`
- [WinGet](https://github.com/microsoft/winget-pkgs/tree/master/manifests/t/TermoraDev/Termora): `winget install termora`
## Development

View File

@@ -11,6 +11,7 @@
## 功能特性
- 支持 SSH 和本地终端
- 支持串口协议
- 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) 文件传输
- 支持 Windows、macOS、Linux 平台
- 支持 Zmodem 协议
@@ -29,6 +30,7 @@
- [Latest release](https://github.com/TermoraDev/termora/releases/latest)
- [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora`
- [WinGet](https://github.com/microsoft/winget-pkgs/tree/master/manifests/t/TermoraDev/Termora): `winget install termora`
## 开发

View File

@@ -15,7 +15,7 @@ plugins {
group = "app.termora"
version = "1.0.6"
version = "1.0.7"
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
@@ -38,7 +38,7 @@ repositories {
dependencies {
// 由于签名和公证macOS 不携带 natives
val useNoNativesFlatLaf = os.isMacOsX && macOSNotary && System.getenv("ENABLE_BUILD").toBoolean()
val useNoNativesFlatLaf = os.isMacOsX && System.getenv("ENABLE_BUILD").toBoolean()
testImplementation(kotlin("test"))
testImplementation(libs.hutool)
@@ -251,15 +251,18 @@ tasks.register<Exec>("jpackage") {
"-Dapp-version=${project.version}",
)
if (os.isMacOsX) {
options.add("-Dsun.java2d.metal=true")
if (os.isMacOsX) {
options.add("-Dapple.awt.application.appearance=system")
options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")
} else {
}
if (os.isLinux) {
options.add("-Dsun.java2d.opengl=true")
}
val arguments = mutableListOf("${Jvm.current().javaHome}/bin/jpackage", "--verbose")
val arguments = mutableListOf("${Jvm.current().javaHome}/bin/jpackage")
arguments.addAll(listOf("--runtime-image", "${buildDir}/jlink"))
arguments.addAll(listOf("--name", project.name.uppercaseFirstChar()))
arguments.addAll(listOf("--app-version", "${project.version}"))

View File

@@ -111,11 +111,18 @@ object Application {
return "Termora"
}
@Suppress("OPT_IN_USAGE")
fun browse(uri: URI, async: Boolean = true) {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
// https://github.com/TermoraDev/termora/issues/178
if (SystemInfo.isWindows && uri.scheme == "file") {
if (async) {
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) }
} else {
tryBrowse(uri)
}
} else if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(uri)
} else if (async) {
@Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) }
} else {
tryBrowse(uri)

View File

@@ -454,6 +454,11 @@ class Database private constructor(private val env: Environment) : Disposable {
*/
var debug by BooleanPropertyDelegate(false)
/**
* 蜂鸣声
*/
var beep by BooleanPropertyDelegate(true)
/**
* 选中复制
*/
@@ -463,6 +468,11 @@ class Database private constructor(private val env: Environment) : Disposable {
* 光标样式
*/
var cursor by CursorStylePropertyDelegate(CursorStyle.Block)
/**
* 终端断开连接时自动关闭Tab
*/
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
}
/**

View File

@@ -68,6 +68,7 @@ object Icons {
val network by lazy { DynamicIcon("icons/network.svg", "icons/network_dark.svg") }
val server by lazy { DynamicIcon("icons/server.svg", "icons/server_dark.svg") }
val runAnything by lazy { DynamicIcon("icons/runAnything.svg", "icons/runAnything_dark.svg") }
val run by lazy { DynamicIcon("icons/run.svg", "icons/run_dark.svg") }
val uiForm by lazy { DynamicIcon("icons/uiForm.svg", "icons/uiForm_dark.svg") }
val cloud by lazy { DynamicIcon("icons/cloud.svg", "icons/cloud_dark.svg") }
val externalLink by lazy { DynamicIcon("icons/externalLink.svg", "icons/externalLink_dark.svg") }

View File

@@ -4,7 +4,8 @@ import app.termora.terminal.PtyConnector
import org.apache.commons.io.Charsets
import java.nio.charset.StandardCharsets
class LocalTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) {
class LocalTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
override suspend fun openPtyConnector(): PtyConnector {
val winSize = terminalPanel.winSize()

View File

@@ -75,6 +75,7 @@ class MyTabbedPane : FlatTabbedPane() {
private var terminalTab: TerminalTab? = null
private var isDragging = false
private var lastVisitTabIndex = -1
private var releasedPoint = Point()
override fun mousePressed(e: MouseEvent) {
val index = indexAtLocation(e.x, e.y)
@@ -137,8 +138,15 @@ class MyTabbedPane : FlatTabbedPane() {
}
// 如果是取消,那么不需要移动到其它窗口
val c = if (cancelled) null else getTopMostWindowUnderMouse()
if (c != owner && c is TermoraFrame) {
val c = if (cancelled) owner else getTopMostWindowUnderMouse()
// 如果等于 null 表示在空地方释放,那么单独一个窗口
if (c == null) {
val window = TermoraFrameManager.getInstance().createWindow()
dragToAnotherWindow(window)
window.location = releasedPoint
window.isVisible = true
} else if (c != owner && c is TermoraFrame) { // 如果在某个窗口内释放,那么就移动到某个窗口内
dragToAnotherWindow(c)
} else {
val tab = this.terminalTab
@@ -161,6 +169,7 @@ class MyTabbedPane : FlatTabbedPane() {
}
override fun mouseReleased(e: MouseEvent) {
releasedPoint = e.point
stopDrag()
}

View File

@@ -23,8 +23,9 @@ abstract class PtyHostTerminalTab(
private var readerJob: Job? = null
private val ptyConnectorDelegate = PtyConnectorDelegate()
protected val terminalPanel =
TerminalPanelFactory.getInstance(windowScope).createTerminalPanel(terminal, ptyConnectorDelegate)
private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
protected val terminalPanel = terminalPanelFactory.createTerminalPanel(terminal, ptyConnectorDelegate)
.apply { Disposer.register(this@PtyHostTerminalTab, this) }
protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance(windowScope)
init {
@@ -120,6 +121,7 @@ abstract class PtyHostTerminalTab(
override fun dispose() {
stop()
terminalPanel
super.dispose()

View File

@@ -1,5 +1,7 @@
package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.actions.TabReconnectAction
import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor
import app.termora.keyboardinteractive.TerminalUserInteraction
@@ -27,11 +29,13 @@ import org.apache.sshd.common.session.SessionListener.Event
import org.apache.sshd.common.util.net.SshdSocketAddress
import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets
import java.util.EventObject
import javax.swing.JComponent
import javax.swing.SwingUtilities
class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) {
class SSHTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
companion object {
private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java)
}
@@ -41,6 +45,9 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null
private var sshChannelShell: ChannelShell? = null
private val terminalTabbedManager
get() = AnActionEvent(getJComponent(), StringUtils.EMPTY, EventObject(getJComponent()))
.getData(DataProviders.TerminalTabbedManager)
init {
terminalPanel.dropFiles = false
@@ -119,13 +126,25 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
override fun channelClosed(channel: Channel, reason: Throwable?) {
coroutineScope.launch(Dispatchers.Swing) {
terminal.write("\r\n\r\n${ControlCharacters.ESC}[31m")
terminal.write("Channel has been disconnected.")
terminal.write(I18n.getString("termora.terminal.channel-disconnected"))
if (reconnectShortcut is KeyShortcut) {
terminal.write(" Type $reconnectShortcut to reconnect.")
terminal.write(
I18n.getString(
"termora.terminal.channel-reconnect",
reconnectShortcut.toString()
)
)
}
terminal.write("\r\n")
terminal.write("${ControlCharacters.ESC}[0m")
terminalModel.setData(DataKey.ShowCursor, false)
if (Database.getDatabase().terminal.autoCloseTabWhenDisconnected) {
terminalTabbedManager?.let { manager ->
SwingUtilities.invokeLater {
manager.closeTerminalTab(this@SSHTerminalTab, true)
}
}
}
}
}
})

View File

@@ -5,7 +5,8 @@ import org.apache.commons.io.Charsets
import java.nio.charset.StandardCharsets
import javax.swing.Icon
class SerialTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) {
class SerialTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
override suspend fun openPtyConnector(): PtyConnector {
val serialPort = Serials.openPort(host)
return SerialPortPtyConnector(

View File

@@ -22,6 +22,7 @@ import app.termora.terminal.CursorStyle
import app.termora.terminal.DataKey
import app.termora.terminal.panel.TerminalPanel
import cash.z.ecc.android.bip39.Mnemonics
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.components.*
import com.formdev.flatlaf.util.FontUtils
@@ -43,6 +44,7 @@ import org.jdesktop.swingx.JXEditorPane
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.awt.event.ItemEvent
import java.io.File
@@ -298,12 +300,14 @@ class SettingsOptionsPane : OptionsPane() {
private inner class TerminalOption : JPanel(BorderLayout()), Option {
private val cursorStyleComboBox = FlatComboBox<CursorStyle>()
private val debugComboBox = YesOrNoComboBox()
private val beepComboBox = YesOrNoComboBox()
private val fontComboBox = FlatComboBox<String>()
private val shellComboBox = FlatComboBox<String>()
private val maxRowsTextField = IntSpinner(0, 0)
private val fontSizeTextField = IntSpinner(0, 9, 99)
private val terminalSetting get() = Database.getDatabase().terminal
private val selectCopyComboBox = YesOrNoComboBox()
private val autoCloseTabComboBox = YesOrNoComboBox()
init {
initView()
@@ -319,6 +323,13 @@ class SettingsOptionsPane : OptionsPane() {
}
}
autoCloseTabComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.autoCloseTabWhenDisconnected = autoCloseTabComboBox.selectedItem as Boolean
}
}
autoCloseTabComboBox.toolTipText = I18n.getString("termora.settings.terminal.auto-close-tab-description")
selectCopyComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.selectCopy = selectCopyComboBox.selectedItem as Boolean
@@ -355,6 +366,13 @@ class SettingsOptionsPane : OptionsPane() {
}
beepComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.beep = beepComboBox.selectedItem as Boolean
}
}
shellComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
terminalSetting.localShell = shellComboBox.selectedItem as String
@@ -453,8 +471,10 @@ class SettingsOptionsPane : OptionsPane() {
fontComboBox.selectedItem = terminalSetting.font
debugComboBox.selectedItem = terminalSetting.debug
beepComboBox.selectedItem = terminalSetting.beep
cursorStyleComboBox.selectedItem = terminalSetting.cursor
selectCopyComboBox.selectedItem = terminalSetting.selectCopy
autoCloseTabComboBox.selectedItem = terminalSetting.autoCloseTabWhenDisconnected
}
override fun getIcon(isSelected: Boolean): Icon {
@@ -472,9 +492,14 @@ class SettingsOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val beepBtn = JButton(Icons.run)
beepBtn.isFocusable = false
beepBtn.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON)
beepBtn.addActionListener { Toolkit.getDefaultToolkit().beep() }
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
@@ -487,10 +512,15 @@ class SettingsOptionsPane : OptionsPane() {
.add(maxRowsTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.debug")}:").xy(1, rows)
.add(debugComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.beep")}:").xy(1, rows)
.add(beepComboBox).xy(3, rows)
.add(beepBtn).xy(5, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.select-copy")}:").xy(1, rows)
.add(selectCopyComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows)
.add(cursorStyleComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.auto-close-tab")}:").xy(1, rows)
.add(autoCloseTabComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.local-shell")}:").xy(1, rows)
.add(shellComboBox).xyw(3, rows, 5)
.build()
@@ -550,12 +580,6 @@ class SettingsOptionsPane : OptionsPane() {
}
}
if (typeComboBox.selectedItem == SyncType.Gitee) {
gistTextField.trailingComponent = null
} else {
gistTextField.trailingComponent = visitGistBtn
}
removeAll()
add(getCenterComponent(), BorderLayout.CENTER)
revalidate()
@@ -987,6 +1011,7 @@ class SettingsOptionsPane : OptionsPane() {
typeComboBox.addItem(SyncType.GitHub)
typeComboBox.addItem(SyncType.GitLab)
typeComboBox.addItem(SyncType.Gitee)
typeComboBox.addItem(SyncType.WebDAV)
hostsCheckBox.isFocusable = false
keysCheckBox.isFocusable = false
@@ -1005,7 +1030,31 @@ class SettingsOptionsPane : OptionsPane() {
tokenTextField.text = sync.token
domainTextField.trailingComponent = JButton(Icons.externalLink).apply {
addActionListener {
if (typeComboBox.selectedItem == SyncType.GitLab) {
Application.browse(URI.create("https://docs.gitlab.com/ee/api/snippets.html"))
} else if (typeComboBox.selectedItem == SyncType.WebDAV) {
val url = domainTextField.text
if (url.isNullOrBlank()) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.settings.sync.webdav.help")
)
} else {
val uri = URI.create(url)
val sb = StringBuilder()
sb.append(uri.scheme).append("://")
if (tokenTextField.password.isNotEmpty() && gistTextField.text.isNotBlank()) {
sb.append(String(tokenTextField.password)).append(":").append(gistTextField.text)
sb.append('@')
}
sb.append(uri.authority).append(uri.path)
if (!uri.query.isNullOrBlank()) {
sb.append('?').append(uri.query)
}
Application.browse(URI.create(sb.toString()))
}
}
}
}
@@ -1015,12 +1064,15 @@ class SettingsOptionsPane : OptionsPane() {
tokenTextField.trailingComponent = if (tokenTextField.password.isEmpty()) getTokenBtn else null
if (typeComboBox.selectedItem == SyncType.GitLab) {
if (domainTextField.text.isBlank()) {
if (typeComboBox.selectedItem == SyncType.GitLab) {
domainTextField.text = StringUtils.defaultIfBlank(sync.domain, "https://gitlab.com/api")
} else if (typeComboBox.selectedItem == SyncType.WebDAV) {
domainTextField.text = sync.domain
}
}
val lastSyncTime = sync.lastSyncTime
lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${
if (lastSyncTime > 0) DateFormatUtils.format(
@@ -1069,17 +1121,37 @@ class SettingsOptionsPane : OptionsPane() {
val builder = FormBuilder.create().layout(layout).debug(false)
val box = Box.createHorizontalBox()
box.add(typeComboBox)
if (typeComboBox.selectedItem == SyncType.GitLab) {
if (typeComboBox.selectedItem == SyncType.GitLab || typeComboBox.selectedItem == SyncType.WebDAV) {
box.add(Box.createHorizontalStrut(4))
box.add(domainTextField)
}
builder.add("${I18n.getString("termora.settings.sync.type")}:").xy(1, rows)
.add(box).xy(3, rows).apply { rows += step }
builder.add("${I18n.getString("termora.settings.sync.token")}:").xy(1, rows)
.add(tokenTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.sync.gist")}:").xy(1, rows)
.add(gistTextField).xy(3, rows).apply { rows += step }
val isWebDAV = typeComboBox.selectedItem == SyncType.WebDAV
val tokenText = if (isWebDAV) {
I18n.getString("termora.new-host.general.username")
} else {
I18n.getString("termora.settings.sync.token")
}
val gistText = if (isWebDAV) {
I18n.getString("termora.new-host.general.password")
} else {
I18n.getString("termora.settings.sync.gist")
}
if (typeComboBox.selectedItem == SyncType.Gitee || isWebDAV) {
gistTextField.trailingComponent = null
} else {
gistTextField.trailingComponent = visitGistBtn
}
builder.add("${tokenText}:").xy(1, rows)
.add(if (isWebDAV) gistTextField else tokenTextField).xy(3, rows).apply { rows += step }
.add("${gistText}:").xy(1, rows)
.add(if (isWebDAV) tokenTextField else gistTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows)
.add(rangeBox).xy(3, rows).apply { rows += step }
// Sync buttons

View File

@@ -12,6 +12,7 @@ import org.apache.sshd.common.SshException
import org.apache.sshd.common.channel.PtyChannelConfiguration
import org.apache.sshd.common.global.KeepAliveHandler
import org.apache.sshd.common.kex.BuiltinDHFactories
import org.apache.sshd.common.keyprovider.KeyIdentityProvider
import org.apache.sshd.common.util.net.SshdSocketAddress
import org.apache.sshd.core.CoreModuleProperties
import org.apache.sshd.server.forward.AcceptAllForwardingFilter
@@ -156,6 +157,11 @@ object SshClients {
builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY)
val sshClient = builder.build() as JGitSshClient
// https://github.com/TermoraDev/termora/issues/180
// JGit 会尝试读取本地的私钥或缓存的私钥
sshClient.keyIdentityProvider = KeyIdentityProvider { mutableListOf() }
val heartbeatInterval = max(host.options.heartbeatInterval, 3)
CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong()))
CoreModuleProperties.ALLOW_DHG1_KEX_FALLBACK.set(sshClient, true)

View File

@@ -21,6 +21,11 @@ class TerminalFactory private constructor() : Disposable {
// terminal logger listener
terminal.getTerminalModel().addDataListener(TerminalLoggerDataListener(terminal))
terminal.addTerminalListener(object : TerminalListener {
override fun onClose(terminal: Terminal) {
terminals.remove(terminal)
}
})
terminals.add(terminal)
return terminal
@@ -51,6 +56,11 @@ class TerminalFactory private constructor() : Disposable {
return colorPalette
}
override fun bell() {
if (config.beep) {
super.bell()
}
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(key: DataKey<T>): T {

View File

@@ -23,6 +23,11 @@ class TerminalPanelFactory {
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
Disposer.register(terminalPanel, object : Disposable {
override fun dispose() {
terminalPanels.remove(terminalPanel)
}
})
terminalPanels.add(terminalPanel)
return terminalPanel
}
@@ -47,4 +52,8 @@ class TerminalPanelFactory {
}
}
fun removeTerminalPanel(terminalPanel: TerminalPanel) {
terminalPanels.remove(terminalPanel)
}
}

View File

@@ -4,7 +4,8 @@ import com.formdev.flatlaf.util.SystemInfo
import org.slf4j.LoggerFactory
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.WindowConstants.DISPOSE_ON_CLOSE
import javax.swing.JOptionPane
import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE
import kotlin.system.exitProcess
class TermoraFrameManager {
@@ -22,7 +23,7 @@ class TermoraFrameManager {
val frame = TermoraFrame()
registerCloseCallback(frame)
frame.title = if (SystemInfo.isLinux) null else Application.getName()
frame.defaultCloseOperation = DISPOSE_ON_CLOSE
frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE
frame.setSize(1280, 800)
frame.setLocationRelativeTo(null)
return frame
@@ -43,6 +44,21 @@ class TermoraFrameManager {
this@TermoraFrameManager.dispose()
}
}
override fun windowClosing(e: WindowEvent) {
if (ApplicationScope.windowScopes().size == 1) {
if (OptionPane.showConfirmDialog(
window,
I18n.getString("termora.quit-confirm", Application.getName()),
optionType = JOptionPane.YES_NO_OPTION,
) == JOptionPane.YES_OPTION
) {
window.dispose()
}
} else {
window.dispose()
}
}
})
}

View File

@@ -4,6 +4,7 @@ import app.termora.Application.ohMyJson
import kotlinx.serialization.json.*
import okhttp3.Request
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.commonmark.node.BulletList
import org.commonmark.node.Heading
import org.commonmark.node.Paragraph
@@ -97,7 +98,14 @@ class UpdaterManager private constructor() {
}
val parser = Parser.builder().build()
val document = parser.parse("# ${name.trim()}\n${body.trim()}")
val document = parser.parse(
"# 🎉 ${name.trim()} (${
DateFormatUtils.format(
publishedDate,
"yyyy-MM-dd"
)
}) \n${body.trim()}"
)
val renderer = HtmlRenderer.builder()
.attributeProviderFactory {
AttributeProvider { node, _, attributes ->
@@ -106,7 +114,7 @@ class UpdaterManager private constructor() {
attributes["style"] = "margin: 5px 0;"
} else if (node is BulletList) {
attributes["style"] = "margin: 0 20px;"
}else if(node is Paragraph){
} else if (node is Paragraph) {
attributes["style"] = "margin: 0;"
}
}

View File

@@ -28,6 +28,7 @@ class TerminalUserInteraction(
prompt[i],
true
)
dialog.setLocationRelativeTo(owner)
dialog.title = instruction ?: name ?: StringUtils.EMPTY
passwords[i] = dialog.getText()
if (passwords[i].isBlank()) {

View File

@@ -1,9 +1,6 @@
package app.termora.keymap
import app.termora.ApplicationScope
import app.termora.Database
import app.termora.DialogWrapper
import app.termora.Disposable
import app.termora.*
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.findeverywhere.FindEverywhereAction
@@ -17,6 +14,7 @@ import java.awt.event.KeyEvent
import javax.swing.JComponent
import javax.swing.JDialog
import javax.swing.KeyStroke
import javax.swing.SwingUtilities
class KeymapManager private constructor() : Disposable {
@@ -32,8 +30,9 @@ class KeymapManager private constructor() : Disposable {
private val keymapKeyEventDispatcher = KeymapKeyEventDispatcher()
private val myKeyEventDispatcher = MyKeyEventDispatcher()
private val database get() = Database.getDatabase()
private val properties get() = database.properties
private val keymaps = linkedMapOf<String, Keymap>()
private val activeKeymap get() = database.properties.getString("Keymap.Active")
private val activeKeymap get() = properties.getString("Keymap.Active")
private val keyboardFocusManager by lazy { KeyboardFocusManager.getCurrentKeyboardFocusManager() }
init {
@@ -146,20 +145,39 @@ class KeymapManager private constructor() : Disposable {
}
@Deprecated(message = "Deprecated")
private inner class MyKeyEventDispatcher : KeyEventDispatcher {
// double shift
private var lastTime = -1L
private val findEverywhereAction
get() = ActionManager.getInstance().getAction(FindEverywhereAction.FIND_EVERYWHERE)
private val deprecatedKey by lazy { "${Application.getVersion()}.FindEverywhereActionDeprecated" }
override fun dispatchKeyEvent(e: KeyEvent): Boolean {
if (e.keyCode == KeyEvent.VK_SHIFT && e.id == KeyEvent.KEY_PRESSED) {
val owner = AnActionEvent(e.source, StringUtils.EMPTY, e).getData(DataProviders.TermoraFrame)
?: return false
val evt = AnActionEvent(e.source, StringUtils.EMPTY, e)
val owner = evt.getData(DataProviders.TermoraFrame) ?: return false
if (keyboardFocusManager.focusedWindow == owner) {
val now = System.currentTimeMillis()
if (now - 250 < lastTime) {
app.termora.actions.ActionManager.getInstance()
.getAction(FindEverywhereAction.FIND_EVERYWHERE)
?.actionPerformed(AnActionEvent(e.source, StringUtils.EMPTY, e))
if (!properties.getString(deprecatedKey, "false").toBoolean()) {
properties.putString(deprecatedKey, "true")
val shortcut = getActiveKeymap().getShortcut(FindEverywhereAction.FIND_EVERYWHERE)
.firstOrNull()
if (shortcut == null) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.find-everywhere.double-shift-deprecated")
)
} else {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.find-everywhere.double-shift-deprecated-instead", shortcut)
)
}
}
SwingUtilities.invokeLater { findEverywhereAction?.actionPerformed(evt) }
}
lastTime = now
}

View File

@@ -2,6 +2,9 @@ package app.termora.keymgr
import app.termora.*
import app.termora.AES.decodeBase64
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.native.FileChooser
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatTable
@@ -48,6 +51,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
private val exportBtn = JButton(I18n.getString("termora.keymgr.export"))
private val editBtn = JButton(I18n.getString("termora.keymgr.edit"))
private val deleteBtn = JButton(I18n.getString("termora.remove"))
private val sshCopyIdBtn = JButton("ssh-copy-id")
init {
initView()
@@ -59,6 +63,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
exportBtn.isEnabled = false
editBtn.isEnabled = false
sshCopyIdBtn.isEnabled = false
deleteBtn.isEnabled = false
keyPairTableModel.addColumn(I18n.getString("termora.keymgr.table.name"))
@@ -75,7 +80,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
val formMargin = "4dlu"
val layout = FormLayout(
"default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin"
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref"
)
var rows = 1
@@ -91,6 +96,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
.add(importBtn).xy(1, rows).apply { rows += step }
.add(exportBtn).xy(1, rows).apply { rows += step }
.add(deleteBtn).xy(1, rows).apply { rows += step }
.add(sshCopyIdBtn).xy(1, rows).apply { rows += step }
.build(), BorderLayout.EAST)
border = BorderFactory.createEmptyBorder(if (SystemInfo.isWindows || SystemInfo.isLinux) 6 else 0, 12, 12, 12)
@@ -175,13 +181,48 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
}
})
sshCopyIdBtn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
sshCopyId(evt)
}
})
keyPairTable.selectionModel.addListSelectionListener {
exportBtn.isEnabled = keyPairTable.selectedRowCount > 0
editBtn.isEnabled = exportBtn.isEnabled
deleteBtn.isEnabled = exportBtn.isEnabled
sshCopyIdBtn.isEnabled = exportBtn.isEnabled
}
}
private fun sshCopyId(evt: AnActionEvent) {
val windowScope = evt.getData(DataProviders.WindowScope) ?: return
val keyPairs = keyPairTable.selectedRows.map { keyPairTableModel.getOhKeyPair(it) }
val publicKeys = mutableListOf<Pair<String, String>>()
for (keyPair in keyPairs) {
val publicKey = OhKeyPairKeyPairProvider.generateKeyPair(keyPair).public
val baos = ByteArrayOutputStream()
OpenSSHKeyPairResourceWriter.INSTANCE.writePublicKey(publicKey, keyPair.name, baos)
publicKeys.add(Pair(keyPair.name, baos.toString(Charsets.UTF_8)))
}
if (publicKeys.isEmpty()) {
return
}
val owner = SwingUtilities.getWindowAncestor(this) ?: return
val hostTreeDialog = HostTreeDialog(owner) {
it.protocol == Protocol.SSH
}
hostTreeDialog.isVisible = true
val hosts = hostTreeDialog.hosts
if (hosts.isEmpty()) {
return
}
SSHCopyIdDialog(owner, windowScope, hosts, publicKeys).start()
}
private fun exportKeyPairs(file: File, keyPairs: List<OhKeyPair>) {
file.outputStream().use { fis ->
val names = mutableMapOf<String, Int>()

View File

@@ -0,0 +1,197 @@
package app.termora.keymgr
import app.termora.*
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnectorDelegate
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.IOUtils
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ClientChannelEvent
import org.apache.sshd.client.session.ClientSession
import org.slf4j.LoggerFactory
import java.awt.Dimension
import java.awt.Window
import java.io.ByteArrayOutputStream
import java.time.Duration
import java.util.*
import javax.swing.AbstractAction
import javax.swing.JComponent
import javax.swing.UIManager
class SSHCopyIdDialog(
owner: Window,
private val windowScope: WindowScope,
private val hosts: List<Host>,
// key: name , value: public key
private val publicKeys: List<Pair<String, String>>,
) : DialogWrapper(owner) {
companion object {
private val log = LoggerFactory.getLogger(SSHCopyIdDialog::class.java)
}
private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
private val terminal by lazy {
TerminalFactory.getInstance(windowScope).createTerminal().apply {
getTerminalModel().setData(DataKey.ShowCursor, false)
getTerminalModel().setData(DataKey.AutoNewline, true)
}
}
private val terminalPanel by lazy {
terminalPanelFactory.createTerminalPanel(terminal, PtyConnectorDelegate())
}
private val coroutineScope = CoroutineScope(Job() + Dispatchers.IO)
init {
size = Dimension(UIManager.getInt("Dialog.width") - 100, UIManager.getInt("Dialog.height") - 100)
isModal = true
title = "SSH Copy ID"
setLocationRelativeTo(null)
Disposer.register(disposable, object : Disposable {
override fun dispose() {
coroutineScope.cancel()
terminal.close()
Disposer.dispose(terminalPanel)
}
})
init()
}
override fun createCenterPanel(): JComponent {
return terminalPanel
}
fun start() {
coroutineScope.launch {
try {
doStart()
} catch (e: Exception) {
log.error(e.message, e)
}
}
isVisible = true
}
override fun createActions(): List<AbstractAction> {
return listOf(CancelAction())
}
private fun magenta(text: Any): String {
return "${ControlCharacters.ESC}[35m${text}${ControlCharacters.ESC}[0m"
}
private fun cyan(text: Any): String {
return "${ControlCharacters.ESC}[36m${text}${ControlCharacters.ESC}[0m"
}
private fun red(text: Any): String {
return "${ControlCharacters.ESC}[31m${text}${ControlCharacters.ESC}[0m"
}
private fun green(text: Any): String {
return "${ControlCharacters.ESC}[32m${text}${ControlCharacters.ESC}[0m"
}
private suspend fun doStart() {
withContext(Dispatchers.Swing) {
terminal.write(
I18n.getString(
"termora.keymgr.ssh-copy-id.number",
magenta(hosts.size),
magenta(publicKeys.size)
)
)
terminal.getDocument().newline()
terminal.getDocument().newline()
}
var myClient: SshClient? = null
var mySession: ClientSession? = null
val timeout = Duration.ofMinutes(1)
// 获取公钥名称最长的
val publicKeyNameLength = publicKeys.maxOfOrNull { it.first.length } ?: 0
for (index in hosts.indices) {
if (!coroutineScope.isActive) {
return
}
val host = hosts[index]
withContext(Dispatchers.Swing) {
terminal.write("[${cyan(index + 1)}/${cyan(hosts.size)}] ${host.name}")
terminal.getDocument().newline()
}
for ((j, e) in publicKeys.withIndex()) {
if (!coroutineScope.isActive) {
return
}
val publicKeyName = e.first.padEnd(publicKeyNameLength, ' ')
val publicKey = e.second
withContext(Dispatchers.Swing) {
// @formatter:off
terminal.write("\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] $publicKeyName ${I18n.getString("termora.transport.sftp.connecting")}")
// @formatter:on
}
try {
val client = SshClients.openClient(host).apply { myClient = this }
client.userInteraction = TerminalUserInteraction(owner)
val session = SshClients.openSession(host, client).apply { mySession = this }
val channel =
session.createExecChannel("mkdir -p ~/.ssh && grep -qxF \"$publicKey\" ~/.ssh/authorized_keys || echo \"$publicKey\" >> ~/.ssh/authorized_keys")
val baos = ByteArrayOutputStream()
channel.out = baos
if (channel.open().verify(timeout).await(timeout)) {
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout);
}
if (channel.exitStatus != 0) {
throw IllegalStateException("Server response: ${channel.exitStatus}")
}
withContext(Dispatchers.Swing) {
terminal.getDocument().eraseInLine(2)
// @formatter:off
terminal.write("\r\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] $publicKeyName ${green(I18n.getString("termora.keymgr.ssh-copy-id.successful"))}")
// @formatter:on
}
} catch (e: Exception) {
withContext(Dispatchers.Swing) {
terminal.getDocument().eraseInLine(2)
// @formatter:off
terminal.write("\r\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] $publicKeyName ${red("${I18n.getString("termora.keymgr.ssh-copy-id.failed")}: ${e.message}")}")
// @formatter:on
}
} finally {
IOUtils.closeQuietly(mySession)
IOUtils.closeQuietly(myClient)
}
withContext(Dispatchers.Swing) {
terminal.getDocument().newline()
}
}
withContext(Dispatchers.Swing) {
terminal.getDocument().newline()
}
}
withContext(Dispatchers.Swing) {
terminal.write(I18n.getString("termora.keymgr.ssh-copy-id.end"))
}
}
}

View File

@@ -1,42 +1,19 @@
package app.termora.sync
import app.termora.*
import app.termora.AES.CBC.aesCBCDecrypt
import app.termora.AES.CBC.aesCBCEncrypt
import app.termora.AES.decodeBase64
import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson
import app.termora.highlight.KeywordHighlight
import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.Keymap
import app.termora.keymap.KeymapManager
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.macro.MacroManager
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonObject
import app.termora.ResponseException
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Request
import okhttp3.Response
import org.apache.commons.lang3.ArrayUtils
import org.slf4j.LoggerFactory
import javax.swing.SwingUtilities
abstract class GitSyncer : Syncer {
abstract class GitSyncer : SafetySyncer() {
companion object {
private val log = LoggerFactory.getLogger(GitSyncer::class.java)
}
protected val description = "${Application.getName()} config"
protected val httpClient get() = Application.httpClient
protected val hostManager get() = HostManager.getInstance()
protected val keyManager get() = KeyManager.getInstance()
protected val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
protected val macroManager get() = MacroManager.getInstance()
protected val keymapManager get() = KeymapManager.getInstance()
override fun pull(config: SyncConfig): GistResponse {
if (log.isInfoEnabled) {
@@ -92,174 +69,6 @@ abstract class GitSyncer : Syncer {
return gistResponse
}
private fun decodeHosts(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedHosts = ohMyJson.decodeFromString<List<EncryptedHost>>(text)
val hosts = hostManager.hosts().associateBy { it.id }
for (encryptedHost in encryptedHosts) {
val oldHost = hosts[encryptedHost.id]
// 如果一样,则无需配置
if (oldHost != null) {
if (oldHost.updateDate == encryptedHost.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(encryptedHost.id)
val host = Host(
id = encryptedHost.id,
name = encryptedHost.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
protocol = Protocol.valueOf(
encryptedHost.protocol.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
host = encryptedHost.host.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
port = encryptedHost.port.decodeBase64().aesCBCDecrypt(key, iv)
.decodeToString().toIntOrNull() ?: 0,
username = encryptedHost.username.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedHost.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
authentication = ohMyJson.decodeFromString(
encryptedHost.authentication.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
proxy = ohMyJson.decodeFromString(
encryptedHost.proxy.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
options = ohMyJson.decodeFromString(
encryptedHost.options.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
tunnelings = ohMyJson.decodeFromString(
encryptedHost.tunnelings.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
sort = encryptedHost.sort,
parentId = encryptedHost.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
ownerId = encryptedHost.ownerId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
createDate = encryptedHost.createDate,
updateDate = encryptedHost.updateDate,
deleted = encryptedHost.deleted
)
SwingUtilities.invokeLater { hostManager.addHost(host) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode host: ${encryptedHost.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode hosts: {}", text)
}
}
private fun decodeKeys(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeys = ohMyJson.decodeFromString<List<OhKeyPair>>(text)
for (encryptedKey in encryptedKeys) {
try {
// aes iv
val iv = getIv(encryptedKey.id)
val keyPair = OhKeyPair(
id = encryptedKey.id,
publicKey = encryptedKey.publicKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
privateKey = encryptedKey.privateKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
type = encryptedKey.type.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
name = encryptedKey.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedKey.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
length = encryptedKey.length,
sort = encryptedKey.sort
)
SwingUtilities.invokeLater { keyManager.addOhKeyPair(keyPair) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode key: ${encryptedKey.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode keys: {}", text)
}
}
private fun decodeKeywordHighlights(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeywordHighlights = ohMyJson.decodeFromString<List<KeywordHighlight>>(text)
for (e in encryptedKeywordHighlights) {
try {
// aes iv
val iv = getIv(e.id)
keywordHighlightManager.addKeywordHighlight(
e.copy(
keyword = e.keyword.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
description = e.description.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode KeywordHighlight: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode KeywordHighlight: {}", text)
}
}
private fun decodeMacros(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedMacros = ohMyJson.decodeFromString<List<Macro>>(text)
for (e in encryptedMacros) {
try {
// aes iv
val iv = getIv(e.id)
macroManager.addMacro(
e.copy(
name = e.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
macro = e.macro.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode Macro: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode Macros: {}", text)
}
}
private fun decodeKeymaps(text: String, config: SyncConfig) {
for (keymap in ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }) {
keymapManager.addKeymap(keymap)
}
if (log.isDebugEnabled) {
log.debug("Decode Keymaps: {}", text)
}
}
private fun getKey(config: SyncConfig): ByteArray {
return ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16)
}
private fun getIv(id: String): ByteArray {
return ArrayUtils.subarray(id.padEnd(16, '0').toByteArray(), 0, 16)
}
override fun push(config: SyncConfig): GistResponse {
val gistFiles = mutableListOf<GistFile>()
@@ -268,62 +77,16 @@ abstract class GitSyncer : Syncer {
// Hosts
if (config.ranges.contains(SyncRange.Hosts)) {
val encryptedHosts = mutableListOf<EncryptedHost>()
for (host in hostManager.hosts()) {
// aes iv
val iv = ArrayUtils.subarray(host.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedHost = EncryptedHost()
encryptedHost.id = host.id
encryptedHost.name = host.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.protocol = host.protocol.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.host = host.host.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.port = "${host.port}".aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.username = host.username.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.remark = host.remark.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.authentication = ohMyJson.encodeToString(host.authentication)
.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.proxy = ohMyJson.encodeToString(host.proxy).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.options =
ohMyJson.encodeToString(host.options).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.tunnelings =
ohMyJson.encodeToString(host.tunnelings).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.sort = host.sort
encryptedHost.deleted = host.deleted
encryptedHost.parentId = host.parentId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.ownerId = host.ownerId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.creatorId = host.creatorId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.createDate = host.createDate
encryptedHost.updateDate = host.updateDate
encryptedHosts.add(encryptedHost)
}
val hostsContent = ohMyJson.encodeToString(encryptedHosts)
val hostsContent = encodeHosts(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedHosts: {}", hostsContent)
}
gistFiles.add(GistFile("Hosts", hostsContent))
}
// KeyPairs
if (config.ranges.contains(SyncRange.KeyPairs)) {
val encryptedKeys = mutableListOf<OhKeyPair>()
for (keyPair in keyManager.getOhKeyPairs()) {
// aes iv
val iv = ArrayUtils.subarray(keyPair.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedKeyPair = OhKeyPair(
id = keyPair.id,
publicKey = keyPair.publicKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
privateKey = keyPair.privateKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
type = keyPair.type.aesCBCEncrypt(key, iv).encodeBase64String(),
name = keyPair.name.aesCBCEncrypt(key, iv).encodeBase64String(),
remark = keyPair.remark.aesCBCEncrypt(key, iv).encodeBase64String(),
length = keyPair.length,
sort = keyPair.sort
)
encryptedKeys.add(encryptedKeyPair)
}
val keysContent = ohMyJson.encodeToString(encryptedKeys)
val keysContent = encodeKeys(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedKeys: {}", keysContent)
}
@@ -332,17 +95,7 @@ abstract class GitSyncer : Syncer {
// Highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
val keywordHighlights = mutableListOf<KeywordHighlight>()
for (keywordHighlight in keywordHighlightManager.getKeywordHighlights()) {
// aes iv
val iv = getIv(keywordHighlight.id)
val encryptedKeyPair = keywordHighlight.copy(
keyword = keywordHighlight.keyword.aesCBCEncrypt(key, iv).encodeBase64String(),
description = keywordHighlight.description.aesCBCEncrypt(key, iv).encodeBase64String(),
)
keywordHighlights.add(encryptedKeyPair)
}
val keywordHighlightsContent = ohMyJson.encodeToString(keywordHighlights)
val keywordHighlightsContent = encodeKeywordHighlights(key)
if (log.isDebugEnabled) {
log.debug("Push keywordHighlights: {}", keywordHighlightsContent)
}
@@ -351,17 +104,7 @@ abstract class GitSyncer : Syncer {
// Macros
if (config.ranges.contains(SyncRange.Macros)) {
val macros = mutableListOf<Macro>()
for (macro in macroManager.getMacros()) {
val iv = getIv(macro.id)
macros.add(
macro.copy(
name = macro.name.aesCBCEncrypt(key, iv).encodeBase64String(),
macro = macro.macro.aesCBCEncrypt(key, iv).encodeBase64String()
)
)
}
val macrosContent = ohMyJson.encodeToString(macros)
val macrosContent = encodeMacros(key)
if (log.isDebugEnabled) {
log.debug("Push macros: {}", macrosContent)
}
@@ -370,23 +113,12 @@ abstract class GitSyncer : Syncer {
// Keymap
if (config.ranges.contains(SyncRange.Keymap)) {
val keymaps = mutableListOf<JsonObject>()
for (keymap in keymapManager.getKeymaps()) {
// 只读的是内置的
if (keymap.isReadonly) {
continue
}
keymaps.add(keymap.toJSONObject())
}
if (keymaps.isNotEmpty()) {
val keymapsContent = ohMyJson.encodeToString(keymaps)
val keymapsContent = encodeKeymaps()
if (log.isDebugEnabled) {
log.debug("Push keymaps: {}", keymapsContent)
}
gistFiles.add(GistFile("Keymaps", keymapsContent))
}
}
if (gistFiles.isEmpty()) {
throw IllegalArgumentException("No gist files found")

View File

@@ -0,0 +1,299 @@
package app.termora.sync
import app.termora.*
import app.termora.AES.CBC.aesCBCDecrypt
import app.termora.AES.CBC.aesCBCEncrypt
import app.termora.AES.decodeBase64
import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson
import app.termora.highlight.KeywordHighlight
import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.Keymap
import app.termora.keymap.KeymapManager
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.macro.MacroManager
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonObject
import org.apache.commons.lang3.ArrayUtils
import org.slf4j.LoggerFactory
import javax.swing.SwingUtilities
abstract class SafetySyncer : Syncer {
companion object {
private val log = LoggerFactory.getLogger(SafetySyncer::class.java)
}
protected val description = "${Application.getName()} config"
protected val httpClient get() = Application.httpClient
protected val hostManager get() = HostManager.getInstance()
protected val keyManager get() = KeyManager.getInstance()
protected val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
protected val macroManager get() = MacroManager.getInstance()
protected val keymapManager get() = KeymapManager.getInstance()
protected fun decodeHosts(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedHosts = ohMyJson.decodeFromString<List<EncryptedHost>>(text)
val hosts = hostManager.hosts().associateBy { it.id }
for (encryptedHost in encryptedHosts) {
val oldHost = hosts[encryptedHost.id]
// 如果一样,则无需配置
if (oldHost != null) {
if (oldHost.updateDate == encryptedHost.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(encryptedHost.id)
val host = Host(
id = encryptedHost.id,
name = encryptedHost.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
protocol = Protocol.valueOf(
encryptedHost.protocol.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
host = encryptedHost.host.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
port = encryptedHost.port.decodeBase64().aesCBCDecrypt(key, iv)
.decodeToString().toIntOrNull() ?: 0,
username = encryptedHost.username.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedHost.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
authentication = ohMyJson.decodeFromString(
encryptedHost.authentication.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
proxy = ohMyJson.decodeFromString(
encryptedHost.proxy.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
options = ohMyJson.decodeFromString(
encryptedHost.options.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
tunnelings = ohMyJson.decodeFromString(
encryptedHost.tunnelings.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
sort = encryptedHost.sort,
parentId = encryptedHost.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
ownerId = encryptedHost.ownerId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
createDate = encryptedHost.createDate,
updateDate = encryptedHost.updateDate,
deleted = encryptedHost.deleted
)
SwingUtilities.invokeLater { hostManager.addHost(host) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode host: ${encryptedHost.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode hosts: {}", text)
}
}
protected fun encodeHosts(key: ByteArray): String {
val encryptedHosts = mutableListOf<EncryptedHost>()
for (host in hostManager.hosts()) {
// aes iv
val iv = ArrayUtils.subarray(host.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedHost = EncryptedHost()
encryptedHost.id = host.id
encryptedHost.name = host.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.protocol = host.protocol.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.host = host.host.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.port = "${host.port}".aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.username = host.username.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.remark = host.remark.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.authentication = ohMyJson.encodeToString(host.authentication)
.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.proxy = ohMyJson.encodeToString(host.proxy).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.options =
ohMyJson.encodeToString(host.options).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.tunnelings =
ohMyJson.encodeToString(host.tunnelings).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.sort = host.sort
encryptedHost.deleted = host.deleted
encryptedHost.parentId = host.parentId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.ownerId = host.ownerId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.creatorId = host.creatorId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.createDate = host.createDate
encryptedHost.updateDate = host.updateDate
encryptedHosts.add(encryptedHost)
}
return ohMyJson.encodeToString(encryptedHosts)
}
protected fun decodeKeys(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeys = ohMyJson.decodeFromString<List<OhKeyPair>>(text)
for (encryptedKey in encryptedKeys) {
try {
// aes iv
val iv = getIv(encryptedKey.id)
val keyPair = OhKeyPair(
id = encryptedKey.id,
publicKey = encryptedKey.publicKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
privateKey = encryptedKey.privateKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
type = encryptedKey.type.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
name = encryptedKey.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedKey.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
length = encryptedKey.length,
sort = encryptedKey.sort
)
SwingUtilities.invokeLater { keyManager.addOhKeyPair(keyPair) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode key: ${encryptedKey.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode keys: {}", text)
}
}
protected fun encodeKeys(key: ByteArray): String {
val encryptedKeys = mutableListOf<OhKeyPair>()
for (keyPair in keyManager.getOhKeyPairs()) {
// aes iv
val iv = ArrayUtils.subarray(keyPair.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedKeyPair = OhKeyPair(
id = keyPair.id,
publicKey = keyPair.publicKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
privateKey = keyPair.privateKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
type = keyPair.type.aesCBCEncrypt(key, iv).encodeBase64String(),
name = keyPair.name.aesCBCEncrypt(key, iv).encodeBase64String(),
remark = keyPair.remark.aesCBCEncrypt(key, iv).encodeBase64String(),
length = keyPair.length,
sort = keyPair.sort
)
encryptedKeys.add(encryptedKeyPair)
}
return ohMyJson.encodeToString(encryptedKeys)
}
protected fun decodeKeywordHighlights(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeywordHighlights = ohMyJson.decodeFromString<List<KeywordHighlight>>(text)
for (e in encryptedKeywordHighlights) {
try {
// aes iv
val iv = getIv(e.id)
keywordHighlightManager.addKeywordHighlight(
e.copy(
keyword = e.keyword.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
description = e.description.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode KeywordHighlight: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode KeywordHighlight: {}", text)
}
}
protected fun encodeKeywordHighlights(key: ByteArray): String {
val keywordHighlights = mutableListOf<KeywordHighlight>()
for (keywordHighlight in keywordHighlightManager.getKeywordHighlights()) {
// aes iv
val iv = getIv(keywordHighlight.id)
val encryptedKeyPair = keywordHighlight.copy(
keyword = keywordHighlight.keyword.aesCBCEncrypt(key, iv).encodeBase64String(),
description = keywordHighlight.description.aesCBCEncrypt(key, iv).encodeBase64String(),
)
keywordHighlights.add(encryptedKeyPair)
}
return ohMyJson.encodeToString(keywordHighlights)
}
protected fun decodeMacros(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedMacros = ohMyJson.decodeFromString<List<Macro>>(text)
for (e in encryptedMacros) {
try {
// aes iv
val iv = getIv(e.id)
macroManager.addMacro(
e.copy(
name = e.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
macro = e.macro.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode Macro: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode Macros: {}", text)
}
}
protected fun encodeMacros(key: ByteArray): String {
val macros = mutableListOf<Macro>()
for (macro in macroManager.getMacros()) {
val iv = getIv(macro.id)
macros.add(
macro.copy(
name = macro.name.aesCBCEncrypt(key, iv).encodeBase64String(),
macro = macro.macro.aesCBCEncrypt(key, iv).encodeBase64String()
)
)
}
return ohMyJson.encodeToString(macros)
}
protected fun decodeKeymaps(text: String, config: SyncConfig) {
for (keymap in ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }) {
keymapManager.addKeymap(keymap)
}
if (log.isDebugEnabled) {
log.debug("Decode Keymaps: {}", text)
}
}
protected fun encodeKeymaps(): String {
val keymaps = mutableListOf<JsonObject>()
for (keymap in keymapManager.getKeymaps()) {
// 只读的是内置的
if (keymap.isReadonly) {
continue
}
keymaps.add(keymap.toJSONObject())
}
return ohMyJson.encodeToString(keymaps)
}
protected open fun getKey(config: SyncConfig): ByteArray {
return ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16)
}
protected fun getIv(id: String): ByteArray {
return ArrayUtils.subarray(id.padEnd(16, '0').toByteArray(), 0, 16)
}
}

View File

@@ -4,6 +4,7 @@ enum class SyncType {
GitLab,
GitHub,
Gitee,
WebDAV,
}
enum class SyncRange {

View File

@@ -15,6 +15,7 @@ class SyncerProvider private constructor() {
SyncType.GitHub -> GitHubSyncer.getInstance()
SyncType.Gitee -> GiteeSyncer.getInstance()
SyncType.GitLab -> GitLabSyncer.getInstance()
SyncType.WebDAV -> WebDAVSyncer.getInstance()
}
}
}

View File

@@ -0,0 +1,152 @@
package app.termora.sync
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.PBKDF2
import app.termora.ResponseException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.Credentials
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.slf4j.LoggerFactory
class WebDAVSyncer private constructor() : SafetySyncer() {
companion object {
private val log = LoggerFactory.getLogger(WebDAVSyncer::class.java)
fun getInstance(): WebDAVSyncer {
return ApplicationScope.forApplicationScope().getOrCreate(WebDAVSyncer::class) { WebDAVSyncer() }
}
}
override fun pull(config: SyncConfig): GistResponse {
val response = httpClient.newCall(newRequestBuilder(config).get().build()).execute()
if (!response.isSuccessful) {
throw ResponseException(response.code, response)
}
val text = response.use { resp -> resp.body?.use { it.string() } }
?: throw ResponseException(response.code, response)
val json = ohMyJson.decodeFromString<JsonObject>(text)
// decode hosts
json["Hosts"]?.jsonPrimitive?.content?.let {
decodeHosts(it, config)
}
// decode KeyPairs
json["KeyPairs"]?.jsonPrimitive?.content?.let {
decodeKeys(it, config)
}
// decode Highlights
json["KeywordHighlights"]?.jsonPrimitive?.content?.let {
decodeKeywordHighlights(it, config)
}
// decode Macros
json["Macros"]?.jsonPrimitive?.content?.let {
decodeMacros(it, config)
}
// decode Keymaps
json["Keymaps"]?.jsonPrimitive?.content?.let {
decodeKeymaps(it, config)
}
return GistResponse(config, emptyList())
}
override fun push(config: SyncConfig): GistResponse {
// aes key
val key = getKey(config)
val json = buildJsonObject {
// Hosts
if (config.ranges.contains(SyncRange.Hosts)) {
val hostsContent = encodeHosts(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedHosts: {}", hostsContent)
}
put("Hosts", hostsContent)
}
// KeyPairs
if (config.ranges.contains(SyncRange.KeyPairs)) {
val keysContent = encodeKeys(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedKeys: {}", keysContent)
}
put("KeyPairs", keysContent)
}
// Highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
val keywordHighlightsContent = encodeKeywordHighlights(key)
if (log.isDebugEnabled) {
log.debug("Push keywordHighlights: {}", keywordHighlightsContent)
}
put("KeywordHighlights", keywordHighlightsContent)
}
// Macros
if (config.ranges.contains(SyncRange.Macros)) {
val macrosContent = encodeMacros(key)
if (log.isDebugEnabled) {
log.debug("Push macros: {}", macrosContent)
}
put("Macros", macrosContent)
}
// Keymap
if (config.ranges.contains(SyncRange.Keymap)) {
val keymapsContent = encodeKeymaps()
if (log.isDebugEnabled) {
log.debug("Push keymaps: {}", keymapsContent)
}
put("Keymaps", keymapsContent)
}
}
val response = httpClient.newCall(
newRequestBuilder(config).put(
ohMyJson.encodeToString(json)
.toRequestBody("application/json".toMediaType())
).build()
).execute()
if (!response.isSuccessful) {
throw ResponseException(response.code, response)
}
return GistResponse(
config = config,
gists = emptyList()
)
}
private fun getWebDavFileUrl(config: SyncConfig): String {
return config.options["domain"] ?: throw IllegalStateException("domain is not defined")
}
override fun getKey(config: SyncConfig): ByteArray {
return PBKDF2.generateSecret(
config.gistId.toCharArray(),
config.token.toByteArray(),
10000, 128
)
}
private fun newRequestBuilder(config: SyncConfig): Request.Builder {
return Request.Builder()
.header("Authorization", Credentials.basic(config.gistId, config.token, Charsets.UTF_8))
.url(getWebDavFileUrl(config))
}
}

View File

@@ -691,6 +691,13 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer
1049 -> {
// Save cursor
if (enable) {
CursorStoreStores.store(terminal)
} else {
CursorStoreStores.restore(terminal)
}
// 如果是关闭 清屏
if (!enable) {
terminal.getDocument().eraseInDisplay(2)
@@ -924,7 +931,7 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
else -> {
if (log.isWarnEnabled) {
log.warn("xterm-256 foreground color, code: $code")
log.warn("xterm-256 background color, code: $code")
}
}
}

View File

@@ -0,0 +1,66 @@
package app.termora.terminal
import org.slf4j.LoggerFactory
object CursorStoreStores {
private val log = LoggerFactory.getLogger(CursorStoreStores::class.java)
fun restore(terminal: Terminal) {
val terminalModel = terminal.getTerminalModel()
val cursorStore = if (terminalModel.hasData(DataKey.SaveCursor)) {
terminalModel.getData(DataKey.SaveCursor)
} else {
CursorStore(
position = Position(1, 1),
textStyle = TextStyle.Default,
autoWarpMode = false,
originMode = false,
graphicCharacterSet = GraphicCharacterSet()
)
}
terminalModel.setData(DataKey.OriginMode, cursorStore.originMode)
terminalModel.setData(DataKey.TextStyle, cursorStore.textStyle)
terminalModel.setData(DataKey.AutoWrapMode, cursorStore.autoWarpMode)
terminalModel.setData(DataKey.GraphicCharacterSet, cursorStore.graphicCharacterSet)
val region = if (terminalModel.isOriginMode()) terminalModel.getScrollingRegion()
else ScrollingRegion(top = 1, bottom = terminalModel.getRows())
var y = cursorStore.position.y
if (y < region.top) {
y = 1
} else if (y > region.bottom) {
y = region.bottom
}
terminal.getCursorModel().move(row = y, col = cursorStore.position.x)
if (log.isDebugEnabled) {
log.debug("Restore Cursor (DECRC). $cursorStore")
}
}
fun store(terminal: Terminal) {
val terminalModel = terminal.getTerminalModel()
val graphicCharacterSet = terminalModel.getData(DataKey.GraphicCharacterSet)
// 避免引用
val characterSets = mutableMapOf<Graphic, CharacterSet>()
characterSets.putAll(graphicCharacterSet.characterSets)
val cursorStore = CursorStore(
position = terminal.getCursorModel().getPosition(),
textStyle = terminalModel.getData(DataKey.TextStyle),
autoWarpMode = terminalModel.getData(DataKey.AutoWrapMode, false),
originMode = terminalModel.isOriginMode(),
graphicCharacterSet = graphicCharacterSet.copy(characterSets = characterSets),
)
terminalModel.setData(DataKey.SaveCursor, cursorStore)
if (log.isDebugEnabled) {
log.debug("Save Cursor (DECSC). $cursorStore")
}
}
}

View File

@@ -333,59 +333,12 @@ class EscapeSequenceProcessor(terminal: Terminal, reader: TerminalReader) : Abst
// ESC 7 Save Cursor (DECSC), VT100.
'7' -> {
val graphicCharacterSet = terminalModel.getData(DataKey.GraphicCharacterSet)
// 避免引用
val characterSets = mutableMapOf<Graphic, CharacterSet>()
characterSets.putAll(graphicCharacterSet.characterSets)
val cursorStore = CursorStore(
position = terminal.getCursorModel().getPosition(),
textStyle = terminalModel.getData(DataKey.TextStyle),
autoWarpMode = terminalModel.getData(DataKey.AutoWrapMode, false),
originMode = terminalModel.isOriginMode(),
graphicCharacterSet = graphicCharacterSet.copy(characterSets = characterSets),
)
terminalModel.setData(DataKey.SaveCursor, cursorStore)
if (log.isDebugEnabled) {
log.debug("Save Cursor (DECSC). $cursorStore")
}
CursorStoreStores.store(terminal)
}
// Restore Cursor (DECRC), VT100.
'8' -> {
val cursorStore = if (terminalModel.hasData(DataKey.SaveCursor)) {
terminalModel.getData(DataKey.SaveCursor)
} else {
CursorStore(
position = Position(1, 1),
textStyle = TextStyle.Default,
autoWarpMode = false,
originMode = false,
graphicCharacterSet = GraphicCharacterSet()
)
}
terminalModel.setData(DataKey.OriginMode, cursorStore.originMode)
terminalModel.setData(DataKey.TextStyle, cursorStore.textStyle)
terminalModel.setData(DataKey.AutoWrapMode, cursorStore.autoWarpMode)
terminalModel.setData(DataKey.GraphicCharacterSet, cursorStore.graphicCharacterSet)
val region = if (terminalModel.isOriginMode()) terminalModel.getScrollingRegion()
else ScrollingRegion(top = 1, bottom = terminalModel.getRows())
var y = cursorStore.position.y
if (y < region.top) {
y = 1
} else if (y > region.bottom) {
y = region.bottom
}
terminal.getCursorModel().move(row = y, col = cursorStore.position.x)
if (log.isDebugEnabled) {
log.debug("Restore Cursor (DECRC). $cursorStore")
}
CursorStoreStores.restore(terminal)
}

View File

@@ -1,5 +1,6 @@
package app.termora.terminal.panel
import app.termora.Disposable
import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders
@@ -30,7 +31,7 @@ import kotlin.time.Duration.Companion.milliseconds
class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnector) :
JPanel(BorderLayout()), DataProvider {
JPanel(BorderLayout()), DataProvider, Disposable {
companion object {
val Debug = DataKey(Boolean::class)
@@ -397,22 +398,20 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
* 执行粘贴操作
*/
fun paste(text: String) {
val content = if (SystemInfo.isWindows) {
text.replace("${ControlCharacters.CR}${ControlCharacters.LF}", "${ControlCharacters.LF}")
} else {
text.replace(ControlCharacters.LF, ControlCharacters.CR)
var content = text
if (!SystemInfo.isWindows) {
content = content.replace("\r\n", "\n")
}
content = content.replace('\n', '\r')
if (terminal.getTerminalModel().getData(DataKey.BracketedPasteMode, false)) {
val bytes = ptyConnector.getCharset()
.encode("${ControlCharacters.ESC}[200~${content}${ControlCharacters.ESC}[201~")
.array()
ptyConnector.write(bytes)
ptyConnector.write(
"${ControlCharacters.ESC}[200~${content}${ControlCharacters.ESC}[201~".toByteArray(
ptyConnector.getCharset()
)
)
} else {
val bytes = ptyConnector.getCharset()
.encode(content)
.array()
ptyConnector.write(bytes)
ptyConnector.write(content.toByteArray(ptyConnector.getCharset()))
}
terminal.getScrollingModel().scrollToRow(

View File

@@ -9,7 +9,10 @@ import java.io.FileNotFoundException
import java.nio.file.Files
import javax.swing.Icon
class LogViewerTerminalTab(windowScope: WindowScope, private val file: File) : PtyHostTerminalTab(
class LogViewerTerminalTab(
windowScope: WindowScope,
private val file: File,
) : PtyHostTerminalTab(
windowScope,
Host(
name = file.name,

View File

@@ -25,9 +25,8 @@ import java.awt.Component
import java.awt.Desktop
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.dnd.DnDConstants
import java.awt.dnd.DropTarget
import java.awt.dnd.DropTargetDropEvent
import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException
import java.awt.event.ActionEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
@@ -80,6 +79,8 @@ class FileSystemPanel(
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdir.toString())
table.setUI(FlatTableUI())
table.dragEnabled = true
table.dropMode = DropMode.INSERT_ROWS
table.rowHeight = UIManager.getInt("Table.rowHeight")
table.autoResizeMode = JTable.AUTO_RESIZE_OFF
table.fillsViewportHeight = true
@@ -231,17 +232,45 @@ class FileSystemPanel(
}
})
// 本地文件系统不支持本地拖拽进去
if (!tableModel.isLocalFileSystem) {
table.dropTarget = object : DropTarget() {
override fun drop(dtde: DropTargetDropEvent) {
dtde.acceptDrop(DnDConstants.ACTION_COPY)
val files = dtde.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return
copyLocalFileToFileSystem(files.filterIsInstance<File>())
table.transferHandler = object : TransferHandler() {
override fun canImport(support: TransferSupport): Boolean {
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
return data is FileSystemTableRowTransferable && data.fileSystemPanel != this@FileSystemPanel
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
return !tableModel.isLocalFileSystem
}
}.apply {
this.defaultActions = DnDConstants.ACTION_COPY
return false
}
override fun importData(comp: JComponent?, t: Transferable): Boolean {
if (t.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = t.getTransferData(FileSystemTableRowTransferable.dataFlavor)
if (data !is FileSystemTableRowTransferable) {
return false
}
data.fileSystemPanel.transport(data.paths)
return true
} else if (t.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
val files = t.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return false
copyLocalFileToFileSystem(files.filterIsInstance<File>())
return true
}
return false
}
override fun getSourceActions(c: JComponent?): Int {
return COPY
}
override fun createTransferable(c: JComponent?): Transferable? {
val paths = table.selectedRows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
if (paths.isEmpty()) {
return null
}
return FileSystemTableRowTransferable(this@FileSystemPanel, paths)
}
}
@@ -838,4 +867,28 @@ class FileSystemPanel(
}
private class FileSystemTableRowTransferable(
val fileSystemPanel: FileSystemPanel,
val paths: List<FileSystemTableModel.CacheablePath>
) : Transferable {
companion object {
val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable")
}
override fun getTransferDataFlavors(): Array<DataFlavor> {
return arrayOf(dataFlavor)
}
override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean {
return flavor == dataFlavor
}
override fun getTransferData(flavor: DataFlavor?): Any {
if (flavor != dataFlavor) {
throw UnsupportedFlavorException(flavor)
}
return this
}
}
}

View File

@@ -11,6 +11,8 @@ termora.date-format=MM/dd/yyyy hh:mm:ss a
termora.finder=Finder
termora.folder=Folder
termora.explorer=Explorer
termora.quit-confirm=Quit {0}?
# update
termora.update.title=New version
@@ -61,9 +63,12 @@ termora.settings.terminal.font=Font
termora.settings.terminal.size=Size
termora.settings.terminal.max-rows=Max rows
termora.settings.terminal.debug=Debug mode
termora.settings.terminal.beep=Beep
termora.settings.terminal.select-copy=Select copy
termora.settings.terminal.cursor-style=Cursor type
termora.settings.terminal.local-shell=Local shell
termora.settings.terminal.auto-close-tab=Auto Close Tab
termora.settings.terminal.auto-close-tab-description=Automatically close the tab when the terminal is disconnected normally
termora.settings.sync=Sync
termora.settings.sync.push=Push
@@ -83,6 +88,7 @@ termora.settings.sync.last-sync-time=Last sync time
termora.settings.sync.gist=Gist
termora.settings.sync.token=Token
termora.settings.sync.type=Type
termora.settings.sync.webdav.help=WebDAV storage address, e.g. https://yourhost/webdav/termora.json
termora.settings.about=About
termora.settings.about.author=Author
@@ -109,6 +115,8 @@ termora.find-everywhere.groups.opened-hosts=Opened hosts
termora.find-everywhere.groups.tools=Tools
termora.find-everywhere.groups.settings=${termora.setting}
termora.find-everywhere.quick-command.local-terminal=Local Terminal
termora.find-everywhere.double-shift-deprecated=The double-click Shift shortcut will be removed in a future version
termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywhere.double-shift-deprecated}, use {0} instead
# Welcome
termora.welcome.my-hosts=My hosts
@@ -180,6 +188,12 @@ termora.keymgr.table.type=Type
termora.keymgr.table.length=Length
termora.keymgr.table.remark=Description
termora.keymgr.ssh-copy-id.number=Number of hosts [{0}] Number of public keys [{1}]
termora.keymgr.ssh-copy-id.successful=${termora.terminal.copied}
termora.keymgr.ssh-copy-id.failed=Copy Failure
termora.keymgr.ssh-copy-id.end=End of public key copying
# Tabbed
termora.tabbed.contextmenu.rename=Rename
termora.tabbed.contextmenu.clone=Clone
@@ -311,6 +325,8 @@ termora.actions.switch-tab=Switch to specific Tab [1..9]
# Terminal
termora.terminal.size=Size: {0} x {1}
termora.terminal.copied=Copied
termora.terminal.channel-disconnected=Channel has been disconnected.\u0020
termora.terminal.channel-reconnect=Type {0} to reconnect.
# zmodem

View File

@@ -10,6 +10,7 @@ termora.date-format=yyyy-MM-dd HH:mm:ss
termora.finder=访达
termora.folder=文件夹
termora.explorer=文件管理器
termora.quit-confirm=你要退出 {0} 吗?
# update
termora.update.title=新版本
@@ -59,15 +60,20 @@ termora.find-everywhere.groups.opened-hosts=已打开的主机
termora.find-everywhere.groups.tools=工具
termora.find-everywhere.groups.settings=${termora.setting}
termora.find-everywhere.quick-command.local-terminal=本地终端
termora.find-everywhere.double-shift-deprecated=双击 Shift 快捷键将会在未来的版本中移除
termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywhere.double-shift-deprecated},请使用 {0} 代替
termora.settings.terminal=终端
termora.settings.terminal.font=字体
termora.settings.terminal.size=大小
termora.settings.terminal.max-rows=最大行数
termora.settings.terminal.debug=调试模式
termora.settings.terminal.beep=蜂鸣声
termora.settings.terminal.select-copy=选中复制
termora.settings.terminal.cursor-style=光标样式
termora.settings.terminal.local-shell=本地终端
termora.settings.terminal.auto-close-tab=自动关闭标签
termora.settings.terminal.auto-close-tab-description=当终端正常断开连接时自动关闭标签页
termora.settings.sync=同步
@@ -85,6 +91,7 @@ termora.settings.sync.import.successful=导入数据成功
termora.settings.sync.gist=片段
termora.settings.sync.token=令牌
termora.settings.sync.type=类型
termora.settings.sync.webdav.help=WebDAV 的存储地址例如https://yourhost/webdav/termora.json
termora.settings.about=关于
termora.settings.about.author=作者
@@ -167,6 +174,10 @@ termora.keymgr.table.type=类型
termora.keymgr.table.length=长度
termora.keymgr.table.remark=备注
termora.keymgr.ssh-copy-id.number=主机数量 [{0}] 公钥数量 [{1}]
termora.keymgr.ssh-copy-id.failed=复制失败
termora.keymgr.ssh-copy-id.end=复制公钥结束
# Tools
termora.tools.multiple=将命令发送到所有会话
@@ -283,6 +294,8 @@ termora.toolbar.customize-toolbar=自定义工具栏...
termora.terminal.size=大小: {0} x {1}
termora.terminal.copied=已复制
termora.terminal.channel-disconnected=终端断开连接,
termora.terminal.channel-reconnect=按 {0} 进行重连。
# Actions

View File

@@ -9,6 +9,7 @@ termora.date-format=yyyy/MM/dd HH:mm:ss
termora.finder=訪達
termora.folder=資料夾
termora.explorer=檔案管理器
termora.quit-confirm=你要退出 {0} 嗎?
# update
termora.update.title=新版本
@@ -64,15 +65,20 @@ termora.find-everywhere.groups.opened-hosts=已開啟的主機
termora.find-everywhere.groups.tools=工具
termora.find-everywhere.groups.settings=${termora.setting}
termora.find-everywhere.quick-command.local-terminal=本地端
termora.find-everywhere.double-shift-deprecated=雙擊 Shift 快捷鍵將會在未來的版本中移除
termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywhere.double-shift-deprecated},請使用 {0} 代替
termora.settings.terminal=終端
termora.settings.terminal.font=字體
termora.settings.terminal.size=大小
termora.settings.terminal.max-rows=最大行數
termora.settings.terminal.debug=偵錯模式
termora.settings.terminal.beep=蜂鳴聲
termora.settings.terminal.select-copy=選取複製
termora.settings.terminal.cursor-style=遊標風格
termora.settings.terminal.local-shell=本地端
termora.settings.terminal.auto-close-tab=自動關閉標籤
termora.settings.terminal.auto-close-tab-description=當終端正常斷開連線時自動關閉標籤頁
termora.settings.sync=同步
termora.settings.sync.push=推送
@@ -89,6 +95,7 @@ termora.settings.sync.import.successful=導入資料成功
termora.settings.sync.gist=片段
termora.settings.sync.token=令牌
termora.settings.sync.type=類型
termora.settings.sync.webdav.help=WebDAV 的儲存位址例如https://yourhost/webdav/termora.json
termora.settings.about=關於
termora.settings.about.author=作者
@@ -164,6 +171,10 @@ termora.keymgr.table.type=型別
termora.keymgr.table.length=長度
termora.keymgr.table.remark=備註
termora.keymgr.ssh-copy-id.number=主機數量 [{0}] 公鑰數量 [{1}]
termora.keymgr.ssh-copy-id.failed=複製失敗
termora.keymgr.ssh-copy-id.end=複製公鑰結束
# Tools
termora.tools.multiple=將指令傳送到所有會話
@@ -265,6 +276,8 @@ termora.toolbar.customize-toolbar=自訂工具列...
termora.terminal.size=大小: {0} x {1}
termora.terminal.copied=已複製
termora.terminal.channel-disconnected=終端機連線中斷,
termora.terminal.channel-reconnect=按 {0} 進行重新連線。
# Actions
termora.actions.copy-from-terminal=從終端複製

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 7.13397C14.1667 7.51888 14.1667 8.48113 13.5 8.86603L4.5 14.0622C3.83333 14.4471 3 13.966 3 13.1962L3 2.80385C3 2.03405 3.83333 1.55292 4.5 1.93782L13.5 7.13397Z" stroke="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 7.13397C14.1667 7.51888 14.1667 8.48113 13.5 8.86603L4.5 14.0622C3.83333 14.4471 3 13.966 3 13.1962L3 2.80385C3 2.03405 3.83333 1.55292 4.5 1.93782L13.5 7.13397Z" stroke="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -1,6 +1,6 @@
FROM linuxserver/openssh-server
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk update && apk add wget gcc g++ git make zsh htop && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \
&& apk update && apk add wget gcc g++ git make zsh htop inetutils-telnet && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \
&& tar -xf lrzsz-0.12.20.tar.gz && cd lrzsz-0.12.20 && ./configure && make && make install \
&& ln -s /usr/local/bin/lrz /usr/local/bin/rz && ln -s /usr/local/bin/lsz /usr/local/bin/sz