chore!: migrate to version 2.x

This commit is contained in:
hstyi
2025-06-13 15:16:56 +08:00
committed by GitHub
parent ca484618c7
commit 6177bbdc68
444 changed files with 18594 additions and 3832 deletions

View File

@@ -0,0 +1,746 @@
package app.termora.plugins.migration
import app.termora.*
import app.termora.Application.ohMyJson
import app.termora.highlight.KeywordHighlight
import app.termora.keymap.Keymap
import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.snippet.Snippet
import app.termora.terminal.CursorStyle
import jetbrains.exodus.bindings.StringBinding
import jetbrains.exodus.env.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.io.File
import java.util.*
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
import kotlin.time.Duration.Companion.minutes
class Database private constructor(private val env: Environment) : Disposable {
companion object {
private const val KEYMAP_STORE = "Keymap"
private const val HOST_STORE = "Host"
private const val SNIPPET_STORE = "Snippet"
private const val KEYWORD_HIGHLIGHT_STORE = "KeywordHighlight"
private const val MACRO_STORE = "Macro"
private const val KEY_PAIR_STORE = "KeyPair"
private const val DELETED_DATA_STORE = "DeletedData"
private val log = LoggerFactory.getLogger(Database::class.java)
private fun open(dir: File): Database {
val config = EnvironmentConfig()
// 32MB
config.setLogFileSize(1024 * 32)
config.setGcEnabled(true)
// 5m
config.setGcStartIn(5.minutes.inWholeMilliseconds.toInt())
val environment = Environments.newInstance(dir, config)
return Database(environment)
}
fun getDatabase(): Database {
return ApplicationScope.forApplicationScope()
.getOrCreate(Database::class) { open(MigrationApplicationRunnerExtension.instance.getDatabaseFile()) }
}
}
val properties by lazy { Properties() }
val safetyProperties by lazy { SafetyProperties("Setting.SafetyProperties") }
val terminal by lazy { Terminal() }
val appearance by lazy { Appearance() }
val sftp by lazy { SFTP() }
val sync by lazy { Sync() }
private val doorman get() = Doorman.getInstance()
fun getKeymaps(): Collection<Keymap> {
val array = env.computeInTransaction { tx ->
openCursor<String>(tx, KEYMAP_STORE) { _, value ->
value
}.values
}
val keymaps = mutableListOf<Keymap>()
for (text in array.iterator()) {
keymaps.add(Keymap.fromJSON(text) ?: continue)
}
return keymaps
}
fun addKeymap(keymap: Keymap) {
env.executeInTransaction {
put(it, KEYMAP_STORE, keymap.name, keymap.toJSON())
if (log.isDebugEnabled) {
log.debug("Added Keymap: ${keymap.name}")
}
}
}
fun removeKeymap(name: String) {
env.executeInTransaction {
delete(it, KEYMAP_STORE, name)
if (log.isDebugEnabled) {
log.debug("Removed Keymap: $name")
}
}
}
fun getHosts(): Collection<Host> {
val isWorking = doorman.isWorking()
return env.computeInTransaction { tx ->
openCursor<Host>(tx, HOST_STORE) { _, value ->
if (isWorking)
ohMyJson.decodeFromString(doorman.decrypt(value))
else
ohMyJson.decodeFromString(value)
}.values
}
}
fun removeAllKeyPair() {
env.executeInTransaction { tx ->
val store = env.openStore(KEY_PAIR_STORE, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
store.openCursor(tx).use {
while (it.next) {
it.deleteCurrent()
}
}
}
}
fun getKeyPairs(): Collection<OhKeyPair> {
val isWorking = doorman.isWorking()
return env.computeInTransaction { tx ->
openCursor<OhKeyPair>(tx, KEY_PAIR_STORE) { _, value ->
if (isWorking)
ohMyJson.decodeFromString(doorman.decrypt(value))
else
ohMyJson.decodeFromString(value)
}.values
}
}
fun addHost(host: Host) {
var text = ohMyJson.encodeToString(host)
if (doorman.isWorking()) {
text = doorman.encrypt(text)
}
env.executeInTransaction {
put(it, HOST_STORE, host.id, text)
if (log.isDebugEnabled) {
log.debug("Added Host: ${host.id} , ${host.name}")
}
}
}
fun removeHost(id: String) {
env.executeInTransaction {
delete(it, HOST_STORE, id)
if (log.isDebugEnabled) {
log.debug("Removed host: $id")
}
}
}
fun addDeletedData(deletedData: DeletedData) {
val text = ohMyJson.encodeToString(deletedData)
env.executeInTransaction {
put(it, DELETED_DATA_STORE, deletedData.id, text)
if (log.isDebugEnabled) {
log.debug("Added DeletedData: ${deletedData.id} , $text")
}
}
}
fun getDeletedData(): Collection<DeletedData> {
return env.computeInTransaction { tx ->
openCursor<DeletedData?>(tx, DELETED_DATA_STORE) { _, value ->
try {
ohMyJson.decodeFromString(value)
} catch (e: Exception) {
null
}
}.values.filterNotNull()
}
}
fun addSnippet(snippet: Snippet) {
var text = ohMyJson.encodeToString(snippet)
if (doorman.isWorking()) {
text = doorman.encrypt(text)
}
env.executeInTransaction {
put(it, SNIPPET_STORE, snippet.id, text)
if (log.isDebugEnabled) {
log.debug("Added Snippet: ${snippet.id} , ${snippet.name}")
}
}
}
fun removeSnippet(id: String) {
env.executeInTransaction {
delete(it, SNIPPET_STORE, id)
if (log.isDebugEnabled) {
log.debug("Removed snippet: $id")
}
}
}
fun getSnippets(): Collection<Snippet> {
val isWorking = doorman.isWorking()
return env.computeInTransaction { tx ->
openCursor<Snippet>(tx, SNIPPET_STORE) { _, value ->
if (isWorking)
ohMyJson.decodeFromString(doorman.decrypt(value))
else
ohMyJson.decodeFromString(value)
}.values
}
}
fun getKeywordHighlights(): Collection<KeywordHighlight> {
return env.computeInTransaction { tx ->
openCursor<KeywordHighlight>(tx, KEYWORD_HIGHLIGHT_STORE) { _, value ->
ohMyJson.decodeFromString(value)
}.values
}
}
fun addKeywordHighlight(keywordHighlight: KeywordHighlight) {
val text = ohMyJson.encodeToString(keywordHighlight)
env.executeInTransaction {
put(it, KEYWORD_HIGHLIGHT_STORE, keywordHighlight.id, text)
if (log.isDebugEnabled) {
log.debug("Added keyword highlight: ${keywordHighlight.id} , ${keywordHighlight.keyword}")
}
}
}
fun removeKeywordHighlight(id: String) {
env.executeInTransaction {
delete(it, KEYWORD_HIGHLIGHT_STORE, id)
if (log.isDebugEnabled) {
log.debug("Removed keyword highlight: $id")
}
}
}
fun getMacros(): Collection<Macro> {
return env.computeInTransaction { tx ->
openCursor<Macro>(tx, MACRO_STORE) { _, value ->
ohMyJson.decodeFromString(value)
}.values
}
}
fun addMacro(macro: Macro) {
val text = ohMyJson.encodeToString(macro)
env.executeInTransaction {
put(it, MACRO_STORE, macro.id, text)
if (log.isDebugEnabled) {
log.debug("Added macro: ${macro.id}")
}
}
}
fun removeMacro(id: String) {
env.executeInTransaction {
delete(it, MACRO_STORE, id)
if (log.isDebugEnabled) {
log.debug("Removed macro: $id")
}
}
}
fun addKeyPair(key: OhKeyPair) {
var text = ohMyJson.encodeToString(key)
if (doorman.isWorking()) {
text = doorman.encrypt(text)
}
env.executeInTransaction {
put(it, KEY_PAIR_STORE, key.id, text)
if (log.isDebugEnabled) {
log.debug("Added Key Pair: ${key.id} , ${key.name}")
}
}
}
fun removeKeyPair(id: String) {
env.executeInTransaction {
delete(it, KEY_PAIR_STORE, id)
if (log.isDebugEnabled) {
log.debug("Removed Key Pair: $id")
}
}
}
private fun put(tx: Transaction, name: String, key: String, value: String) {
val store = env.openStore(name, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
val k = StringBinding.stringToEntry(key)
val v = StringBinding.stringToEntry(value)
store.put(tx, k, v)
}
private fun delete(tx: Transaction, name: String, key: String) {
val store = env.openStore(name, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
val k = StringBinding.stringToEntry(key)
store.delete(tx, k)
}
fun getSafetyProperties(): List<SafetyProperties> {
return listOf(sync, safetyProperties)
}
private inline fun <reified T> openCursor(
tx: Transaction,
name: String,
callback: (key: String, value: String) -> T
): Map<String, T> {
val store = env.openStore(name, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
val map = mutableMapOf<String, T>()
store.openCursor(tx).use {
while (it.next) {
try {
val key = StringBinding.entryToString(it.key)
map[key] = callback.invoke(
key,
StringBinding.entryToString(it.value)
)
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode data failed. data: {}", it.value, e)
}
}
}
}
return map
}
private fun putString(name: String, map: Map<String, String>) {
return env.computeInTransaction {
val store = env.openStore(name, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, it)
for ((key, value) in map.entries) {
store.put(it, StringBinding.stringToEntry(key), StringBinding.stringToEntry(value))
}
}
}
private fun getString(name: String, key: String): String? {
return env.computeInTransaction {
val store = env.openStore(name, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, it)
val value = store.get(it, StringBinding.stringToEntry(key))
if (value == null) null else StringBinding.entryToString(value)
}
}
abstract inner class Property(val name: String) {
private val properties = Collections.synchronizedMap(mutableMapOf<String, String>())
init {
swingCoroutineScope.launch(Dispatchers.IO) { properties.putAll(getProperties()) }
}
protected open fun getString(key: String): String? {
if (properties.containsKey(key)) {
return properties[key]
}
return getString(name, key)
}
open fun getProperties(): Map<String, String> {
return env.computeInTransaction { tx ->
openCursor<String>(
tx,
name
) { _, value -> value }
}
}
protected open fun putString(key: String, value: String) {
properties[key] = value
putString(name, mapOf(key to value))
}
protected abstract inner class PropertyLazyDelegate<T>(protected val initializer: () -> T) :
ReadWriteProperty<Any?, T> {
private var value: T? = null
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
if (value == null) {
val v = getString(property.name)
value = if (v == null) {
initializer.invoke()
} else {
convertValue(v)
}
}
if (value == null) {
value = initializer.invoke()
}
return value!!
}
abstract fun convertValue(value: String): T
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
this.value = value
putString(property.name, value.toString())
}
}
protected abstract inner class PropertyDelegate<T>(private val defaultValue: T) :
PropertyLazyDelegate<T>({ defaultValue })
protected inner class StringPropertyDelegate(defaultValue: String) :
PropertyDelegate<String>(defaultValue) {
override fun convertValue(value: String): String {
return value
}
}
protected inner class IntPropertyDelegate(defaultValue: Int) :
PropertyDelegate<Int>(defaultValue) {
override fun convertValue(value: String): Int {
return value.toIntOrNull() ?: initializer.invoke()
}
}
protected inner class DoublePropertyDelegate(defaultValue: Double) :
PropertyDelegate<Double>(defaultValue) {
override fun convertValue(value: String): Double {
return value.toDoubleOrNull() ?: initializer.invoke()
}
}
protected inner class LongPropertyDelegate(defaultValue: Long) :
PropertyDelegate<Long>(defaultValue) {
override fun convertValue(value: String): Long {
return value.toLongOrNull() ?: initializer.invoke()
}
}
protected inner class BooleanPropertyDelegate(defaultValue: Boolean) :
PropertyDelegate<Boolean>(defaultValue) {
override fun convertValue(value: String): Boolean {
return value.toBooleanStrictOrNull() ?: initializer.invoke()
}
}
protected open inner class StringPropertyLazyDelegate(initializer: () -> String) :
PropertyLazyDelegate<String>(initializer) {
override fun convertValue(value: String): String {
return value
}
}
protected inner class CursorStylePropertyDelegate(defaultValue: CursorStyle) :
PropertyDelegate<CursorStyle>(defaultValue) {
override fun convertValue(value: String): CursorStyle {
return try {
CursorStyle.valueOf(value)
} catch (_: Exception) {
initializer.invoke()
}
}
}
protected inner class SyncTypePropertyDelegate(defaultValue: SyncType) :
PropertyDelegate<SyncType>(defaultValue) {
override fun convertValue(value: String): SyncType {
try {
return SyncType.valueOf(value)
} catch (e: Exception) {
return initializer.invoke()
}
}
}
}
/**
* 终端设置
*/
inner class Terminal : Property("Setting.Terminal") {
/**
* 字体
*/
var font by StringPropertyDelegate("JetBrains Mono")
/**
* 默认终端
*/
var localShell by StringPropertyLazyDelegate { Application.getDefaultShell() }
/**
* 字体大小
*/
var fontSize by IntPropertyDelegate(14)
/**
* 最大行数
*/
var maxRows by IntPropertyDelegate(5000)
/**
* 调试模式
*/
var debug by BooleanPropertyDelegate(false)
/**
* 蜂鸣声
*/
var beep by BooleanPropertyDelegate(true)
/**
* 超链接
*/
var hyperlink by BooleanPropertyDelegate(true)
/**
* 光标闪烁
*/
var cursorBlink by BooleanPropertyDelegate(false)
/**
* 选中复制
*/
var selectCopy by BooleanPropertyDelegate(false)
/**
* 光标样式
*/
var cursor by CursorStylePropertyDelegate(CursorStyle.Block)
/**
* 终端断开连接时自动关闭Tab
*/
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
/**
* 是否显示悬浮工具栏
*/
var floatingToolbar by BooleanPropertyDelegate(true)
}
/**
* 通用属性
*/
inner class Properties : Property("Setting.Properties") {
public override fun getString(key: String): String? {
return super.getString(key)
}
fun getString(key: String, defaultValue: String): String {
return getString(key) ?: defaultValue
}
public override fun putString(key: String, value: String) {
super.putString(key, value)
}
}
/**
* 安全的通用属性
*/
open inner class SafetyProperties(name: String) : Property(name) {
private val doorman get() = Doorman.getInstance()
public override fun getString(key: String): String? {
var value = super.getString(key)
if (value != null && doorman.isWorking()) {
try {
value = doorman.decrypt(value)
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("decryption key: [{}], value: [{}] failed: {}", key, value, e.message)
}
}
}
return value
}
override fun getProperties(): Map<String, String> {
val properties = super.getProperties()
val map = mutableMapOf<String, String>()
if (doorman.isWorking()) {
for ((k, v) in properties) {
try {
map[k] = doorman.decrypt(v)
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("decryption key: [{}], value: [{}] failed: {}", k, v, e.message)
}
}
}
} else {
map.putAll(properties)
}
return map
}
fun getString(key: String, defaultValue: String): String {
return getString(key) ?: defaultValue
}
public override fun putString(key: String, value: String) {
val v = if (doorman.isWorking()) doorman.encrypt(value) else value
super.putString(key, v)
}
}
/**
* 外观
*/
inner class Appearance : Property("Setting.Appearance") {
/**
* 外观
*/
var theme by StringPropertyDelegate("Light")
/**
* 跟随系统
*/
var followSystem by BooleanPropertyDelegate(true)
var darkTheme by StringPropertyDelegate("Dark")
var lightTheme by StringPropertyDelegate("Light")
/**
* 允许后台运行,也就是托盘
*/
var backgroundRunning by BooleanPropertyDelegate(false)
/**
* 标签关闭前确认
*/
var confirmTabClose by BooleanPropertyDelegate(false)
/**
* 背景图片的地址
*/
var backgroundImage by StringPropertyDelegate(StringUtils.EMPTY)
/**
* 语言
*/
var language by StringPropertyLazyDelegate {
I18n.containsLanguage(Locale.getDefault()) ?: Locale.US.toString()
}
/**
* 透明度
*/
var opacity by DoublePropertyDelegate(1.0)
}
/**
* SFTP
*/
inner class SFTP : Property("Setting.SFTP") {
/**
* 编辑命令
*/
var editCommand by StringPropertyDelegate(StringUtils.EMPTY)
/**
* sftp command
*/
var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY)
/**
* defaultDirectory
*/
var defaultDirectory by StringPropertyDelegate(StringUtils.EMPTY)
/**
* 是否固定在标签栏
*/
var pinTab by BooleanPropertyDelegate(false)
/**
* 是否保留原始文件时间
*/
var preserveModificationTime by BooleanPropertyDelegate(false)
}
/**
* 同步配置
*/
inner class Sync : SafetyProperties("Setting.Sync") {
/**
* 同步类型
*/
var type by SyncTypePropertyDelegate(SyncType.GitHub)
/**
* 范围
*/
var rangeHosts by BooleanPropertyDelegate(true)
var rangeKeyPairs by BooleanPropertyDelegate(true)
var rangeSnippets by BooleanPropertyDelegate(true)
var rangeKeywordHighlights by BooleanPropertyDelegate(true)
var rangeMacros by BooleanPropertyDelegate(true)
var rangeKeymap by BooleanPropertyDelegate(true)
/**
* Token
*/
var token by StringPropertyDelegate(String())
/**
* Gist ID
*/
var gist by StringPropertyDelegate(String())
/**
* Domain
*/
var domain by StringPropertyDelegate(String())
/**
* 最后同步时间
*/
var lastSyncTime by LongPropertyDelegate(0L)
/**
* 同步策略,为空就是默认手动
*/
var policy by StringPropertyDelegate(StringUtils.EMPTY)
}
override fun dispose() {
IOUtils.closeQuietly(env)
}
}

View File

@@ -0,0 +1,314 @@
package app.termora.plugins.migration
import app.termora.*
import app.termora.AES.decodeBase64
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.terminal.ControlCharacters
import cash.z.ecc.android.bip39.Mnemonics
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.components.FlatButton
import com.formdev.flatlaf.extras.components.FlatLabel
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXHyperlink
import org.slf4j.LoggerFactory
import java.awt.Dimension
import java.awt.Window
import java.awt.datatransfer.DataFlavor
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import javax.imageio.ImageIO
import javax.swing.*
import kotlin.math.max
class DoormanDialog(owner: Window?) : DialogWrapper(owner) {
companion object {
private val log = LoggerFactory.getLogger(DoormanDialog::class.java)
}
private val formMargin = "7dlu"
private val label = FlatLabel()
private val icon = JLabel()
private val passwordTextField = OutlinePasswordField()
private val tip = FlatLabel()
private val safeBtn = FlatButton()
var isOpened = false
init {
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
isModal = true
isResizable = false
controlsVisible = false
if (SystemInfo.isWindows || SystemInfo.isLinux) {
title = I18n.getString("termora.doorman.safe")
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE, false)
}
if (SystemInfo.isWindows || SystemInfo.isLinux) {
val sizes = listOf(16, 20, 24, 28, 32, 48, 64)
val loader = TermoraFrame::class.java.classLoader
val images = sizes.mapNotNull { e ->
loader.getResourceAsStream("icons/termora_${e}x${e}.png")?.use { ImageIO.read(it) }
}
iconImages = images
}
setLocationRelativeTo(null)
init()
}
override fun createCenterPanel(): JComponent {
label.text = I18n.getString("termora.doorman.safe")
tip.text = I18n.getString("termora.doorman.unlock-data")
icon.icon = FlatSVGIcon(Icons.role.name, 80, 80)
safeBtn.icon = Icons.unlocked
label.labelType = FlatLabel.LabelType.h2
label.horizontalAlignment = SwingConstants.CENTER
safeBtn.isFocusable = false
tip.foreground = UIManager.getColor("TextField.placeholderForeground")
icon.horizontalAlignment = SwingConstants.CENTER
safeBtn.addActionListener { doOKAction() }
passwordTextField.addActionListener { doOKAction() }
var rows = 2
val step = 2
return FormBuilder.create().debug(false)
.layout(
FormLayout(
"$formMargin, default:grow, 4dlu, pref, $formMargin",
"${"0dlu"}, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin"
)
)
.add(icon).xyw(2, rows, 4).apply { rows += step }
.add(label).xyw(2, rows, 4).apply { rows += step }
.add(passwordTextField).xy(2, rows)
.add(safeBtn).xy(4, rows).apply { rows += step }
.add(tip).xyw(2, rows, 4, "center, fill").apply { rows += step }
.add(JXHyperlink(object : AnAction(I18n.getString("termora.doorman.forget-password")) {
override fun actionPerformed(evt: AnActionEvent) {
val option = OptionPane.showConfirmDialog(
this@DoormanDialog, I18n.getString("termora.doorman.forget-password-message"),
options = arrayOf(
I18n.getString("termora.doorman.have-a-mnemonic"),
I18n.getString("termora.doorman.dont-have-a-mnemonic"),
),
optionType = JOptionPane.YES_NO_OPTION,
messageType = JOptionPane.INFORMATION_MESSAGE,
initialValue = I18n.getString("termora.doorman.have-a-mnemonic")
)
if (option == JOptionPane.YES_OPTION) {
showMnemonicsDialog()
} else if (option == JOptionPane.NO_OPTION) {
OptionPane.showMessageDialog(
this@DoormanDialog,
I18n.getString("termora.doorman.delete-data"),
messageType = JOptionPane.WARNING_MESSAGE
)
Application.browse(MigrationApplicationRunnerExtension.instance.getDatabaseFile().toURI())
}
}
}).apply { isFocusable = false }).xyw(2, rows, 4, "center, fill")
.build()
}
private fun showMnemonicsDialog() {
val dialog = MnemonicsDialog(this@DoormanDialog)
dialog.isVisible = true
val entropy = dialog.entropy
if (entropy.isEmpty()) {
return
}
try {
val keyBackup = Database.getDatabase()
.properties.getString("doorman-key-backup")
?: throw IllegalStateException("doorman-key-backup is null")
val key = AES.ECB.decrypt(entropy, keyBackup.decodeBase64())
Doorman.getInstance().work(key)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
OptionPane.showMessageDialog(
this, I18n.getString("termora.doorman.mnemonic-data-corrupted"),
messageType = JOptionPane.ERROR_MESSAGE
)
passwordTextField.outline = "error"
passwordTextField.requestFocus()
return
}
isOpened = true
super.doOKAction()
}
override fun doOKAction() {
if (passwordTextField.password.isEmpty()) {
passwordTextField.outline = "error"
passwordTextField.requestFocus()
return
}
try {
Doorman.getInstance().work(passwordTextField.password)
} catch (e: Exception) {
if (e is PasswordWrongException) {
OptionPane.showMessageDialog(
this, I18n.getString("termora.doorman.password-wrong"),
messageType = JOptionPane.ERROR_MESSAGE
)
}
passwordTextField.outline = "error"
passwordTextField.requestFocus()
return
}
isOpened = true
super.doOKAction()
}
fun open(): Boolean {
isModal = true
isVisible = true
return isOpened
}
private class MnemonicsDialog(owner: Window) : DialogWrapper(owner) {
private val textFields = (1..12).map { PasteTextField(it) }
var entropy = byteArrayOf()
private set
init {
isModal = true
isResizable = true
controlsVisible = false
title = I18n.getString("termora.doorman.mnemonic.title")
init()
pack()
size = Dimension(max(size.width, UIManager.getInt("Dialog.width") - 250), size.height)
setLocationRelativeTo(null)
}
fun getWords(): List<String> {
val words = mutableListOf<String>()
for (e in textFields) {
if (e.text.isBlank()) {
return emptyList()
}
words.add(e.text)
}
return words
}
override fun createCenterPanel(): JComponent {
val formMargin = "4dlu"
val layout = FormLayout(
"default:grow, $formMargin, default:grow, $formMargin, default:grow, $formMargin, default:grow",
"pref, $formMargin, pref, $formMargin, pref"
)
val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin")
.layout(layout).debug(false)
val iterator = textFields.iterator()
for (i in 1..5 step 2) {
for (j in 1..7 step 2) {
builder.add(iterator.next()).xy(j, i)
}
}
return builder.build()
}
override fun doOKAction() {
for (textField in textFields) {
if (textField.text.isBlank()) {
textField.outline = "error"
textField.requestFocusInWindow()
return
}
}
try {
Mnemonics.MnemonicCode(getWords().joinToString(StringUtils.SPACE)).use {
it.validate()
entropy = it.toEntropy()
}
} catch (e: Exception) {
OptionPane.showMessageDialog(
this,
I18n.getString("termora.doorman.mnemonic.incorrect"),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
super.doOKAction()
}
override fun doCancelAction() {
entropy = byteArrayOf()
super.doCancelAction()
}
private inner class PasteTextField(private val index: Int) : OutlineTextField() {
init {
addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if (e.keyCode == KeyEvent.VK_BACK_SPACE) {
if (text.isEmpty() && index != 1) {
textFields[index - 2].requestFocusInWindow()
}
}
}
})
}
override fun paste() {
if (!toolkit.systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
return
}
val text = toolkit.systemClipboard.getData(DataFlavor.stringFlavor)?.toString() ?: return
if (text.isBlank()) {
return
}
val words = mutableListOf<String>()
if (text.count { it == ControlCharacters.SP } > text.count { it == ControlCharacters.LF }) {
words.addAll(text.split(StringUtils.SPACE))
} else {
words.addAll(text.split(ControlCharacters.LF))
}
val iterator = words.iterator()
for (i in index..textFields.size) {
if (iterator.hasNext()) {
textFields[i - 1].text = iterator.next()
textFields[i - 1].requestFocusInWindow()
} else {
break
}
}
}
}
}
override fun createSouthPanel(): JComponent? {
return null
}
}

View File

@@ -0,0 +1,198 @@
package app.termora.plugins.migration
import app.termora.*
import app.termora.account.AccountManager
import app.termora.account.AccountOwner
import app.termora.database.DatabaseManager
import app.termora.database.OwnerType
import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.KeymapManager
import app.termora.keymgr.KeyManager
import app.termora.macro.MacroManager
import app.termora.snippet.SnippetManager
import org.apache.commons.io.FileUtils
import org.slf4j.LoggerFactory
import java.io.File
import java.util.concurrent.CountDownLatch
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import kotlin.system.exitProcess
class MigrationApplicationRunnerExtension private constructor() : ApplicationRunnerExtension {
companion object {
private val log = LoggerFactory.getLogger(MigrationApplicationRunnerExtension::class.java)
val instance by lazy { MigrationApplicationRunnerExtension() }
}
override fun ready() {
val file = getDatabaseFile()
if (file.exists().not()) return
// 如果数据库文件存在,那么需要迁移文件
val countDownLatch = CountDownLatch(1)
SwingUtilities.invokeAndWait {
try {
// 打开数据
openDatabase()
// 尝试解锁
openDoor()
// 询问是否迁移
if (askMigrate()) {
// 迁移
migrate()
// 移动到旧的目录
moveOldDirectory()
// 重启
restart()
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
} finally {
countDownLatch.countDown()
}
}
countDownLatch.await()
}
private fun openDoor() {
if (Doorman.getInstance().isWorking()) {
if (DoormanDialog(null).open().not()) {
Disposer.dispose(TermoraFrameManager.getInstance())
}
}
}
private fun openDatabase() {
try {
// 初始化数据库
Database.getDatabase()
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
JOptionPane.showMessageDialog(
null, "Unable to open database",
I18n.getString("termora.title"), JOptionPane.ERROR_MESSAGE
)
exitProcess(1)
}
}
private fun migrate() {
val database = Database.getDatabase()
val accountManager = AccountManager.getInstance()
val databaseManager = DatabaseManager.getInstance()
val ownerId = accountManager.getAccountId()
val hostManager = HostManager.getInstance()
val snippetManager = SnippetManager.getInstance()
val macroManager = MacroManager.getInstance()
val keymapManager = KeymapManager.getInstance()
val keyManager = KeyManager.getInstance()
val highlightManager = KeywordHighlightManager.getInstance()
val accountOwner = AccountOwner(
id = accountManager.getAccountId(),
name = accountManager.getEmail(),
type = OwnerType.User
)
for (host in database.getHosts()) {
if (host.deleted) continue
hostManager.addHost(host.copy(ownerId = accountManager.getAccountId(), ownerType = OwnerType.User.name))
}
for (snippet in database.getSnippets()) {
if (snippet.deleted) continue
snippetManager.addSnippet(snippet)
}
for (macro in database.getMacros()) {
macroManager.addMacro(macro)
}
for (keymap in database.getKeymaps()) {
keymapManager.addKeymap(keymap)
}
for (keypair in database.getKeyPairs()) {
keyManager.addOhKeyPair(keypair, accountOwner)
}
for (e in database.getKeywordHighlights()) {
highlightManager.addKeywordHighlight(e, accountOwner)
}
val list = listOf(
database.sync,
database.properties,
database.terminal,
database.sftp,
database.appearance,
)
for (e in list) {
for (k in e.getProperties()) {
databaseManager.setSetting(e.name + "." + k.key, k.value)
}
}
for (e in database.safetyProperties.getProperties()) {
databaseManager.setSetting(database.properties.name + "." + e.key, e.value)
}
}
private fun askMigrate(): Boolean {
if (MigrationDialog(null).open()) {
return true
}
// 移动到旧的目录
moveOldDirectory()
// 重启
restart()
return false
}
private fun moveOldDirectory() {
// 关闭数据库
Disposer.dispose(Database.getDatabase())
val file = getDatabaseFile()
FileUtils.moveDirectory(
file,
FileUtils.getFile(file.parentFile, file.name + "-old-" + System.currentTimeMillis())
)
}
private fun restart() {
// 重启
TermoraRestarter.getInstance().scheduleRestart(null, ask = false)
// 退出程序
Disposer.dispose(TermoraFrameManager.getInstance())
}
fun getDatabaseFile(): File {
return FileUtils.getFile(Application.getBaseDataDir(), "storage")
}
}

View File

@@ -0,0 +1,112 @@
package app.termora.plugins.migration
import app.termora.*
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXEditorPane
import java.awt.Dimension
import java.awt.Window
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.imageio.ImageIO
import javax.swing.*
import javax.swing.event.HyperlinkEvent
class MigrationDialog(owner: Window?) : DialogWrapper(owner) {
private var isOpened = false
init {
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
isModal = true
isResizable = false
controlsVisible = false
escapeDispose = false
if (SystemInfo.isWindows || SystemInfo.isLinux) {
title = StringUtils.EMPTY
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE, false)
}
if (SystemInfo.isWindows || SystemInfo.isLinux) {
val sizes = listOf(16, 20, 24, 28, 32, 48, 64)
val loader = TermoraFrame::class.java.classLoader
val images = sizes.mapNotNull { e ->
loader.getResourceAsStream("icons/termora_${e}x${e}.png")?.use { ImageIO.read(it) }
}
iconImages = images
}
setLocationRelativeTo(null)
init()
}
override fun createCenterPanel(): JComponent {
var rows = 2
val step = 2
val formMargin = "7dlu"
val icon = JLabel()
icon.horizontalAlignment = SwingConstants.CENTER
icon.icon = FlatSVGIcon(Icons.newUI.name, 80, 80)
val editorPane = JXEditorPane()
editorPane.contentType = "text/html"
editorPane.text = MigrationI18n.getString("termora.plugins.migration.message")
editorPane.isEditable = false
editorPane.addHyperlinkListener {
if (it.eventType == HyperlinkEvent.EventType.ACTIVATED) {
Application.browse(it.url.toURI())
}
}
editorPane.background = DynamicColor("window")
val scrollPane = JScrollPane(editorPane)
scrollPane.border = BorderFactory.createEmptyBorder()
scrollPane.preferredSize = Dimension(Int.MAX_VALUE, 225)
addWindowListener(object : WindowAdapter() {
override fun windowOpened(e: WindowEvent) {
removeWindowListener(this)
SwingUtilities.invokeLater { scrollPane.verticalScrollBar.value = 0 }
}
})
return FormBuilder.create().debug(false)
.layout(
FormLayout(
"$formMargin, default:grow, 4dlu, pref, $formMargin",
"${"0dlu"}, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin"
)
)
.add(icon).xyw(2, rows, 4).apply { rows += step }
.add(scrollPane).xyw(2, rows, 4).apply { rows += step }
.build()
}
fun open(): Boolean {
isModal = true
isVisible = true
return isOpened
}
override fun doOKAction() {
isOpened = true
super.doOKAction()
}
override fun doCancelAction() {
isOpened = false
super.doCancelAction()
}
override fun createOkAction(): AbstractAction {
return OkAction(MigrationI18n.getString("termora.plugins.migration.migrate"))
}
}

View File

@@ -0,0 +1,13 @@
package app.termora.plugins.migration
import app.termora.NamedI18n
import org.slf4j.Logger
import org.slf4j.LoggerFactory
object MigrationI18n : NamedI18n("i18n/messages") {
private val log = LoggerFactory.getLogger(MigrationI18n::class.java)
override fun getLogger(): Logger {
return log
}
}

View File

@@ -0,0 +1,33 @@
package app.termora.plugins.migration
import app.termora.ApplicationRunnerExtension
import app.termora.DynamicIcon
import app.termora.I18n
import app.termora.Icons
import app.termora.plugin.Extension
import app.termora.plugin.ExtensionSupport
import app.termora.plugin.Plugin
class MigrationPlugin : Plugin {
private val support = ExtensionSupport()
init {
support.addExtension(ApplicationRunnerExtension::class.java) { MigrationApplicationRunnerExtension.instance }
}
override fun getAuthor(): String {
return "TermoraDev"
}
override fun getName(): String {
return "Migration"
}
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
return support.getExtensions(clazz)
}
}

View File

@@ -0,0 +1,37 @@
package app.termora.plugins.migration
import org.slf4j.LoggerFactory
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
import kotlin.time.measureTime
object PBKDF2 {
private const val ALGORITHM = "PBKDF2WithHmacSHA512"
private val log = LoggerFactory.getLogger(PBKDF2::class.java)
fun generateSecret(
password: CharArray,
salt: ByteArray,
iterationCount: Int = 150000,
keyLength: Int = 256
): ByteArray {
val bytes: ByteArray
val time = measureTime {
bytes = SecretKeyFactory.getInstance(ALGORITHM)
.generateSecret(PBEKeySpec(password, salt, iterationCount, keyLength))
.encoded
}
if (log.isDebugEnabled) {
log.debug("Secret generated $time")
}
return bytes
}
fun hash(slat: ByteArray, password: CharArray, iterationCount: Int, keyLength: Int): ByteArray {
val spec = PBEKeySpec(password, slat, iterationCount, keyLength)
val secretKeyFactory = SecretKeyFactory.getInstance(ALGORITHM)
return secretKeyFactory.generateSecret(spec).encoded
}
}

View File

@@ -0,0 +1,91 @@
package app.termora.plugins.migration
import app.termora.*
import app.termora.AES.decodeBase64
import app.termora.AES.encodeBase64String
class PasswordWrongException : RuntimeException()
class Doorman private constructor() : Disposable {
private val properties get() = Database.getDatabase().properties
private var key = byteArrayOf()
companion object {
fun getInstance(): Doorman {
return ApplicationScope.forApplicationScope().getOrCreate(Doorman::class) { Doorman() }
}
}
fun isWorking(): Boolean {
return properties.getString("doorman", "false").toBoolean()
}
fun encrypt(text: String): String {
checkIsWorking()
return AES.ECB.encrypt(key, text.toByteArray()).encodeBase64String()
}
fun decrypt(text: String): String {
checkIsWorking()
return AES.ECB.decrypt(key, text.decodeBase64()).decodeToString()
}
/**
* @return 返回钥匙
*/
fun work(password: CharArray): ByteArray {
if (key.isNotEmpty()) {
throw IllegalStateException("Working")
}
return work(convertKey(password))
}
fun work(key: ByteArray): ByteArray {
val verify = properties.getString("doorman-verify")
if (verify == null) {
properties.putString(
"doorman-verify",
AES.ECB.encrypt(key, factor()).encodeBase64String()
)
} else {
try {
if (!AES.ECB.decrypt(key, verify.decodeBase64()).contentEquals(factor())) {
throw PasswordWrongException()
}
} catch (e: Exception) {
throw PasswordWrongException()
}
}
this.key = key
properties.putString("doorman", "true")
return this.key
}
private fun convertKey(password: CharArray): ByteArray {
return PBKDF2.generateSecret(password, factor())
}
private fun checkIsWorking() {
if (key.isEmpty() || !isWorking()) {
throw UnsupportedOperationException("Doorman is not working")
}
}
private fun factor(): ByteArray {
return Application.getName().toByteArray()
}
fun test(password: CharArray): Boolean {
checkIsWorking()
return key.contentEquals(convertKey(password))
}
override fun dispose() {
key = byteArrayOf()
}
}

View File

@@ -0,0 +1,7 @@
package app.termora.plugins.migration
enum class SyncType {
GitLab,
GitHub,
Gitee,
WebDAV,
}

View File

@@ -0,0 +1,24 @@
<termora-plugin>
<id>migration</id>
<name>Migration</name>
<version>${projectVersion}</version>
<!-- since: >=xxx , or >xxx -->
<!-- until: <=xxx , or <xxx -->
<termora-version since=">=${rootProjectVersion}" until=""/>
<entry>app.termora.plugins.migration.MigrationPlugin</entry>
<descriptions>
<description>Migrate version 1.x configuration files to 2.x</description>
<description language="zh_CN">将 1.x 版本的配置文件迁移到 2.x</description>
<description language="zh_TW">將 1.x 版本的設定檔移轉到 2.x</description>
</descriptions>
<vendor url="https://github.com/TermoraDev">TermoraDev</vendor>
</termora-plugin>

View File

@@ -0,0 +1,19 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_659_75852)">
<path d="M7.49998 0.523674C8.50002 5.49999 10.5 7.49999 15.554 8.5C10.5 9.49999 8.5 11.5 7.50005 16.4763C6.50002 11.5 4.50002 9.49999 -0.553986 8.49998C4.5 7.49999 6.5 5.49999 7.49998 0.523674Z" fill="url(#paint0_linear_659_75852)"/>
<path d="M12.9933 4.90705C14.0451 4.90705 14.8979 4.05433 14.8979 3.00245C14.8979 1.95056 14.0451 1.09784 12.9933 1.09784C11.9414 1.09784 11.0886 1.95056 11.0886 3.00245C11.0886 4.05433 11.9414 4.90705 12.9933 4.90705Z" fill="url(#paint1_linear_659_75852)"/>
</g>
<defs>
<linearGradient id="paint0_linear_659_75852" x1="7.50002" y1="0.523674" x2="7.50002" y2="16.4763" gradientUnits="userSpaceOnUse">
<stop stop-color="#3573F0"/>
<stop offset="1" stop-color="#EA33EC"/>
</linearGradient>
<linearGradient id="paint1_linear_659_75852" x1="7.50002" y1="0.523674" x2="7.50002" y2="16.4763" gradientUnits="userSpaceOnUse">
<stop stop-color="#3573F0"/>
<stop offset="1" stop-color="#EA33EC"/>
</linearGradient>
<clipPath id="clip0_659_75852">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,9 @@
termora.plugins.migration.message=<html> \
<h1 align="center">2.0 is ready.</h1> \
<br/> \
<h3>1. The storage structure has been updated. Existing data needs to be migrated. Just click <font color="#3573F0">“Migrate”</font> to complete the process.</h3> \
<h3>2. The <font color="#3573F0">Sync feature</font> is now provided as a plugin. If needed, please <font color="#EA33EC">manually install</font> it from Settings.</h3> \
<h3>3. The <font color="#3573F0">Data Encryption</font> feature has been <font color="#EA33EC">removed</font> (local data will now be stored with basic encryption). Please ensure your device is in a trusted environment.</h3> \
<h3 align="center">📎 For more information, please see: <a href="https://github.com/TermoraDev/termora/issues/645">TermoraDev/termora/issues/645</a></h3> \
</html>
termora.plugins.migration.migrate=Migrate

View File

@@ -0,0 +1,9 @@
termora.plugins.migration.message=<html> \
<h1 align="center">2.0 已就绪。</h1> \
<br/> \
<h3>1. 存储结构已更新,需迁移现有数据。只需点击 <font color="#3573F0">“迁移”</font> 即可完成操作。</h3> \
<h3>2. <font color="#3573F0">同步功能</font> 现作为插件提供,如需使用,请前往设置中 <font color="#EA33EC">手动安装</font>。</h3> \
<h3>3. <font color="#3573F0">数据加密</font> 功能已被 <font color="#EA33EC">移除</font>(本地数据将以简单加密方式存储),请确保你的设备处于可信环境中。</h3> \
<h3 align="center">📎 更多信息请查看:<a href="https://github.com/TermoraDev/termora/issues/645">TermoraDev/termora/issues/645</a></h3> \
</html>
termora.plugins.migration.migrate=迁移

View File

@@ -0,0 +1,9 @@
termora.plugins.migration.message=<html> \
<h1 align="center">2.0 已準備就緒。</h1> \
<br/> \
<h3>1. 儲存結構已更新,需要遷移現有資料。只需點擊 <font color="#3573F0">「遷移」</font> 即可完成操作。</h3> \
<h3>2. <font color="#3573F0">同步功能</font> 現以外掛形式提供,如需使用,請至設定中 <font color="#EA33EC">手動安裝</font>。</h3> \
<h3>3. <font color="#3573F0">資料加密</font> 功能已被 <font color="#EA33EC">移除</font>(本機資料將以簡易加密方式儲存),請確保你的裝置處於可信環境中。</h3> \
<h3 align="center">📎 更多資訊請參見:<a href="https://github.com/TermoraDev/termora/issues/645">TermoraDev/termora/issues/645</a></h3> \
</html>
termora.plugins.migration.migrate=遷移