mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 10:22:58 +08:00
Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9caf2578 | ||
|
|
b85bdf840e | ||
|
|
a2d7f3b5bb | ||
|
|
02a96d73c8 | ||
|
|
9fb12c7a71 | ||
|
|
145d8fc802 | ||
|
|
72c9dba806 | ||
|
|
de20bd654c | ||
|
|
35b3a10746 | ||
|
|
05fe6a0eb1 | ||
|
|
0552917c26 | ||
|
|
51c355c113 | ||
|
|
034ee3791d | ||
|
|
adabaf8f2d | ||
|
|
1f392c52a1 | ||
|
|
28fe4c725f | ||
|
|
18fe92cb11 | ||
|
|
c49acf7b51 | ||
|
|
7df317a1b9 | ||
|
|
219e5420f5 | ||
|
|
aefb7c3014 | ||
|
|
f0c7f06ff5 | ||
|
|
604e07b43a | ||
|
|
0000e4610a | ||
|
|
510324d7c4 | ||
|
|
33a359fcbf | ||
|
|
0b84d3271c | ||
|
|
57547c95cb | ||
|
|
503cfa9a4e | ||
|
|
af1f979e31 | ||
|
|
3cd9f92ea9 | ||
|
|
b332bada95 | ||
|
|
63a12c2ec8 | ||
|
|
743f242805 | ||
|
|
5bead0b27d | ||
|
|
73e3c7016b | ||
|
|
3829dcd0f9 | ||
|
|
b2047044fe | ||
|
|
47d1a13189 | ||
|
|
309909cbd7 | ||
|
|
b5cebb4cea | ||
|
|
b6dd2693cd | ||
|
|
5fdfe98f26 | ||
|
|
0c768aa1ca | ||
|
|
d493e6dc9e | ||
|
|
7e0c7d8891 | ||
|
|
3510c6600d | ||
|
|
32d91150bd | ||
|
|
bbf2d50e3f | ||
|
|
39725f9828 | ||
|
|
1e8c617a85 | ||
|
|
7f8573ec4c | ||
|
|
d8e629917e | ||
|
|
bdc0a15439 | ||
|
|
a25b97614f | ||
|
|
4e12c32566 | ||
|
|
ea9c0f1225 | ||
|
|
ff865f13a2 | ||
|
|
9875200912 | ||
|
|
9f218d004e | ||
|
|
ab727f66f4 | ||
|
|
efbc0302e4 | ||
|
|
ab2367d670 | ||
|
|
045e4f81d6 | ||
|
|
160cfee947 | ||
|
|
0e40b5ecce | ||
|
|
fcaddcee80 | ||
|
|
8d6295fd3b | ||
|
|
d0d51b3e6f | ||
|
|
b8d612f1d5 | ||
|
|
f7c49cde0c | ||
|
|
189f8fb3ba | ||
|
|
2a64bd28a8 | ||
|
|
8a733379a3 | ||
|
|
e5f854dfcd | ||
|
|
4e690bafed | ||
|
|
28b511e179 | ||
|
|
f010a13abd | ||
|
|
4d80ffafdd | ||
|
|
9aecd4d54b | ||
|
|
65091823eb | ||
|
|
d17218bfbd | ||
|
|
724c5d2632 | ||
|
|
6806c26028 | ||
|
|
dcd89174c9 | ||
|
|
9a8707b8cb | ||
|
|
28f1d05f06 | ||
|
|
54b044584e | ||
|
|
ed39449a20 | ||
|
|
2ff3f3a352 | ||
|
|
91e2e964a5 | ||
|
|
ca6cc68fed | ||
|
|
0962de7735 | ||
|
|
062b957fdb | ||
|
|
4efe4e5663 | ||
|
|
25eb6966c4 | ||
|
|
7843460020 | ||
|
|
1cbc6ba4a9 | ||
|
|
a43407bee8 | ||
|
|
05c4ec9af2 | ||
|
|
9236064293 | ||
|
|
e1955a371e | ||
|
|
58b56c4221 | ||
|
|
1e461e529f | ||
|
|
38ada1207c | ||
|
|
8bd1b34f46 | ||
|
|
4a513360e6 | ||
|
|
22da5c1c37 | ||
|
|
483582a8d1 | ||
|
|
f037cbfac0 | ||
|
|
343d11482d | ||
|
|
7ef81a0116 | ||
|
|
5df62d5d3e | ||
|
|
7db650d69f | ||
|
|
8d80d38d63 | ||
|
|
48f05d4cff | ||
|
|
9a1cf387c0 | ||
|
|
8b7efefbdb | ||
|
|
75f21db325 | ||
|
|
b094c9d4ff | ||
|
|
0da3c95759 | ||
|
|
fa79473ece | ||
|
|
86ccb5e0cc | ||
|
|
f385f4b277 | ||
|
|
3d0ef2a331 | ||
|
|
96999205a8 | ||
|
|
ee7f3871eb | ||
|
|
df2e9b0743 | ||
|
|
7964950149 | ||
|
|
e2d77fe881 | ||
|
|
f5783c8587 | ||
|
|
346044b1ba | ||
|
|
aa6ec8dd43 |
47
.github/workflows/linux-aarch64.yml
vendored
Normal file
47
.github/workflows/linux-aarch64.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Linux aarch64
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# download jdk
|
||||
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-aarch64-b825.69.tar.gz
|
||||
|
||||
# appimagetool
|
||||
- run: sudo apt install libfuse2
|
||||
|
||||
# install jdk
|
||||
- name: Installing Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'jdkfile'
|
||||
jdkFile: ${{ runner.temp }}/java_package.tar.gz
|
||||
java-version: '21.0.6'
|
||||
architecture: aarch64
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
# dist
|
||||
- run: |
|
||||
./gradlew dist --no-daemon
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termora-linux-aarch64
|
||||
path: |
|
||||
build/distributions/*.tar.gz
|
||||
build/distributions/*.AppImage
|
||||
22
.github/workflows/linux-x86-64.yml
vendored
22
.github/workflows/linux-x86-64.yml
vendored
@@ -4,14 +4,17 @@ on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# download jdk
|
||||
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.5-linux-x64-b509.30.tar.gz
|
||||
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-x64-b825.69.tar.gz
|
||||
|
||||
# appimagetool
|
||||
- run: sudo apt install libfuse2
|
||||
|
||||
# install jdk
|
||||
- name: Installing Java
|
||||
@@ -19,9 +22,18 @@ jobs:
|
||||
with:
|
||||
distribution: 'jdkfile'
|
||||
jdkFile: ${{ runner.temp }}/java_package.tar.gz
|
||||
java-version: '21.0.5'
|
||||
java-version: '21.0.6'
|
||||
architecture: x64
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
# dist
|
||||
- run: |
|
||||
./gradlew dist --no-daemon
|
||||
@@ -30,4 +42,6 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termora-linux-x86-64
|
||||
path: build/distributions/*.tar.gz
|
||||
path: |
|
||||
build/distributions/*.tar.gz
|
||||
build/distributions/*.AppImage
|
||||
|
||||
57
.github/workflows/osx-aarch64.yml
vendored
57
.github/workflows/osx-aarch64.yml
vendored
@@ -10,9 +10,41 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install the Apple certificate
|
||||
if: github.event_name == 'push' && github.repository == 'TermoraDev/termora'
|
||||
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
|
||||
|
||||
- name: Setup the Notary Information
|
||||
if: github.ref_type == 'tag' && github.repository == 'TermoraDev/termora'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
STORE_CREDENTIALS: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
|
||||
run: |
|
||||
xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD"
|
||||
|
||||
# download jdk
|
||||
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.5-osx-aarch64-b509.30.tar.gz
|
||||
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b825.69.tar.gz
|
||||
|
||||
# install jdk
|
||||
- name: Installing Java
|
||||
@@ -20,16 +52,33 @@ jobs:
|
||||
with:
|
||||
distribution: 'jdkfile'
|
||||
jdkFile: ${{ runner.temp }}/java_package.tar.gz
|
||||
java-version: '21.0.5'
|
||||
java-version: '21.0.6'
|
||||
architecture: aarch64
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
# dist
|
||||
- run: |
|
||||
- name: Dist
|
||||
env:
|
||||
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' && github.repository == 'TermoraDev/termora' }}
|
||||
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
|
||||
# 只有发布版本时才需要公证
|
||||
TERMORA_MAC_NOTARY: ${{ github.ref_type == 'tag' && github.repository == 'TermoraDev/termora' }}
|
||||
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
|
||||
run: |
|
||||
./gradlew dist --no-daemon
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termora-osx-aarch64
|
||||
path: build/distributions/*.dmg
|
||||
path: |
|
||||
build/distributions/*.zip
|
||||
build/distributions/*.dmg
|
||||
|
||||
60
.github/workflows/osx-x86-64.yml
vendored
60
.github/workflows/osx-x86-64.yml
vendored
@@ -10,8 +10,41 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install the Apple certificate
|
||||
if: github.event_name == 'push' && github.repository == 'TermoraDev/termora'
|
||||
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
|
||||
|
||||
- name: Setup the Notary Information
|
||||
if: github.ref_type == 'tag' && github.repository == 'TermoraDev/termora'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
STORE_CREDENTIALS: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
|
||||
run: |
|
||||
xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD"
|
||||
|
||||
# download jdk
|
||||
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.5-osx-x64-b509.30.tar.gz
|
||||
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b825.69.tar.gz
|
||||
|
||||
# install jdk
|
||||
- name: Installing Java
|
||||
@@ -19,16 +52,35 @@ jobs:
|
||||
with:
|
||||
distribution: 'jdkfile'
|
||||
jdkFile: ${{ runner.temp }}/java_package.tar.gz
|
||||
java-version: '21.0.5'
|
||||
java-version: '21.0.6'
|
||||
architecture: x64
|
||||
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
|
||||
# dist
|
||||
- run: |
|
||||
- name: Dist
|
||||
env:
|
||||
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' && github.repository == 'TermoraDev/termora' }}
|
||||
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
|
||||
# 只有发布版本时才需要公证
|
||||
TERMORA_MAC_NOTARY: ${{ github.ref_type == 'tag' && github.repository == 'TermoraDev/termora' }}
|
||||
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
|
||||
run: |
|
||||
./gradlew dist --no-daemon
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termora-osx-x86-64
|
||||
path: build/distributions/*.dmg
|
||||
path: |
|
||||
build/distributions/*.zip
|
||||
build/distributions/*.dmg
|
||||
|
||||
28
.github/workflows/windows-x86-64.yml
vendored
28
.github/workflows/windows-x86-64.yml
vendored
@@ -10,15 +10,34 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install zip
|
||||
run: |
|
||||
$system32 = [System.Environment]::GetEnvironmentVariable("WINDIR") + "\System32"
|
||||
Invoke-WebRequest -Uri "http://stahlworks.com/dev/zip.exe" -OutFile "$system32\zip.exe"
|
||||
Invoke-WebRequest -Uri "http://stahlworks.com/dev/unzip.exe" -OutFile "$system32\unzip.exe"
|
||||
|
||||
- name: Install 7z
|
||||
uses: milliewalky/setup-7-zip@v2
|
||||
|
||||
- name: Installing Java
|
||||
uses: actions/setup-java@v4
|
||||
run: |
|
||||
curl -s --output ${{ runner.temp }}\java_package.zip -L https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-windows-x64-b895.91.zip
|
||||
unzip -q ${{ runner.temp }}\java_package.zip -d ${{ runner.temp }}\jbr
|
||||
echo "JAVA_HOME=${{ runner.temp }}\jbr\jbrsdk-21.0.6-windows-x64-b895.91" >> $env:GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
distribution: 'jetbrains'
|
||||
java-version: '21'
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
# dist
|
||||
- run: |
|
||||
.\gradlew.bat dist --no-daemon
|
||||
.\gradlew.bat --stop
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -26,4 +45,5 @@ jobs:
|
||||
name: termora-windows-x86-64
|
||||
path: |
|
||||
build/distributions/*.zip
|
||||
build/distributions/*.msi
|
||||
build/distributions/*.msi
|
||||
build/distributions/*.exe
|
||||
|
||||
14
.github/workflows/winget.yml
vendored
Normal file
14
.github/workflows/winget.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
release:
|
||||
types: [ released ]
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@main
|
||||
if: github.repository == 'TermoraDev/termora'
|
||||
with:
|
||||
identifier: TermoraDev.Termora
|
||||
installers-regex: 'x86-64\.exe$' # Only x86-64.exe files
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
@@ -15,11 +15,13 @@
|
||||
## Features
|
||||
|
||||
- SSH and local terminal support
|
||||
- [SFTP](./docs/sftp.png?raw=1) file transfer support
|
||||
- Serial port protocol support
|
||||
- [SFTP](./docs/sftp.png?raw=1) & [Command](./docs/sftp-command.png?raw=1) file transfer support
|
||||
- Compatible with Windows, macOS, and Linux
|
||||
- Zmodem protocol support
|
||||
- SSH port forwarding
|
||||
- Configuration synchronization via [Gist](https://gist.github.com)
|
||||
- SSH port forwarding & Jump hosts
|
||||
- Terminal log
|
||||
- Configuration synchronization via [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
|
||||
- Macro support (record and replay scripts)
|
||||
- Keyword highlighting
|
||||
- Key management
|
||||
@@ -32,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
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
## 功能特性
|
||||
|
||||
- 支持 SSH 和本地终端
|
||||
- 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) 文件传输
|
||||
- 支持串口协议
|
||||
- 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) & [命令行](./docs/sftp-command.png?raw=1) 文件传输
|
||||
- 支持 Windows、macOS、Linux 平台
|
||||
- 支持 Zmodem 协议
|
||||
- 支持 SSH 端口转发
|
||||
- 支持配置同步到 [Gist](https://gist.github.com)
|
||||
- 支持 SSH 端口转发和跳板机
|
||||
- 终端日志记录
|
||||
- 支持配置同步到 [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
|
||||
- 支持宏(录制脚本并回放)
|
||||
- 支持关键词高亮
|
||||
- 支持密钥管理器
|
||||
@@ -28,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`
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
28
THIRDPARTY
28
THIRDPARTY
@@ -14,7 +14,7 @@ commonmark 0.24.0
|
||||
BSD 2-Clause "Simplified" License
|
||||
https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt
|
||||
|
||||
commons-codec 1.17.1
|
||||
commons-codec 1.18.0
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-codec/blob/master/LICENSE.txt
|
||||
|
||||
@@ -34,10 +34,18 @@ commons-net 3.11.1
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-net/blob/master/LICENSE.txt
|
||||
|
||||
commons-text 1.12.0
|
||||
commons-text 1.13.0
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-text/blob/master/LICENSE.txt
|
||||
|
||||
commons-csv 1.13.0
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-csv/blob/master/LICENSE.txt
|
||||
|
||||
ini4j 0.5.5-2
|
||||
Apache License 2.0
|
||||
http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
|
||||
eddsa 0.3.0
|
||||
Creative Commons Zero v1.0 Universal
|
||||
https://github.com/str4d/ed25519-java/blob/master/LICENSE.txt
|
||||
@@ -110,7 +118,7 @@ kotlin-logging 1.7.9
|
||||
Apache License 2.0
|
||||
https://github.com/oshai/kotlin-logging/blob/master/LICENSE
|
||||
|
||||
kotlin-stdlib 2.1.0
|
||||
kotlin-stdlib 2.1.10
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
|
||||
|
||||
@@ -126,6 +134,10 @@ kotlin-stdlib-jdk8 1.9.10
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
|
||||
|
||||
restart4j 0.0.1
|
||||
Apache License 2.0
|
||||
https://github.com/hstyi/restart4j/blob/main/LICENSE
|
||||
|
||||
kotlinx-coroutines-core-jvm 1.10.1
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
@@ -134,11 +146,11 @@ kotlinx-coroutines-swing 1.10.1
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
kotlinx-serialization-core-jvm 1.7.3
|
||||
kotlinx-serialization-core-jvm 1.8.0
|
||||
Apache License 2.0
|
||||
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
|
||||
|
||||
kotlinx-serialization-json-jvm 1.7.3
|
||||
kotlinx-serialization-json-jvm 1.8.0
|
||||
Apache License 2.0
|
||||
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
|
||||
|
||||
@@ -240,4 +252,8 @@ https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE
|
||||
|
||||
json-20231013
|
||||
Public Domain.
|
||||
https://github.com/stleary/JSON-java/blob/master/LICENSE
|
||||
https://github.com/stleary/JSON-java/blob/master/LICENSE
|
||||
|
||||
jSerialComm 2.11.0
|
||||
Apache License 2.0
|
||||
https://github.com/Fazecast/jSerialComm/blob/master/LICENSE-APACHE-2.0
|
||||
464
build.gradle.kts
464
build.gradle.kts
@@ -1,12 +1,18 @@
|
||||
import org.gradle.internal.jvm.Jvm
|
||||
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
|
||||
import org.gradle.nativeplatform.platform.internal.ArchitectureInternal
|
||||
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
|
||||
import org.jetbrains.kotlin.org.apache.commons.io.FileUtils
|
||||
import org.jetbrains.kotlin.org.apache.commons.io.filefilter.FileFilterUtils
|
||||
import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils
|
||||
import java.io.FileNotFoundException
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
|
||||
plugins {
|
||||
java
|
||||
idea
|
||||
application
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
@@ -14,10 +20,10 @@ plugins {
|
||||
|
||||
|
||||
group = "app.termora"
|
||||
version = "1.0.4"
|
||||
version = "1.0.9"
|
||||
|
||||
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
|
||||
val arch: Architecture = DefaultNativePlatform.getCurrentArchitecture()
|
||||
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
|
||||
|
||||
// macOS 签名信息
|
||||
val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY
|
||||
@@ -37,7 +43,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)
|
||||
@@ -57,6 +63,7 @@ dependencies {
|
||||
implementation(libs.commons.codec)
|
||||
implementation(libs.commons.io)
|
||||
implementation(libs.commons.lang3)
|
||||
implementation(libs.commons.csv)
|
||||
implementation(libs.commons.net)
|
||||
implementation(libs.commons.text)
|
||||
implementation(libs.commons.compress)
|
||||
@@ -96,7 +103,7 @@ dependencies {
|
||||
implementation(libs.sshd.core)
|
||||
implementation(libs.commonmark)
|
||||
implementation(libs.jgit)
|
||||
implementation(libs.jgit.sshd)
|
||||
implementation(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
|
||||
implementation(libs.jnafilechooser)
|
||||
implementation(libs.xodus.vfs)
|
||||
implementation(libs.xodus.openAPI)
|
||||
@@ -104,6 +111,9 @@ dependencies {
|
||||
implementation(libs.bip39)
|
||||
implementation(libs.colorpicker)
|
||||
implementation(libs.mixpanel)
|
||||
implementation(libs.jSerialComm)
|
||||
implementation(libs.ini4j)
|
||||
implementation(libs.restart4j)
|
||||
}
|
||||
|
||||
application {
|
||||
@@ -114,7 +124,6 @@ application {
|
||||
"-XX:+ZUncommit",
|
||||
"-XX:+ZGenerational",
|
||||
"-XX:ZUncommitDelay=60",
|
||||
"-XX:SoftMaxHeapSize=64m"
|
||||
)
|
||||
|
||||
if (os.isMacOsX) {
|
||||
@@ -140,14 +149,17 @@ tasks.test {
|
||||
tasks.register<Copy>("copy-dependencies") {
|
||||
val dir = layout.buildDirectory.dir("libs")
|
||||
from(configurations.runtimeClasspath).into(dir)
|
||||
val jna = libs.jna.asProvider().get()
|
||||
val pty4j = libs.pty4j.get()
|
||||
val jSerialComm = libs.jSerialComm.get()
|
||||
val restart4j = libs.restart4j.get()
|
||||
|
||||
// 对 JNA 和 PTY4J 的本地库提取
|
||||
// 提取出来是为了单独签名,不然无法通过公证
|
||||
if (os.isMacOsX && macOSSign) {
|
||||
doLast {
|
||||
val jna = libs.jna.asProvider().get()
|
||||
val archName = if (arch.isArm) "aarch64" else "x86_64"
|
||||
val dylib = dir.get().dir("dylib").asFile
|
||||
val pty4j = libs.pty4j.get()
|
||||
for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
|
||||
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
|
||||
val targetDir = File(dylib, jna.name)
|
||||
@@ -172,6 +184,38 @@ tasks.register<Copy>("copy-dependencies") {
|
||||
// @formatter:on
|
||||
// 删除所有二进制类库
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*") }
|
||||
} else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) {
|
||||
val targetDir = FileUtils.getFile(dylib, jSerialComm.name, "OSX", archName)
|
||||
FileUtils.forceMkdir(targetDir)
|
||||
// @formatter:off
|
||||
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "OSX/${archName}/*", "-d", targetDir.absolutePath) }
|
||||
// @formatter:on
|
||||
// 删除所有二进制类库
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Android/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "FreeBSD/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Linux/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "OpenBSD/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") }
|
||||
} else if ("${restart4j.name}-${restart4j.version}" == file.nameWithoutExtension) {
|
||||
val targetDir = FileUtils.getFile(dylib, restart4j.name)
|
||||
FileUtils.forceMkdir(targetDir)
|
||||
// @formatter:off
|
||||
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "darwin/${archName}/*", "-d", targetDir.absolutePath) }
|
||||
// @formatter:on
|
||||
// 删除所有二进制类库
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "win32/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "darwin/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "linux/*") }
|
||||
// 设置可执行权限
|
||||
for (e in FileUtils.listFiles(
|
||||
targetDir,
|
||||
FileFilterUtils.trueFileFilter(),
|
||||
FileFilterUtils.falseFileFilter()
|
||||
)) {
|
||||
e.setExecutable(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +228,73 @@ tasks.register<Copy>("copy-dependencies") {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (os.isLinux || os.isWindows) { // 缩减安装包
|
||||
doLast {
|
||||
for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
|
||||
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/darwin-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/sunos-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/openbsd-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/freebsd-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/dragonflybsd-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/aix-*") }
|
||||
if (os.isWindows) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/linux-*") }
|
||||
if (arch.isArm) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-x86*") }
|
||||
} else {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-aarch64/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-x86/*") }
|
||||
}
|
||||
} else if (os.isLinux) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-*") }
|
||||
}
|
||||
} else if ("${pty4j.name}-${pty4j.version}" == file.nameWithoutExtension) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*darwin*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*freebsd*") }
|
||||
if (os.isWindows) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*linux*") }
|
||||
if (arch.isArm) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/x86/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/x86-64*") }
|
||||
} else {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/x86/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/aarch64/*") }
|
||||
}
|
||||
} else if (os.isLinux) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win*") }
|
||||
}
|
||||
} else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Android/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "FreeBSD/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "OpenBSD/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") }
|
||||
if (os.isWindows) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Linux/*") }
|
||||
} else if (os.isLinux) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") }
|
||||
}
|
||||
} else if ("${restart4j.name}-${restart4j.version}" == file.nameWithoutExtension) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "darwin/*") }
|
||||
if (os.isWindows) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "linux/*") }
|
||||
if (arch.isArm) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "win32/x86_64/*") }
|
||||
} else {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "win32/aarch64/*") }
|
||||
}
|
||||
} else if (os.isLinux) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "win32/*") }
|
||||
if (arch.isArm) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "linux/x86_64/*") }
|
||||
} else {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "linux/aarch64/*") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,22 +336,24 @@ tasks.register<Exec>("jpackage") {
|
||||
"-XX:+ZUncommit",
|
||||
"-XX:+ZGenerational",
|
||||
"-XX:ZUncommitDelay=60",
|
||||
"-XX:SoftMaxHeapSize=64m",
|
||||
"-XX:+HeapDumpOnOutOfMemoryError",
|
||||
"-Dlogger.console.level=off",
|
||||
"-Dkotlinx.coroutines.debug=off",
|
||||
"-Dapp-version=${project.version}",
|
||||
)
|
||||
|
||||
options.add("-Dsun.java2d.metal=true")
|
||||
|
||||
if (os.isMacOsX) {
|
||||
options.add("-Dsun.java2d.metal=true")
|
||||
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}"))
|
||||
@@ -252,7 +365,17 @@ tasks.register<Exec>("jpackage") {
|
||||
arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE)))
|
||||
arguments.addAll(listOf("--vendor", "TermoraDev"))
|
||||
arguments.addAll(listOf("--copyright", "TermoraDev"))
|
||||
arguments.addAll(listOf("--description", "A terminal emulator and SSH client."))
|
||||
|
||||
if (os.isWindows) {
|
||||
arguments.addAll(
|
||||
listOf(
|
||||
"--description",
|
||||
"${project.name.uppercaseFirstChar()}: A terminal emulator and SSH client"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
arguments.addAll(listOf("--description", "A terminal emulator and SSH client."))
|
||||
}
|
||||
|
||||
|
||||
if (os.isMacOsX) {
|
||||
@@ -270,6 +393,10 @@ tasks.register<Exec>("jpackage") {
|
||||
arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.ico"))
|
||||
}
|
||||
|
||||
if (os.isLinux) {
|
||||
arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.png"))
|
||||
}
|
||||
|
||||
|
||||
arguments.add("--type")
|
||||
if (os.isMacOsX) {
|
||||
@@ -300,11 +427,8 @@ tasks.register("dist") {
|
||||
throw GradleException("JVM: $vendor is not supported")
|
||||
}
|
||||
|
||||
val distributionDir = layout.buildDirectory.dir("distributions").get()
|
||||
val gradlew = File(projectDir, if (os.isWindows) "gradlew.bat" else "gradlew").absolutePath
|
||||
val osName = if (os.isMacOsX) "osx" else if (os.isWindows) "windows" else "linux"
|
||||
val finalFilenameWithoutExtension = "${project.name}-${project.version}-${osName}-${arch.name}"
|
||||
val macOSFinalFilePath = distributionDir.file("${finalFilenameWithoutExtension}.dmg").asFile.absolutePath
|
||||
|
||||
|
||||
// 清空目录
|
||||
exec { commandLine(gradlew, "clean") }
|
||||
@@ -324,67 +448,8 @@ tasks.register("dist") {
|
||||
// 打包
|
||||
exec { commandLine(gradlew, "jpackage") }
|
||||
|
||||
// pack
|
||||
if (os.isWindows) { // zip and msi
|
||||
// zip
|
||||
exec {
|
||||
commandLine(
|
||||
"tar", "-vacf",
|
||||
distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile.absolutePath,
|
||||
project.name.uppercaseFirstChar()
|
||||
)
|
||||
workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile
|
||||
}
|
||||
|
||||
// msi
|
||||
exec {
|
||||
commandLine(
|
||||
"cmd", "/c", "move",
|
||||
"${project.name.uppercaseFirstChar()}-${project.version}.msi",
|
||||
"${finalFilenameWithoutExtension}.msi"
|
||||
)
|
||||
workingDir = distributionDir.asFile
|
||||
}
|
||||
} else if (os.isLinux) { // tar.gz
|
||||
exec {
|
||||
commandLine(
|
||||
"tar", "-czvf",
|
||||
distributionDir.file("${finalFilenameWithoutExtension}.tar.gz").asFile.absolutePath,
|
||||
project.name.uppercaseFirstChar()
|
||||
)
|
||||
workingDir = distributionDir.asFile
|
||||
}
|
||||
} else if (os.isMacOsX) { // rename
|
||||
exec {
|
||||
commandLine(
|
||||
"mv",
|
||||
distributionDir.file("${project.name.uppercaseFirstChar()}-${project.version}.dmg").asFile.absolutePath,
|
||||
macOSFinalFilePath,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw GradleException("${os.name} is not supported")
|
||||
}
|
||||
|
||||
|
||||
// sign dmg
|
||||
if (os.isMacOsX && macOSSign) {
|
||||
|
||||
// sign
|
||||
signMacOSLocalFile(File(macOSFinalFilePath))
|
||||
|
||||
// notary
|
||||
if (macOSNotary) {
|
||||
exec {
|
||||
commandLine(
|
||||
"/usr/bin/xcrun", "notarytool",
|
||||
"submit", macOSFinalFilePath,
|
||||
"--keychain-profile", macOSNotaryKeychainProfile,
|
||||
"--wait",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 根据不同的系统构建不同的二进制包
|
||||
pack()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +472,210 @@ tasks.register("check-license") {
|
||||
thirdParty[nameWithVersion.replace(StringUtils.SPACE, "-")] = license
|
||||
thirdPartyNames.add(nameWithVersion.split(StringUtils.SPACE).first())
|
||||
}
|
||||
|
||||
for (file in configurations.runtimeClasspath.get()) {
|
||||
val name = file.nameWithoutExtension
|
||||
if (!thirdParty.containsKey(name)) {
|
||||
if (logger.isWarnEnabled) {
|
||||
logger.warn("$name does not exist in third-party")
|
||||
}
|
||||
if (!thirdPartyNames.contains(name)) {
|
||||
throw GradleException("$name No license found")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建包
|
||||
*/
|
||||
fun pack() {
|
||||
val osName = if (os.isMacOsX) "osx" else if (os.isWindows) "windows" else "linux"
|
||||
val distributionDir = layout.buildDirectory.dir("distributions").get()
|
||||
val finalFilenameWithoutExtension = "${project.name}-${project.version}-${osName}-${arch.name}"
|
||||
val projectName = project.name.uppercaseFirstChar()
|
||||
|
||||
if (os.isWindows) {
|
||||
packOnWindows(distributionDir, finalFilenameWithoutExtension, projectName)
|
||||
} else if (os.isLinux) {
|
||||
packOnLinux(distributionDir, finalFilenameWithoutExtension, projectName)
|
||||
} else if (os.isMacOsX) {
|
||||
packOnMac(distributionDir, finalFilenameWithoutExtension, projectName)
|
||||
} else {
|
||||
throw GradleException("${os.name} is not supported")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 zip、7z、msi
|
||||
*/
|
||||
fun packOnWindows(distributionDir: Directory, finalFilenameWithoutExtension: String, projectName: String) {
|
||||
// zip
|
||||
exec {
|
||||
commandLine(
|
||||
"tar", "-vacf",
|
||||
distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile.absolutePath,
|
||||
projectName
|
||||
)
|
||||
workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile
|
||||
}
|
||||
|
||||
// exe
|
||||
exec {
|
||||
commandLine(
|
||||
"iscc",
|
||||
"/DMyAppId=${projectName}",
|
||||
"/DMyAppName=${projectName}",
|
||||
"/DMyAppVersion=${project.version}",
|
||||
"/DMyOutputDir=${distributionDir.asFile.absolutePath}",
|
||||
"/DMySetupIconFile=${FileUtils.getFile(projectDir, "src", "main", "resources", "icons", "termora.ico")}",
|
||||
"/DMySourceDir=${layout.buildDirectory.dir("jpackage/images/win-msi.image/${projectName}").get().asFile}",
|
||||
"/F${finalFilenameWithoutExtension}",
|
||||
FileUtils.getFile(projectDir, "src", "main", "resources", "termora.iss")
|
||||
)
|
||||
}
|
||||
|
||||
// msi
|
||||
exec {
|
||||
commandLine(
|
||||
"cmd", "/c", "move",
|
||||
"${projectName}-${project.version}.msi",
|
||||
"${finalFilenameWithoutExtension}.msi"
|
||||
)
|
||||
workingDir = distributionDir.asFile
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对于 macOS 先对 jpackage 构建的 dmg 重命名 -> 签名 -> 公证,另外还会创建一个 zip 包
|
||||
*/
|
||||
fun packOnMac(distributionDir: Directory, finalFilenameWithoutExtension: String, projectName: String) {
|
||||
val dmgFile = distributionDir.file("${finalFilenameWithoutExtension}.dmg").asFile
|
||||
val zipFile = distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile
|
||||
|
||||
// rename
|
||||
// @formatter:off
|
||||
exec { commandLine("mv", distributionDir.file("${projectName}-${project.version}.dmg").asFile.absolutePath, dmgFile.absolutePath,) }
|
||||
// @formatter:on
|
||||
|
||||
// sign dmg
|
||||
if (macOSSign) signMacOSLocalFile(dmgFile)
|
||||
|
||||
// 找到 .app
|
||||
val imageFile = layout.buildDirectory.dir("jpackage/images/").get().asFile
|
||||
val appFile = imageFile.listFiles()?.firstOrNull()?.listFiles()?.firstOrNull()
|
||||
?: throw FileNotFoundException("${projectName}.app")
|
||||
|
||||
// zip
|
||||
// @formatter:off
|
||||
exec { commandLine("ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", appFile.absolutePath, zipFile.absolutePath) }
|
||||
// @formatter:on
|
||||
|
||||
// sign zip
|
||||
if (macOSSign) signMacOSLocalFile(zipFile)
|
||||
|
||||
// 公证
|
||||
if (macOSNotary) {
|
||||
val pool = Executors.newCachedThreadPool()
|
||||
val jobs = mutableListOf<Future<*>>()
|
||||
|
||||
// zip
|
||||
pool.submit {
|
||||
// 对 zip 公证
|
||||
notaryMacOSLocalFile(zipFile)
|
||||
// 对 .app 盖章
|
||||
stapleMacOSLocalFile(appFile)
|
||||
// 删除旧的 zip ,旧的 zip 仅仅是为了公证
|
||||
FileUtils.deleteQuietly(zipFile)
|
||||
// 再对盖完章的 app 打成 zip 包
|
||||
// @formatter:off
|
||||
exec { commandLine("ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", appFile.absolutePath, zipFile.absolutePath) }
|
||||
// @formatter:on
|
||||
// 再对 zip 签名
|
||||
signMacOSLocalFile(zipFile)
|
||||
}.apply { jobs.add(this) }
|
||||
|
||||
// dmg
|
||||
pool.submit {
|
||||
// 公证
|
||||
notaryMacOSLocalFile(dmgFile)
|
||||
// 盖章
|
||||
stapleMacOSLocalFile(dmgFile)
|
||||
}.apply { jobs.add(this) }
|
||||
|
||||
// join ...
|
||||
jobs.forEach { it.get() }
|
||||
|
||||
// shutdown
|
||||
pool.shutdown()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 tar.gz 和 AppImage
|
||||
*/
|
||||
fun packOnLinux(distributionDir: Directory, finalFilenameWithoutExtension: String, projectName: String) {
|
||||
// tar.gz
|
||||
exec {
|
||||
commandLine(
|
||||
"tar", "-czvf",
|
||||
distributionDir.file("${finalFilenameWithoutExtension}.tar.gz").asFile.absolutePath,
|
||||
projectName
|
||||
)
|
||||
workingDir = distributionDir.asFile
|
||||
}
|
||||
|
||||
|
||||
// AppImage
|
||||
// Download AppImageKit
|
||||
val appimagetool = FileUtils.getFile(projectDir, ".gradle", "appimagetool")
|
||||
if (!appimagetool.exists()) {
|
||||
exec {
|
||||
commandLine(
|
||||
"wget",
|
||||
"-O", appimagetool.absolutePath,
|
||||
"https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-${if (arch.isArm) "aarch64" else "x86_64"}.AppImage"
|
||||
)
|
||||
workingDir = distributionDir.asFile
|
||||
}
|
||||
|
||||
// AppImageKit chmod
|
||||
exec { commandLine("chmod", "+x", appimagetool.absolutePath) }
|
||||
}
|
||||
|
||||
|
||||
// Desktop file
|
||||
val termoraName = project.name.uppercaseFirstChar()
|
||||
val desktopFile = distributionDir.file(termoraName + File.separator + termoraName + ".desktop").asFile
|
||||
desktopFile.writeText(
|
||||
"""[Desktop Entry]
|
||||
Type=Application
|
||||
Name=${termoraName}
|
||||
Comment=Terminal emulator and SSH client
|
||||
Icon=/lib/${termoraName}
|
||||
Categories=Development;
|
||||
Terminal=false
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// AppRun file
|
||||
val appRun = File(desktopFile.parentFile, "AppRun")
|
||||
val sb = StringBuilder()
|
||||
sb.append("#!/bin/sh").appendLine()
|
||||
sb.append("SELF=$(readlink -f \"$0\")").appendLine()
|
||||
sb.append("HERE=\${SELF%/*}").appendLine()
|
||||
sb.append("export LinuxAppImage=true").appendLine()
|
||||
sb.append("exec \"\${HERE}/bin/${termoraName}\" \"$@\"")
|
||||
appRun.writeText(sb.toString())
|
||||
appRun.setExecutable(true)
|
||||
|
||||
// AppImage
|
||||
exec {
|
||||
commandLine(appimagetool.absolutePath, termoraName, "${finalFilenameWithoutExtension}.AppImage")
|
||||
workingDir = distributionDir.asFile
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +698,40 @@ fun signMacOSLocalFile(file: File) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS 对本地文件进行公证
|
||||
*/
|
||||
fun notaryMacOSLocalFile(file: File) {
|
||||
if (os.isMacOsX && macOSNotary) {
|
||||
if (file.exists()) {
|
||||
exec {
|
||||
commandLine(
|
||||
"/usr/bin/xcrun", "notarytool",
|
||||
"submit", file,
|
||||
"--keychain-profile", macOSNotaryKeychainProfile,
|
||||
"--wait",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 盖章
|
||||
*/
|
||||
fun stapleMacOSLocalFile(file: File) {
|
||||
if (os.isMacOsX && macOSNotary) {
|
||||
if (file.exists()) {
|
||||
exec {
|
||||
commandLine(
|
||||
"/usr/bin/xcrun",
|
||||
"stapler", "staple", file,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
@@ -436,4 +739,11 @@ kotlin {
|
||||
@Suppress("UnstableApiUsage")
|
||||
vendor = JvmVendorSpec.JETBRAINS
|
||||
}
|
||||
}
|
||||
|
||||
idea {
|
||||
module {
|
||||
isDownloadJavadoc = true
|
||||
isDownloadSources = true
|
||||
}
|
||||
}
|
||||
BIN
docs/sftp-command.png
Normal file
BIN
docs/sftp-command.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -1,16 +1,17 @@
|
||||
[versions]
|
||||
kotlin = "2.1.0"
|
||||
kotlin = "2.1.10"
|
||||
slf4j = "2.0.16"
|
||||
pty4j = "0.13.2"
|
||||
tinylog = "2.7.0"
|
||||
kotlinx-coroutines = "1.10.1"
|
||||
flatlaf = "3.5.4"
|
||||
trove4j = "1.0.20200330"
|
||||
kotlinx-serialization-json = "1.7.3"
|
||||
commons-codec = "1.17.1"
|
||||
kotlinx-serialization-json = "1.8.0"
|
||||
commons-codec = "1.18.0"
|
||||
commons-lang3 = "3.17.0"
|
||||
commons-csv = "1.13.0"
|
||||
commons-net = "3.11.1"
|
||||
commons-text = "1.12.0"
|
||||
commons-text = "1.13.0"
|
||||
commons-compress = "1.27.1"
|
||||
koin-bom = "4.0.0"
|
||||
swingx = "1.6.5-1"
|
||||
@@ -41,6 +42,9 @@ rhino = "1.7.15"
|
||||
delight-rhino-sandbox = "0.0.17"
|
||||
testcontainers = "1.20.4"
|
||||
mixpanel = "1.5.3"
|
||||
jSerialComm = "2.11.0"
|
||||
ini4j = "0.5.5-2"
|
||||
restart4j = "0.0.1"
|
||||
|
||||
[libraries]
|
||||
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
@@ -52,9 +56,11 @@ tinylog-impl = { group = "org.tinylog", name = "tinylog-impl", version.ref = "ti
|
||||
commons-codec = { group = "commons-codec", name = "commons-codec", version.ref = "commons-codec" }
|
||||
commons-net = { group = "commons-net", name = "commons-net", version.ref = "commons-net" }
|
||||
commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version.ref = "commons-lang3" }
|
||||
commons-csv = { group = "org.apache.commons", name = "commons-csv", version.ref = "commons-csv" }
|
||||
commons-text = { group = "org.apache.commons", name = "commons-text", version.ref = "commons-text" }
|
||||
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commons-compress" }
|
||||
pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" }
|
||||
ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" }
|
||||
flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" }
|
||||
flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" }
|
||||
trove4j = { group = "org.jetbrains.intellij.deps", name = "trove4j", version.ref = "trove4j" }
|
||||
@@ -71,6 +77,7 @@ versioncompare = { module = "io.github.g00fy2:versioncompare", version.ref = "ve
|
||||
jfa = { module = "de.jangassen:jfa", version.ref = "jfa" }
|
||||
oshi-core = { module = "com.github.oshi:oshi-core", version.ref = "oshi" }
|
||||
commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" }
|
||||
restart4j = { module = "com.github.hstyi:restart4j", version.ref = "restart4j" }
|
||||
jbr-api = { module = "com.jetbrains:jbr-api", version.ref = "jbr-api" }
|
||||
flatlaf-swingx = { module = "com.formdev:flatlaf-swingx", version.ref = "flatlaf" }
|
||||
leveldb = { module = "org.iq80.leveldb:leveldb", version.ref = "leveldb" }
|
||||
@@ -97,6 +104,7 @@ rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
|
||||
delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" }
|
||||
colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" }
|
||||
mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" }
|
||||
jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "jSerialComm" }
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
|
||||
@@ -15,6 +15,8 @@ import org.slf4j.LoggerFactory
|
||||
import java.awt.Desktop
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.Duration
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.pow
|
||||
@@ -60,6 +62,16 @@ object Application {
|
||||
return "/bin/bash"
|
||||
}
|
||||
|
||||
fun getTemporaryDir(): File {
|
||||
val temporaryDir = File(getBaseDataDir(), "temporary")
|
||||
FileUtils.forceMkdir(temporaryDir)
|
||||
return temporaryDir
|
||||
}
|
||||
|
||||
fun createSubTemporaryDir(prefix: String = getName()): Path {
|
||||
return Files.createTempDirectory(getTemporaryDir().toPath(), prefix)
|
||||
}
|
||||
|
||||
fun getBaseDataDir(): File {
|
||||
if (::baseDataDir.isInitialized) {
|
||||
return baseDataDir
|
||||
@@ -111,11 +123,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)
|
||||
|
||||
@@ -4,15 +4,14 @@ import app.termora.actions.ActionManager
|
||||
import app.termora.keymap.KeymapManager
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.FlatSystemProperties
|
||||
import com.formdev.flatlaf.extras.FlatDesktop
|
||||
import com.formdev.flatlaf.extras.FlatDesktop.QuitResponse
|
||||
import com.formdev.flatlaf.extras.FlatInspector
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jthemedetecor.OsThemeDetector
|
||||
import com.mixpanel.mixpanelapi.ClientDelivery
|
||||
import com.mixpanel.mixpanelapi.MessageBuilder
|
||||
import com.mixpanel.mixpanelapi.MixpanelAPI
|
||||
import com.sun.jna.platform.WindowUtils
|
||||
import com.sun.jna.platform.win32.User32
|
||||
import com.sun.jna.ptr.IntByReference
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -20,25 +19,29 @@ import kotlinx.coroutines.launch
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.LocaleUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.math.NumberUtils
|
||||
import org.json.JSONObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.tinylog.configuration.Configuration
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.FileLock
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.util.*
|
||||
import java.util.function.Consumer
|
||||
import javax.swing.*
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class ApplicationRunner {
|
||||
private lateinit var singletonChannel: FileChannel
|
||||
private lateinit var singletonLock: FileLock
|
||||
private val log by lazy {
|
||||
if (!::singletonLock.isInitialized) {
|
||||
throw UnsupportedOperationException("Singleton lock is not initialized")
|
||||
}
|
||||
LoggerFactory.getLogger("Main")
|
||||
LoggerFactory.getLogger(ApplicationRunner::class.java)
|
||||
}
|
||||
|
||||
fun run() {
|
||||
@@ -74,6 +77,9 @@ class ApplicationRunner {
|
||||
// 解密数据
|
||||
val openDoor = measureTimeMillis { openDoor() }
|
||||
|
||||
// clear temporary
|
||||
clearTemporary()
|
||||
|
||||
// 启动主窗口
|
||||
val startMainFrame = measureTimeMillis { startMainFrame() }
|
||||
|
||||
@@ -95,6 +101,14 @@ class ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
private fun clearTemporary() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
// 启动时清除
|
||||
FileUtils.cleanDirectory(Application.getTemporaryDir())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun openDoor() {
|
||||
if (Doorman.getInstance().isWorking()) {
|
||||
@@ -105,7 +119,37 @@ class ApplicationRunner {
|
||||
}
|
||||
|
||||
private fun startMainFrame() {
|
||||
|
||||
TermoraFrameManager.getInstance().createWindow().isVisible = true
|
||||
|
||||
if (SystemUtils.IS_OS_MAC_OSX) {
|
||||
SwingUtilities.invokeLater {
|
||||
FlatDesktop.setQuitHandler(object : Consumer<QuitResponse> {
|
||||
override fun accept(response: QuitResponse) {
|
||||
quitHandler(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun quitHandler(response: QuitResponse) {
|
||||
val keyboardFocusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
|
||||
|
||||
if (OptionPane.showConfirmDialog(
|
||||
keyboardFocusManager.focusedWindow,
|
||||
I18n.getString("termora.quit-confirm", Application.getName()),
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
) != JOptionPane.YES_OPTION
|
||||
) {
|
||||
response.cancelQuit()
|
||||
return
|
||||
}
|
||||
|
||||
for (frame in TermoraFrameManager.getInstance().getWindows()) {
|
||||
frame.dispose()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
@@ -143,7 +187,7 @@ class ApplicationRunner {
|
||||
themeManager.change(theme, true)
|
||||
|
||||
if (Application.isUnknownVersion())
|
||||
FlatInspector.install("ctrl shift alt X");
|
||||
FlatInspector.install("ctrl shift alt X")
|
||||
|
||||
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
|
||||
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)
|
||||
@@ -224,36 +268,14 @@ class ApplicationRunner {
|
||||
|
||||
|
||||
private fun checkSingleton() {
|
||||
val file = File(Application.getBaseDataDir(), "lock")
|
||||
val pidFile = File(Application.getBaseDataDir(), "pid")
|
||||
|
||||
|
||||
val raf = RandomAccessFile(file, "rw")
|
||||
val lock = raf.channel.tryLock()
|
||||
|
||||
if (lock != null) {
|
||||
pidFile.writeText(ProcessHandle.current().pid().toString())
|
||||
pidFile.deleteOnExit()
|
||||
file.deleteOnExit()
|
||||
} else {
|
||||
if (SystemInfo.isWindows && pidFile.exists()) {
|
||||
val pid = NumberUtils.toLong(pidFile.readText())
|
||||
for (window in WindowUtils.getAllWindows(false)) {
|
||||
if (pid > 0) {
|
||||
val processId = IntByReference()
|
||||
User32.INSTANCE.GetWindowThreadProcessId(window.hwnd, processId)
|
||||
if (processId.value.toLong() != pid) {
|
||||
continue
|
||||
}
|
||||
} else if (window.title != Application.getName() || window.filePath.endsWith("explorer.exe")) {
|
||||
continue
|
||||
}
|
||||
User32.INSTANCE.ShowWindow(window.hwnd, User32.SW_SHOWNOACTIVATE)
|
||||
User32.INSTANCE.SetForegroundWindow(window.hwnd)
|
||||
break
|
||||
}
|
||||
}
|
||||
singletonChannel = FileChannel.open(
|
||||
Paths.get(Application.getBaseDataDir().absolutePath, "lock"),
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.WRITE,
|
||||
)
|
||||
|
||||
val lock = singletonChannel.tryLock()
|
||||
if (lock == null) {
|
||||
System.err.println("Program is already running")
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
@@ -22,10 +22,6 @@ class ChannelShellPtyConnector(
|
||||
output.flush()
|
||||
}
|
||||
|
||||
override fun write(buffer: String) {
|
||||
write(buffer.toByteArray(charset))
|
||||
}
|
||||
|
||||
override fun resize(rows: Int, cols: Int) {
|
||||
channel.sendWindowChange(cols, rows)
|
||||
}
|
||||
@@ -38,4 +34,8 @@ class ChannelShellPtyConnector(
|
||||
override fun close() {
|
||||
channel.close(true)
|
||||
}
|
||||
|
||||
override fun getCharset(): Charset {
|
||||
return charset
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,10 @@ import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
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.collections.component1
|
||||
import kotlin.collections.component2
|
||||
import kotlin.collections.set
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
@@ -55,6 +53,7 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
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()
|
||||
@@ -401,10 +400,10 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
protected inner class CursorStylePropertyDelegate(defaultValue: CursorStyle) :
|
||||
PropertyDelegate<CursorStyle>(defaultValue) {
|
||||
override fun convertValue(value: String): CursorStyle {
|
||||
try {
|
||||
return CursorStyle.valueOf(value)
|
||||
} catch (e: Exception) {
|
||||
return initializer.invoke()
|
||||
return try {
|
||||
CursorStyle.valueOf(value)
|
||||
} catch (_: Exception) {
|
||||
initializer.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,6 +453,16 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
*/
|
||||
var debug by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 蜂鸣声
|
||||
*/
|
||||
var beep by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* 光标闪烁
|
||||
*/
|
||||
var cursorBlink by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 选中复制
|
||||
*/
|
||||
@@ -463,6 +472,16 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
* 光标样式
|
||||
*/
|
||||
var cursor by CursorStylePropertyDelegate(CursorStyle.Block)
|
||||
|
||||
/**
|
||||
* 终端断开连接时自动关闭Tab
|
||||
*/
|
||||
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 是否显示悬浮工具栏
|
||||
*/
|
||||
var floatingToolbar by BooleanPropertyDelegate(true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,6 +582,31 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* SFTP
|
||||
*/
|
||||
inner class SFTP : Property("Setting.SFTP") {
|
||||
|
||||
|
||||
/**
|
||||
* 编辑命令
|
||||
*/
|
||||
var editCommand by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
|
||||
/**
|
||||
* sftp command
|
||||
*/
|
||||
var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
|
||||
/**
|
||||
* 是否固定在标签栏
|
||||
*/
|
||||
var pinTab by BooleanPropertyDelegate(false)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步配置
|
||||
*/
|
||||
|
||||
@@ -54,7 +54,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
protected fun init() {
|
||||
|
||||
|
||||
defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||
defaultCloseOperation = DISPOSE_ON_CLOSE
|
||||
|
||||
initTitleBar()
|
||||
initEvents()
|
||||
@@ -158,12 +158,14 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
openPopup = true
|
||||
}
|
||||
|
||||
val window = SwingUtilities.windowForComponent(c)
|
||||
val windows = window.ownedWindows
|
||||
for (w in windows) {
|
||||
if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) {
|
||||
openPopup = true
|
||||
w.dispose()
|
||||
val window = c as? Window ?: SwingUtilities.windowForComponent(c)
|
||||
if (window != null) {
|
||||
val windows = window.ownedWindows
|
||||
for (w in windows) {
|
||||
if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) {
|
||||
openPopup = true
|
||||
w.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,16 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
|
||||
}
|
||||
|
||||
jumpHostsOption.filter = { it.id != host.id }
|
||||
|
||||
val serialComm = host.options.serialComm
|
||||
if (serialComm.port.isNotBlank()) {
|
||||
serialCommOption.serialPortComboBox.selectedItem = serialComm.port
|
||||
}
|
||||
serialCommOption.baudRateComboBox.selectedItem = serialComm.baudRate
|
||||
serialCommOption.dataBitsComboBox.selectedItem = serialComm.dataBits
|
||||
serialCommOption.parityComboBox.selectedItem = serialComm.parity
|
||||
serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits
|
||||
serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl
|
||||
}
|
||||
|
||||
override fun getHost(): Host {
|
||||
|
||||
156
src/main/kotlin/app/termora/FilterableHostTreeModel.kt
Normal file
156
src/main/kotlin/app/termora/FilterableHostTreeModel.kt
Normal file
@@ -0,0 +1,156 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import java.util.function.Function
|
||||
import javax.swing.JTree
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.event.TreeModelEvent
|
||||
import javax.swing.event.TreeModelListener
|
||||
import javax.swing.tree.DefaultMutableTreeNode
|
||||
import javax.swing.tree.TreeModel
|
||||
import javax.swing.tree.TreeNode
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
class FilterableHostTreeModel(
|
||||
private val tree: JTree,
|
||||
/**
|
||||
* 如果返回 true 则空文件夹也展示
|
||||
*/
|
||||
private val showEmptyFolder: () -> Boolean = { true }
|
||||
) : TreeModel {
|
||||
private val model = tree.model
|
||||
private val root = ReferenceTreeNode(model.root)
|
||||
private var listeners = emptyArray<TreeModelListener>()
|
||||
private var filters = emptyArray<Function<HostTreeNode, Boolean>>()
|
||||
private val mapping = mutableMapOf<TreeNode, ReferenceTreeNode>()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param a 旧的
|
||||
* @param b 新的
|
||||
*/
|
||||
private fun cloneTree(a: HostTreeNode, b: DefaultMutableTreeNode) {
|
||||
b.removeAllChildren()
|
||||
for (c in a.children()) {
|
||||
if (c !is HostTreeNode) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (c.host.protocol != Protocol.Folder) {
|
||||
if (filters.isNotEmpty() && filters.none { it.apply(c) }) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
val n = ReferenceTreeNode(c).apply { mapping[c] = this }.apply { b.add(this) }
|
||||
|
||||
// 文件夹递归复制
|
||||
if (c.host.protocol == Protocol.Folder) {
|
||||
cloneTree(c, n)
|
||||
}
|
||||
|
||||
// 如果是文件夹
|
||||
if (c.host.protocol == Protocol.Folder) {
|
||||
if (n.childCount == 0) {
|
||||
if (showEmptyFolder.invoke()) {
|
||||
continue
|
||||
}
|
||||
n.removeFromParent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
model.addTreeModelListener(object : TreeModelListener {
|
||||
override fun treeNodesChanged(e: TreeModelEvent) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun treeNodesInserted(e: TreeModelEvent) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun treeNodesRemoved(e: TreeModelEvent) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun treeStructureChanged(e: TreeModelEvent) {
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun getRoot(): Any {
|
||||
return root.userObject
|
||||
}
|
||||
|
||||
override fun getChild(parent: Any, index: Int): Any {
|
||||
val c = map(parent)?.getChildAt(index)
|
||||
if (c is ReferenceTreeNode) {
|
||||
return c.userObject
|
||||
}
|
||||
throw IndexOutOfBoundsException("Index out of bounds")
|
||||
}
|
||||
|
||||
override fun getChildCount(parent: Any): Int {
|
||||
return map(parent)?.childCount ?: 0
|
||||
}
|
||||
|
||||
private fun map(parent: Any): ReferenceTreeNode? {
|
||||
if (parent is TreeNode) {
|
||||
return mapping[parent]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun isLeaf(node: Any?): Boolean {
|
||||
return (node as TreeNode).isLeaf
|
||||
}
|
||||
|
||||
override fun valueForPathChanged(path: TreePath, newValue: Any) {
|
||||
|
||||
}
|
||||
|
||||
override fun getIndexOfChild(parent: Any, child: Any): Int {
|
||||
val c = map(parent) ?: return -1
|
||||
for (i in 0 until c.childCount) {
|
||||
val e = c.getChildAt(i)
|
||||
if (e is ReferenceTreeNode && e.userObject == child) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
override fun addTreeModelListener(l: TreeModelListener) {
|
||||
listeners = ArrayUtils.addAll(listeners, l)
|
||||
}
|
||||
|
||||
override fun removeTreeModelListener(l: TreeModelListener) {
|
||||
listeners = ArrayUtils.removeElement(listeners, l)
|
||||
}
|
||||
|
||||
fun addFilter(f: Function<HostTreeNode, Boolean>) {
|
||||
filters = ArrayUtils.add(filters, f)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
mapping.clear()
|
||||
mapping[model.root as TreeNode] = root
|
||||
cloneTree(model.root as HostTreeNode, root)
|
||||
SwingUtilities.updateComponentTreeUI(tree)
|
||||
}
|
||||
|
||||
fun getModel(): TreeModel {
|
||||
return model
|
||||
}
|
||||
|
||||
private class ReferenceTreeNode(any: Any) : DefaultMutableTreeNode(any)
|
||||
|
||||
}
|
||||
@@ -5,6 +5,17 @@ import org.apache.commons.lang3.StringUtils
|
||||
import java.util.*
|
||||
|
||||
|
||||
fun Map<*, *>.toPropertiesString(): String {
|
||||
val env = StringBuilder()
|
||||
for ((i, e) in entries.withIndex()) {
|
||||
env.append(e.key).append('=').append(e.value)
|
||||
if (i != size - 1) {
|
||||
env.appendLine()
|
||||
}
|
||||
}
|
||||
return env.toString()
|
||||
}
|
||||
|
||||
fun UUID.toSimpleString(): String {
|
||||
return toString().replace("-", StringUtils.EMPTY)
|
||||
}
|
||||
@@ -13,6 +24,13 @@ enum class Protocol {
|
||||
Folder,
|
||||
SSH,
|
||||
Local,
|
||||
Serial,
|
||||
|
||||
/**
|
||||
* 交互式的 SFTP,此协议只在系统内部交互不应该暴露给用户也不应该持久化
|
||||
*/
|
||||
@Transient
|
||||
SFTPPty
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +57,53 @@ data class Authentication(
|
||||
}
|
||||
}
|
||||
|
||||
enum class SerialCommParity {
|
||||
None,
|
||||
Even,
|
||||
Odd,
|
||||
Mark,
|
||||
Space
|
||||
}
|
||||
|
||||
enum class SerialCommFlowControl {
|
||||
None,
|
||||
RTS_CTS,
|
||||
XON_XOFF,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SerialComm(
|
||||
/**
|
||||
* 串口
|
||||
*/
|
||||
val port: String = StringUtils.EMPTY,
|
||||
|
||||
/**
|
||||
* 波特率
|
||||
*/
|
||||
val baudRate: Int = 9600,
|
||||
|
||||
/**
|
||||
* 数据位:5、6、7、8
|
||||
*/
|
||||
val dataBits: Int = 8,
|
||||
|
||||
/**
|
||||
* 停止位: 1、1.5、2
|
||||
*/
|
||||
val stopBits: String = "1",
|
||||
|
||||
/**
|
||||
* 校验位
|
||||
*/
|
||||
val parity: SerialCommParity = SerialCommParity.None,
|
||||
|
||||
/**
|
||||
* 流控
|
||||
*/
|
||||
val flowControl: SerialCommFlowControl = SerialCommFlowControl.None,
|
||||
)
|
||||
|
||||
|
||||
@Serializable
|
||||
data class Options(
|
||||
@@ -61,7 +126,12 @@ data class Options(
|
||||
/**
|
||||
* SSH 心跳间隔
|
||||
*/
|
||||
val heartbeatInterval: Int = 30
|
||||
val heartbeatInterval: Int = 30,
|
||||
|
||||
/**
|
||||
* 串口配置
|
||||
*/
|
||||
val serialComm: SerialComm = SerialComm(),
|
||||
) {
|
||||
companion object {
|
||||
val Default = Options()
|
||||
@@ -190,7 +260,7 @@ data class Host(
|
||||
val tunnelings: List<Tunneling> = emptyList(),
|
||||
|
||||
/**
|
||||
* 排序
|
||||
* 排序,越小越靠前
|
||||
*/
|
||||
val sort: Long = 0,
|
||||
/**
|
||||
@@ -237,4 +307,8 @@ data class Host(
|
||||
result = 31 * result + ownerId.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return name
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package app.termora
|
||||
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.keyboardinteractive.TerminalUserInteraction
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
@@ -47,38 +50,70 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
|
||||
}
|
||||
|
||||
putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...")
|
||||
SwingUtilities.invokeLater {
|
||||
testConnection(pane.getHost())
|
||||
putValue(NAME, I18n.getString("termora.new-host.test-connection"))
|
||||
}
|
||||
isEnabled = false
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
testConnection(pane.getHost())
|
||||
withContext(Dispatchers.Swing) {
|
||||
putValue(NAME, I18n.getString("termora.new-host.test-connection"))
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun testConnection(host: Host) {
|
||||
if (host.protocol != Protocol.SSH) {
|
||||
OptionPane.showMessageDialog(this, I18n.getString("termora.new-host.test-connection-successful"))
|
||||
private suspend fun testConnection(host: Host) {
|
||||
val owner = this
|
||||
if (host.protocol == Protocol.Local) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(owner, I18n.getString("termora.new-host.test-connection-successful"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (host.protocol == Protocol.SSH) {
|
||||
testSSH(host)
|
||||
} else if (host.protocol == Protocol.Serial) {
|
||||
testSerial(host)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, ExceptionUtils.getMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.new-host.test-connection-successful")
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun testSSH(host: Host) {
|
||||
var client: SshClient? = null
|
||||
var session: ClientSession? = null
|
||||
try {
|
||||
client = SshClients.openClient(host)
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
session = SshClients.openSession(host, client)
|
||||
OptionPane.showMessageDialog(this, I18n.getString("termora.new-host.test-connection-successful"))
|
||||
} catch (e: Exception) {
|
||||
OptionPane.showMessageDialog(
|
||||
this, ExceptionUtils.getRootCauseMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
} finally {
|
||||
session?.close()
|
||||
client?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun testSerial(host: Host) {
|
||||
Serials.openPort(host).closePort()
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
package app.termora
|
||||
|
||||
import java.util.*
|
||||
|
||||
interface HostListener : EventListener {
|
||||
fun hostAdded(host: Host) {}
|
||||
fun hostRemoved(id: String) {}
|
||||
fun hostsChanged() {}
|
||||
}
|
||||
|
||||
|
||||
class HostManager private constructor() {
|
||||
companion object {
|
||||
@@ -17,39 +9,38 @@ class HostManager private constructor() {
|
||||
}
|
||||
|
||||
private val database get() = Database.getDatabase()
|
||||
private val listeners = mutableListOf<HostListener>()
|
||||
private var hosts = mutableMapOf<String, Host>()
|
||||
|
||||
fun addHost(host: Host, notify: Boolean = true) {
|
||||
/**
|
||||
* 修改缓存并存入数据库
|
||||
*/
|
||||
fun addHost(host: Host) {
|
||||
assertEventDispatchThread()
|
||||
database.addHost(host)
|
||||
if (notify) listeners.forEach { it.hostAdded(host) }
|
||||
}
|
||||
|
||||
fun removeHost(id: String) {
|
||||
assertEventDispatchThread()
|
||||
database.removeHost(id)
|
||||
listeners.forEach { it.hostRemoved(id) }
|
||||
|
||||
if (host.deleted) {
|
||||
hosts.entries.removeIf { it.value.id == host.id || it.value.parentId == host.id }
|
||||
} else {
|
||||
hosts[host.id] = host
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 第一次调用从数据库中获取,后续从缓存中获取
|
||||
*/
|
||||
fun hosts(): List<Host> {
|
||||
return database.getHosts()
|
||||
if (hosts.isEmpty()) {
|
||||
database.getHosts().filter { !it.deleted }
|
||||
.forEach { hosts[it.id] = it }
|
||||
}
|
||||
return hosts.values.filter { !it.deleted }
|
||||
.sortedWith(compareBy<Host> { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort })
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
assertEventDispatchThread()
|
||||
database.removeAllHost()
|
||||
listeners.forEach { it.hostsChanged() }
|
||||
/**
|
||||
* 从缓存中获取
|
||||
*/
|
||||
fun getHost(id: String): Host? {
|
||||
return hosts[id]
|
||||
}
|
||||
|
||||
fun addHostListener(listener: HostListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeHostListener(listener: HostListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -2,11 +2,17 @@ package app.termora
|
||||
|
||||
import app.termora.keymgr.KeyManager
|
||||
import app.termora.keymgr.KeyManagerDialog
|
||||
import com.fazecast.jSerialComm.SerialPort
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatComboBox
|
||||
import com.formdev.flatlaf.ui.FlatTextBorder
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.*
|
||||
import java.awt.event.*
|
||||
@@ -22,6 +28,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
protected val proxyOption = ProxyOption()
|
||||
protected val terminalOption = TerminalOption()
|
||||
protected val jumpHostsOption = JumpHostsOption()
|
||||
protected val serialCommOption = SerialCommOption()
|
||||
protected val owner: Window get() = SwingUtilities.getWindowAncestor(this)
|
||||
|
||||
init {
|
||||
@@ -30,6 +37,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
addOption(tunnelingOption)
|
||||
addOption(jumpHostsOption)
|
||||
addOption(terminalOption)
|
||||
addOption(serialCommOption)
|
||||
|
||||
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
|
||||
}
|
||||
@@ -43,6 +51,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
var authentication = Authentication.No
|
||||
var proxy = Proxy.No
|
||||
|
||||
|
||||
if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
|
||||
authentication = authentication.copy(
|
||||
type = AuthenticationType.Password,
|
||||
@@ -66,12 +75,23 @@ open class HostOptionsPane : OptionsPane() {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val serialComm = SerialComm(
|
||||
port = serialCommOption.serialPortComboBox.selectedItem?.toString() ?: StringUtils.EMPTY,
|
||||
baudRate = serialCommOption.baudRateComboBox.selectedItem?.toString()?.toIntOrNull() ?: 9600,
|
||||
dataBits = serialCommOption.dataBitsComboBox.selectedItem as Int? ?: 8,
|
||||
stopBits = serialCommOption.stopBitsComboBox.selectedItem as String? ?: "1",
|
||||
parity = serialCommOption.parityComboBox.selectedItem as SerialCommParity,
|
||||
flowControl = serialCommOption.flowControlComboBox.selectedItem as SerialCommFlowControl
|
||||
)
|
||||
|
||||
val options = Options.Default.copy(
|
||||
encoding = terminalOption.charsetComboBox.selectedItem as String,
|
||||
env = terminalOption.environmentTextArea.text,
|
||||
startupCommand = terminalOption.startupCommandTextField.text,
|
||||
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
|
||||
jumpHosts = jumpHostsOption.jumpHosts.map { it.id }
|
||||
jumpHosts = jumpHostsOption.jumpHosts.map { it.id },
|
||||
serialComm = serialComm
|
||||
)
|
||||
|
||||
return Host(
|
||||
@@ -103,6 +123,12 @@ open class HostOptionsPane : OptionsPane() {
|
||||
if (validateField(generalOption.usernameTextField)) {
|
||||
return false
|
||||
}
|
||||
} else if (host.protocol == Protocol.Serial) {
|
||||
if (validateField(serialCommOption.serialPortComboBox)
|
||||
|| validateField(serialCommOption.baudRateComboBox)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (host.authentication.type == AuthenticationType.Password) {
|
||||
@@ -152,7 +178,8 @@ open class HostOptionsPane : OptionsPane() {
|
||||
* 返回 true 表示有错误
|
||||
*/
|
||||
private fun validateField(comboBox: JComboBox<*>): Boolean {
|
||||
if (comboBox.isEnabled && comboBox.selectedItem == null) {
|
||||
val selectedItem = comboBox.selectedItem
|
||||
if (comboBox.isEnabled && (selectedItem == null || (selectedItem is String && selectedItem.isBlank()))) {
|
||||
selectOptionJComponent(comboBox)
|
||||
comboBox.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
|
||||
comboBox.requestFocusInWindow()
|
||||
@@ -259,6 +286,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
|
||||
protocolTypeComboBox.addItem(Protocol.SSH)
|
||||
protocolTypeComboBox.addItem(Protocol.Local)
|
||||
protocolTypeComboBox.addItem(Protocol.Serial)
|
||||
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.No)
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.Password)
|
||||
@@ -328,7 +356,9 @@ open class HostOptionsPane : OptionsPane() {
|
||||
passwordTextField.isEnabled = true
|
||||
chooseKeyBtn.isEnabled = true
|
||||
|
||||
if (protocolTypeComboBox.selectedItem == Protocol.Local) {
|
||||
if (protocolTypeComboBox.selectedItem == Protocol.Local
|
||||
|| protocolTypeComboBox.selectedItem == Protocol.Serial
|
||||
) {
|
||||
hostTextField.isEnabled = false
|
||||
portTextField.isEnabled = false
|
||||
usernameTextField.isEnabled = false
|
||||
@@ -901,6 +931,127 @@ open class HostOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class SerialCommOption : JPanel(BorderLayout()), Option {
|
||||
val serialPortComboBox = OutlineComboBox<String>()
|
||||
val baudRateComboBox = OutlineComboBox<Int>()
|
||||
val dataBitsComboBox = OutlineComboBox<Int>()
|
||||
val parityComboBox = OutlineComboBox<SerialCommParity>()
|
||||
val stopBitsComboBox = OutlineComboBox<String>()
|
||||
val flowControlComboBox = OutlineComboBox<SerialCommFlowControl>()
|
||||
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
|
||||
serialPortComboBox.isEditable = true
|
||||
|
||||
baudRateComboBox.isEditable = true
|
||||
baudRateComboBox.addItem(9600)
|
||||
baudRateComboBox.addItem(19200)
|
||||
baudRateComboBox.addItem(38400)
|
||||
baudRateComboBox.addItem(57600)
|
||||
baudRateComboBox.addItem(115200)
|
||||
|
||||
dataBitsComboBox.addItem(5)
|
||||
dataBitsComboBox.addItem(6)
|
||||
dataBitsComboBox.addItem(7)
|
||||
dataBitsComboBox.addItem(8)
|
||||
dataBitsComboBox.selectedItem = 8
|
||||
|
||||
parityComboBox.addItem(SerialCommParity.None)
|
||||
parityComboBox.addItem(SerialCommParity.Even)
|
||||
parityComboBox.addItem(SerialCommParity.Odd)
|
||||
parityComboBox.addItem(SerialCommParity.Mark)
|
||||
parityComboBox.addItem(SerialCommParity.Space)
|
||||
|
||||
stopBitsComboBox.addItem("1")
|
||||
stopBitsComboBox.addItem("1.5")
|
||||
stopBitsComboBox.addItem("2")
|
||||
stopBitsComboBox.selectedItem = "1"
|
||||
|
||||
flowControlComboBox.addItem(SerialCommFlowControl.None)
|
||||
flowControlComboBox.addItem(SerialCommFlowControl.RTS_CTS)
|
||||
flowControlComboBox.addItem(SerialCommFlowControl.XON_XOFF)
|
||||
|
||||
flowControlComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
val text = value?.toString() ?: StringUtils.EMPTY
|
||||
return super.getListCellRendererComponent(
|
||||
list,
|
||||
text.replace('_', '/'),
|
||||
index,
|
||||
isSelected,
|
||||
cellHasFocus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
add(getCenterComponent(), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentShown(e: ComponentEvent) {
|
||||
removeComponentListener(this)
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
for (commPort in SerialPort.getCommPorts()) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
serialPortComboBox.addItem(commPort.systemPortName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
return Icons.plugin
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
return I18n.getString("termora.new-host.serial")
|
||||
}
|
||||
|
||||
override fun getJComponent(): JComponent {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun getCenterComponent(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, $formMargin",
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
val panel = FormBuilder.create().layout(layout)
|
||||
.add("${I18n.getString("termora.new-host.serial.port")}:").xy(1, rows)
|
||||
.add(serialPortComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.baud-rate")}:").xy(1, rows)
|
||||
.add(baudRateComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.data-bits")}:").xy(1, rows)
|
||||
.add(dataBitsComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.parity")}:").xy(1, rows)
|
||||
.add(parityComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.stop-bits")}:").xy(1, rows)
|
||||
.add(stopBitsComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.flow-control")}:").xy(1, rows)
|
||||
.add(flowControlComboBox).xy(3, rows).apply { rows += step }
|
||||
.build()
|
||||
return panel
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class JumpHostsOption : JPanel(BorderLayout()), Option {
|
||||
val jumpHosts = mutableListOf<Host>()
|
||||
@@ -983,16 +1134,16 @@ open class HostOptionsPane : OptionsPane() {
|
||||
private fun initEvents() {
|
||||
addBtn.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent?) {
|
||||
val dialog = HostTreeDialog(owner) { host ->
|
||||
jumpHosts.none { it.id == host.id } && filter.invoke(host)
|
||||
}
|
||||
|
||||
val dialog = NewHostTreeDialog(owner)
|
||||
dialog.setFilter { node -> jumpHosts.none { it.id == node.host.id } && filter.invoke(node.host) }
|
||||
dialog.setTreeName("HostOptionsPane.JumpHostsOption.Tree")
|
||||
dialog.setLocationRelativeTo(owner)
|
||||
dialog.isVisible = true
|
||||
val hosts = dialog.hosts
|
||||
if (hosts.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
hosts.forEach {
|
||||
val rowCount = model.rowCount
|
||||
jumpHosts.add(it)
|
||||
@@ -1006,7 +1157,8 @@ open class HostOptionsPane : OptionsPane() {
|
||||
val rows = table.selectedRows.sortedDescending()
|
||||
if (rows.isEmpty()) return
|
||||
for (row in rows) {
|
||||
model.removeRow(row)
|
||||
jumpHosts.removeAt(row)
|
||||
model.fireTableRowsDeleted(row, row)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.terminal.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -12,7 +14,7 @@ abstract class HostTerminalTab(
|
||||
val windowScope: WindowScope,
|
||||
val host: Host,
|
||||
protected val terminal: Terminal = TerminalFactory.getInstance(windowScope).createTerminal()
|
||||
) : PropertyTerminalTab() {
|
||||
) : PropertyTerminalTab(), DataProvider {
|
||||
companion object {
|
||||
val Host = DataKey(app.termora.Host::class)
|
||||
}
|
||||
@@ -69,4 +71,11 @@ abstract class HostTerminalTab(
|
||||
unread = false
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == DataProviders.Terminal) {
|
||||
return terminal as T?
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
|
||||
import app.termora.actions.NewHostAction
|
||||
import app.termora.actions.OpenHostAction
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
|
||||
import com.formdev.flatlaf.icons.FlatTreeOpenIcon
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.Transferable
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
import javax.swing.event.CellEditorListener
|
||||
import javax.swing.event.ChangeEvent
|
||||
import javax.swing.event.PopupMenuEvent
|
||||
import javax.swing.event.PopupMenuListener
|
||||
import javax.swing.tree.TreePath
|
||||
import javax.swing.tree.TreeSelectionModel
|
||||
|
||||
|
||||
class HostTree : JTree(), Disposable {
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
private val editor = OutlineTextField(64)
|
||||
|
||||
var contextmenu = true
|
||||
|
||||
/**
|
||||
* 双击是否打开连接
|
||||
*/
|
||||
var doubleClickConnection = true
|
||||
|
||||
val model = HostTreeModel()
|
||||
val searchableModel = SearchableHostTreeModel(model)
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
|
||||
private fun initView() {
|
||||
setModel(model)
|
||||
isEditable = true
|
||||
dropMode = DropMode.ON_OR_INSERT
|
||||
dragEnabled = true
|
||||
selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION
|
||||
editor.preferredSize = Dimension(220, 0)
|
||||
|
||||
setCellRenderer(object : DefaultXTreeCellRenderer() {
|
||||
override fun getTreeCellRendererComponent(
|
||||
tree: JTree,
|
||||
value: Any,
|
||||
sel: Boolean,
|
||||
expanded: Boolean,
|
||||
leaf: Boolean,
|
||||
row: Int,
|
||||
hasFocus: Boolean
|
||||
): Component {
|
||||
val host = value as Host
|
||||
val c = super.getTreeCellRendererComponent(tree, host, sel, expanded, leaf, row, hasFocus)
|
||||
if (host.protocol == Protocol.Folder) {
|
||||
icon = if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
|
||||
} else if (host.protocol == Protocol.SSH || host.protocol == Protocol.Local) {
|
||||
icon = if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal
|
||||
}
|
||||
return c
|
||||
}
|
||||
})
|
||||
|
||||
setCellEditor(object : DefaultCellEditor(editor) {
|
||||
override fun isCellEditable(e: EventObject?): Boolean {
|
||||
if (e is MouseEvent) {
|
||||
return false
|
||||
}
|
||||
return super.isCellEditable(e)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
|
||||
val state = Database.getDatabase().properties.getString("HostTreeExpansionState")
|
||||
if (state != null) {
|
||||
TreeUtils.loadExpansionState(this@HostTree, state)
|
||||
}
|
||||
}
|
||||
|
||||
override fun convertValueToText(
|
||||
value: Any?,
|
||||
selected: Boolean,
|
||||
expanded: Boolean,
|
||||
leaf: Boolean,
|
||||
row: Int,
|
||||
hasFocus: Boolean
|
||||
): String {
|
||||
if (value is Host) {
|
||||
return value.name
|
||||
}
|
||||
return super.convertValueToText(value, selected, expanded, leaf, row, hasFocus)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
// 右键选中
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
if (!SwingUtilities.isRightMouseButton(e)) {
|
||||
return
|
||||
}
|
||||
|
||||
requestFocusInWindow()
|
||||
|
||||
val selectionRows = selectionModel.selectionRows
|
||||
|
||||
val selRow = getClosestRowForLocation(e.x, e.y)
|
||||
if (selRow < 0) {
|
||||
selectionModel.clearSelection()
|
||||
return
|
||||
} else if (selectionRows != null && selectionRows.contains(selRow)) {
|
||||
return
|
||||
}
|
||||
|
||||
selectionPath = getPathForLocation(e.x, e.y)
|
||||
|
||||
setSelectionRow(selRow)
|
||||
}
|
||||
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val host = lastSelectedPathComponent
|
||||
if (host is Host && host.protocol != Protocol.Folder) {
|
||||
ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)
|
||||
?.actionPerformed(OpenHostActionEvent(e.source, host, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// contextmenu
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
if (!(SwingUtilities.isRightMouseButton(e))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Objects.isNull(lastSelectedPathComponent)) {
|
||||
return
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater { showContextMenu(e) }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// rename
|
||||
getCellEditor().addCellEditorListener(object : CellEditorListener {
|
||||
override fun editingStopped(e: ChangeEvent) {
|
||||
val lastHost = lastSelectedPathComponent
|
||||
if (lastHost !is Host || editor.text.isBlank() || editor.text == lastHost.name) {
|
||||
return
|
||||
}
|
||||
runCatchingHost(lastHost.copy(name = editor.text))
|
||||
}
|
||||
|
||||
override fun editingCanceled(e: ChangeEvent) {
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// drag
|
||||
transferHandler = object : TransferHandler() {
|
||||
|
||||
override fun createTransferable(c: JComponent): Transferable {
|
||||
val nodes = selectionModel.selectionPaths
|
||||
.map { it.lastPathComponent }
|
||||
.filterIsInstance<Host>()
|
||||
.toMutableList()
|
||||
|
||||
val iterator = nodes.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val node = iterator.next()
|
||||
val parents = model.getPathToRoot(node).filter { it != node }
|
||||
if (parents.any { nodes.contains(it) }) {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
return MoveHostTransferable(nodes)
|
||||
}
|
||||
|
||||
override fun getSourceActions(c: JComponent?): Int {
|
||||
return MOVE
|
||||
}
|
||||
|
||||
override fun canImport(support: TransferSupport): Boolean {
|
||||
if (!support.isDrop) {
|
||||
return false
|
||||
}
|
||||
val dropLocation = support.dropLocation
|
||||
if (dropLocation !is JTree.DropLocation || support.component != this@HostTree
|
||||
|| dropLocation.childIndex != -1
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
val lastNode = dropLocation.path.lastPathComponent
|
||||
if (lastNode !is Host || lastNode.protocol != Protocol.Folder) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) {
|
||||
val nodes = support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*>
|
||||
if (nodes.any { it == lastNode }) {
|
||||
return false
|
||||
}
|
||||
for (parent in model.getPathToRoot(lastNode).filter { it != lastNode }) {
|
||||
if (nodes.any { it == parent }) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
support.setShowDropLocation(true)
|
||||
return support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)
|
||||
}
|
||||
|
||||
override fun importData(support: TransferSupport): Boolean {
|
||||
if (!support.isDrop) {
|
||||
return false
|
||||
}
|
||||
|
||||
val dropLocation = support.dropLocation
|
||||
if (dropLocation !is JTree.DropLocation) {
|
||||
return false
|
||||
}
|
||||
|
||||
val lastNode = dropLocation.path.lastPathComponent
|
||||
if (lastNode !is Host || lastNode.protocol != Protocol.Folder) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) {
|
||||
return false
|
||||
}
|
||||
|
||||
val hosts = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*>)
|
||||
.filterIsInstance<Host>().toMutableList()
|
||||
if (hosts.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 记录展开的节点
|
||||
val expandedHosts = mutableListOf<String>()
|
||||
for (host in hosts) {
|
||||
model.visit(host) {
|
||||
if (it.protocol == Protocol.Folder) {
|
||||
if (isExpanded(TreePath(model.getPathToRoot(it)))) {
|
||||
expandedHosts.addFirst(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var now = System.currentTimeMillis()
|
||||
for (host in hosts) {
|
||||
model.removeNodeFromParent(host)
|
||||
val newHost = host.copy(
|
||||
parentId = lastNode.id,
|
||||
sort = ++now,
|
||||
updateDate = now
|
||||
)
|
||||
runCatchingHost(newHost)
|
||||
}
|
||||
|
||||
expandNode(lastNode)
|
||||
|
||||
// 展开
|
||||
for (id in expandedHosts) {
|
||||
model.getHost(id)?.let { expandNode(it) }
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun isPathEditable(path: TreePath?): Boolean {
|
||||
if (path == null) return false
|
||||
if (path.lastPathComponent == model.root) return false
|
||||
return super.isPathEditable(path)
|
||||
}
|
||||
|
||||
override fun getLastSelectedPathComponent(): Any? {
|
||||
val last = super.getLastSelectedPathComponent() ?: return null
|
||||
if (last is Host) {
|
||||
return model.getHost(last.id) ?: last
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
private fun showContextMenu(event: MouseEvent) {
|
||||
if (!contextmenu) return
|
||||
|
||||
val lastHost = lastSelectedPathComponent
|
||||
if (lastHost !is Host) {
|
||||
return
|
||||
}
|
||||
|
||||
val popupMenu = FlatPopupMenu()
|
||||
val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new"))
|
||||
val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder"))
|
||||
val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host"))
|
||||
|
||||
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open"))
|
||||
popupMenu.addSeparator()
|
||||
val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy"))
|
||||
val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove"))
|
||||
val rename = popupMenu.add(I18n.getString("termora.welcome.contextmenu.rename"))
|
||||
popupMenu.addSeparator()
|
||||
val expandAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.expand-all"))
|
||||
val colspanAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.collapse-all"))
|
||||
popupMenu.addSeparator()
|
||||
popupMenu.add(newMenu)
|
||||
popupMenu.addSeparator()
|
||||
val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property"))
|
||||
|
||||
open.addActionListener { evt ->
|
||||
getSelectionNodes()
|
||||
.filter { it.protocol != Protocol.Folder }
|
||||
.forEach {
|
||||
ActionManager.getInstance()
|
||||
.getAction(OpenHostAction.OPEN_HOST)
|
||||
?.actionPerformed(OpenHostActionEvent(evt.source, it, evt))
|
||||
}
|
||||
}
|
||||
|
||||
rename.addActionListener {
|
||||
startEditingAtPath(TreePath(model.getPathToRoot(lastHost)))
|
||||
}
|
||||
|
||||
expandAll.addActionListener {
|
||||
getSelectionNodes().forEach { expandNode(it, true) }
|
||||
}
|
||||
|
||||
|
||||
colspanAll.addActionListener {
|
||||
selectionModel.selectionPaths.map { it.lastPathComponent }
|
||||
.filterIsInstance<Host>()
|
||||
.filter { it.protocol == Protocol.Folder }
|
||||
.forEach { collapseNode(it) }
|
||||
}
|
||||
|
||||
copy.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val parent = model.getParent(lastHost) ?: return
|
||||
val node = copyNode(parent, lastHost)
|
||||
selectionPath = TreePath(model.getPathToRoot(node))
|
||||
}
|
||||
})
|
||||
|
||||
remove.addActionListener {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
I18n.getString("termora.remove"),
|
||||
JOptionPane.YES_NO_OPTION,
|
||||
JOptionPane.QUESTION_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
var lastParent: Host? = null
|
||||
while (!selectionModel.isSelectionEmpty) {
|
||||
val host = lastSelectedPathComponent ?: break
|
||||
if (host !is Host) {
|
||||
break
|
||||
} else {
|
||||
lastParent = model.getParent(host)
|
||||
}
|
||||
model.visit(host) { hostManager.removeHost(it.id) }
|
||||
}
|
||||
if (lastParent != null) {
|
||||
selectionPath = TreePath(model.getPathToRoot(lastParent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newFolder.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (lastHost.protocol != Protocol.Folder) {
|
||||
return
|
||||
}
|
||||
|
||||
val host = Host(
|
||||
id = UUID.randomUUID().toSimpleString(),
|
||||
protocol = Protocol.Folder,
|
||||
name = I18n.getString("termora.welcome.contextmenu.new.folder.name"),
|
||||
sort = System.currentTimeMillis(),
|
||||
parentId = lastHost.id
|
||||
)
|
||||
|
||||
runCatchingHost(host)
|
||||
|
||||
expandNode(lastHost)
|
||||
selectionPath = TreePath(model.getPathToRoot(host))
|
||||
startEditingAtPath(selectionPath)
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
newHost.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
ActionManager.getInstance().getAction(NewHostAction.NEW_HOST)
|
||||
?.actionPerformed(e)
|
||||
}
|
||||
})
|
||||
|
||||
property.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val dialog = HostDialog(SwingUtilities.getWindowAncestor(this@HostTree), lastHost)
|
||||
dialog.isVisible = true
|
||||
val host = dialog.host ?: return
|
||||
runCatchingHost(host)
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化状态
|
||||
newFolder.isEnabled = lastHost.protocol == Protocol.Folder
|
||||
newHost.isEnabled = newFolder.isEnabled
|
||||
remove.isEnabled = !getSelectionNodes().any { it == model.root }
|
||||
copy.isEnabled = remove.isEnabled
|
||||
rename.isEnabled = remove.isEnabled
|
||||
property.isEnabled = lastHost.protocol != Protocol.Folder
|
||||
|
||||
popupMenu.addPopupMenuListener(object : PopupMenuListener {
|
||||
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
|
||||
this@HostTree.grabFocus()
|
||||
}
|
||||
|
||||
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
|
||||
this@HostTree.requestFocusInWindow()
|
||||
}
|
||||
|
||||
override fun popupMenuCanceled(e: PopupMenuEvent) {
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
|
||||
popupMenu.show(this, event.x, event.y)
|
||||
}
|
||||
|
||||
|
||||
fun expandNode(node: Host, including: Boolean = false) {
|
||||
expandPath(TreePath(model.getPathToRoot(node)))
|
||||
if (including) {
|
||||
model.getChildren(node).forEach { expandNode(it, true) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun copyNode(
|
||||
parent: Host,
|
||||
host: Host,
|
||||
idGenerator: () -> String = { UUID.randomUUID().toSimpleString() }
|
||||
): Host {
|
||||
val now = System.currentTimeMillis()
|
||||
val newHost = host.copy(
|
||||
name = "${host.name} ${I18n.getString("termora.welcome.contextmenu.copy")}",
|
||||
id = idGenerator.invoke(),
|
||||
parentId = parent.id,
|
||||
updateDate = now,
|
||||
createDate = now,
|
||||
sort = now
|
||||
)
|
||||
|
||||
runCatchingHost(newHost)
|
||||
|
||||
if (host.protocol == Protocol.Folder) {
|
||||
for (child in model.getChildren(host)) {
|
||||
copyNode(newHost, child, idGenerator)
|
||||
}
|
||||
if (isExpanded(TreePath(model.getPathToRoot(host)))) {
|
||||
expandNode(newHost)
|
||||
}
|
||||
}
|
||||
|
||||
return newHost
|
||||
|
||||
}
|
||||
|
||||
private fun runCatchingHost(host: Host) {
|
||||
hostManager.addHost(host)
|
||||
}
|
||||
|
||||
private fun collapseNode(node: Host) {
|
||||
model.getChildren(node).forEach { collapseNode(it) }
|
||||
collapsePath(TreePath(model.getPathToRoot(node)))
|
||||
}
|
||||
|
||||
fun getSelectionNodes(): List<Host> {
|
||||
val selectionNodes = selectionModel.selectionPaths.map { it.lastPathComponent }
|
||||
.filterIsInstance<Host>()
|
||||
|
||||
if (selectionNodes.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val nodes = mutableListOf<Host>()
|
||||
val parents = mutableListOf<Host>()
|
||||
|
||||
for (node in selectionNodes) {
|
||||
if (node.protocol == Protocol.Folder) {
|
||||
parents.add(node)
|
||||
}
|
||||
nodes.add(node)
|
||||
}
|
||||
|
||||
while (parents.isNotEmpty()) {
|
||||
val p = parents.removeFirst()
|
||||
for (i in 0 until getModel().getChildCount(p)) {
|
||||
val child = getModel().getChild(p, i) as Host
|
||||
nodes.add(child)
|
||||
parents.add(child)
|
||||
}
|
||||
}
|
||||
|
||||
// 确保是最新的
|
||||
for (i in 0 until nodes.size) {
|
||||
nodes[i] = model.getHost(nodes[i].id) ?: continue
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
Database.getDatabase().properties.putString(
|
||||
"HostTreeExpansionState",
|
||||
TreeUtils.saveExpansionState(this)
|
||||
)
|
||||
}
|
||||
|
||||
private abstract class HostTreeNodeTransferable(val hosts: List<Host>) :
|
||||
Transferable {
|
||||
|
||||
override fun getTransferDataFlavors(): Array<DataFlavor> {
|
||||
return arrayOf(getDataFlavor())
|
||||
}
|
||||
|
||||
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean {
|
||||
return getDataFlavor() == flavor
|
||||
}
|
||||
|
||||
override fun getTransferData(flavor: DataFlavor): Any {
|
||||
return hosts
|
||||
}
|
||||
|
||||
abstract fun getDataFlavor(): DataFlavor
|
||||
}
|
||||
|
||||
private class MoveHostTransferable(hosts: List<Host>) : HostTreeNodeTransferable(hosts) {
|
||||
companion object {
|
||||
val dataFlavor =
|
||||
DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveHostTransferable::class.java.name}")
|
||||
}
|
||||
|
||||
override fun getDataFlavor(): DataFlavor {
|
||||
return dataFlavor
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import javax.swing.*
|
||||
import javax.swing.tree.TreeSelectionModel
|
||||
|
||||
class HostTreeDialog(
|
||||
owner: Window,
|
||||
private val filter: (host: Host) -> Boolean = { true }
|
||||
) : DialogWrapper(owner) {
|
||||
|
||||
private val tree = HostTree()
|
||||
|
||||
val hosts = mutableListOf<Host>()
|
||||
|
||||
var allowMulti = true
|
||||
set(value) {
|
||||
field = value
|
||||
if (value) {
|
||||
tree.selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION
|
||||
} else {
|
||||
tree.selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
|
||||
isModal = true
|
||||
isResizable = false
|
||||
controlsVisible = false
|
||||
title = I18n.getString("termora.transport.sftp.select-host")
|
||||
|
||||
tree.setModel(SearchableHostTreeModel(tree.model) { host ->
|
||||
(host.protocol == Protocol.Folder || host.protocol == Protocol.SSH) && filter.invoke(host)
|
||||
})
|
||||
tree.contextmenu = true
|
||||
tree.doubleClickConnection = false
|
||||
tree.dragEnabled = false
|
||||
|
||||
initEvents()
|
||||
|
||||
init()
|
||||
setLocationRelativeTo(null)
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
addWindowListener(object : WindowAdapter() {
|
||||
override fun windowActivated(e: WindowEvent) {
|
||||
removeWindowListener(this)
|
||||
val state = Database.getDatabase().properties.getString("HostTreeDialog.HostTreeExpansionState")
|
||||
if (state != null) {
|
||||
TreeUtils.loadExpansionState(tree, state)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tree.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val node = tree.lastSelectedPathComponent ?: return
|
||||
if (node is Host && node.protocol != Protocol.Folder) {
|
||||
doOKAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosed(e: WindowEvent) {
|
||||
tree.setModel(null)
|
||||
Database.getDatabase().properties.putString(
|
||||
"HostTreeDialog.HostTreeExpansionState",
|
||||
TreeUtils.saveExpansionState(tree)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
val scrollPane = JScrollPane(tree)
|
||||
scrollPane.border = BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor),
|
||||
BorderFactory.createEmptyBorder(4, 6, 4, 6)
|
||||
)
|
||||
|
||||
return scrollPane
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
|
||||
if (allowMulti) {
|
||||
val nodes = tree.getSelectionNodes().filter { it.protocol == Protocol.SSH }
|
||||
if (nodes.isEmpty()) {
|
||||
return
|
||||
}
|
||||
hosts.clear()
|
||||
hosts.addAll(nodes)
|
||||
} else {
|
||||
val node = tree.lastSelectedPathComponent ?: return
|
||||
if (node !is Host || node.protocol != Protocol.SSH) {
|
||||
return
|
||||
}
|
||||
hosts.clear()
|
||||
hosts.add(node)
|
||||
}
|
||||
|
||||
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
override fun doCancelAction() {
|
||||
hosts.clear()
|
||||
super.doCancelAction()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import javax.swing.event.TreeModelEvent
|
||||
import javax.swing.event.TreeModelListener
|
||||
import javax.swing.tree.TreeModel
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
class HostTreeModel : TreeModel {
|
||||
|
||||
val listeners = mutableListOf<TreeModelListener>()
|
||||
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
private val hosts = mutableMapOf<String, Host>()
|
||||
private val myRoot by lazy {
|
||||
Host(
|
||||
id = "0",
|
||||
protocol = Protocol.Folder,
|
||||
name = I18n.getString("termora.welcome.my-hosts"),
|
||||
host = StringUtils.EMPTY,
|
||||
port = 0,
|
||||
remark = StringUtils.EMPTY,
|
||||
username = StringUtils.EMPTY
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
|
||||
for (host in hostManager.hosts()) {
|
||||
hosts[host.id] = host
|
||||
}
|
||||
|
||||
hostManager.addHostListener(object : HostListener {
|
||||
override fun hostRemoved(id: String) {
|
||||
val host = hosts[id] ?: return
|
||||
removeNodeFromParent(host)
|
||||
}
|
||||
|
||||
override fun hostAdded(host: Host) {
|
||||
// 如果已经存在,那么是修改
|
||||
if (hosts.containsKey(host.id)) {
|
||||
val oldHost = hosts.getValue(host.id)
|
||||
// 父级结构变了
|
||||
if (oldHost.parentId != host.parentId) {
|
||||
hostRemoved(host.id)
|
||||
hostAdded(host)
|
||||
} else {
|
||||
hosts[host.id] = host
|
||||
val event = TreeModelEvent(this, getPathToRoot(host))
|
||||
listeners.forEach { it.treeStructureChanged(event) }
|
||||
}
|
||||
|
||||
} else {
|
||||
hosts[host.id] = host
|
||||
val parent = getParent(host) ?: return
|
||||
val path = TreePath(getPathToRoot(parent))
|
||||
val event = TreeModelEvent(this, path, intArrayOf(getIndexOfChild(parent, host)), arrayOf(host))
|
||||
listeners.forEach { it.treeNodesInserted(event) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun hostsChanged() {
|
||||
hosts.clear()
|
||||
for (host in hostManager.hosts()) {
|
||||
hosts[host.id] = host
|
||||
}
|
||||
val event = TreeModelEvent(this, getPathToRoot(root), null, null)
|
||||
listeners.forEach { it.treeStructureChanged(event) }
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
override fun getRoot(): Host {
|
||||
return myRoot
|
||||
}
|
||||
|
||||
override fun getChild(parent: Any?, index: Int): Any {
|
||||
return getChildren(parent)[index]
|
||||
}
|
||||
|
||||
override fun getChildCount(parent: Any?): Int {
|
||||
return getChildren(parent).size
|
||||
}
|
||||
|
||||
override fun isLeaf(node: Any?): Boolean {
|
||||
return getChildCount(node) == 0
|
||||
}
|
||||
|
||||
fun getParent(node: Host): Host? {
|
||||
if (node.parentId == root.id || root.id == node.id) {
|
||||
return root
|
||||
}
|
||||
return hosts.values.firstOrNull { it.id == node.parentId }
|
||||
}
|
||||
|
||||
override fun valueForPathChanged(path: TreePath?, newValue: Any?) {
|
||||
|
||||
}
|
||||
|
||||
override fun getIndexOfChild(parent: Any?, child: Any?): Int {
|
||||
return getChildren(parent).indexOf(child)
|
||||
}
|
||||
|
||||
override fun addTreeModelListener(listener: TreeModelListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeTreeModelListener(listener: TreeModelListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅从结构中删除
|
||||
*/
|
||||
fun removeNodeFromParent(host: Host) {
|
||||
val parent = getParent(host) ?: return
|
||||
val index = getIndexOfChild(parent, host)
|
||||
val event = TreeModelEvent(this, TreePath(getPathToRoot(parent)), intArrayOf(index), arrayOf(host))
|
||||
hosts.remove(host.id)
|
||||
listeners.forEach { it.treeNodesRemoved(event) }
|
||||
}
|
||||
|
||||
fun visit(host: Host, visitor: (host: Host) -> Unit) {
|
||||
if (host.protocol == Protocol.Folder) {
|
||||
getChildren(host).forEach { visit(it, visitor) }
|
||||
visitor.invoke(host)
|
||||
} else {
|
||||
visitor.invoke(host)
|
||||
}
|
||||
}
|
||||
|
||||
fun getHost(id: String): Host? {
|
||||
return hosts[id]
|
||||
}
|
||||
|
||||
fun getPathToRoot(host: Host): Array<Host> {
|
||||
|
||||
if (host.id == root.id) {
|
||||
return arrayOf(root)
|
||||
}
|
||||
|
||||
val parents = mutableListOf(host)
|
||||
var pId = host.parentId
|
||||
while (pId != root.id) {
|
||||
val e = hosts[(pId)] ?: break
|
||||
parents.addFirst(e)
|
||||
pId = e.parentId
|
||||
}
|
||||
parents.addFirst(root)
|
||||
return parents.toTypedArray()
|
||||
}
|
||||
|
||||
fun getChildren(parent: Any?): List<Host> {
|
||||
val pId = if (parent is Host) parent.id else root.id
|
||||
return hosts.values.filter { it.parentId == pId }
|
||||
.sortedWith(compareBy<Host> { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort })
|
||||
}
|
||||
}
|
||||
97
src/main/kotlin/app/termora/HostTreeNode.kt
Normal file
97
src/main/kotlin/app/termora/HostTreeNode.kt
Normal file
@@ -0,0 +1,97 @@
|
||||
package app.termora
|
||||
|
||||
import javax.swing.tree.DefaultMutableTreeNode
|
||||
import javax.swing.tree.TreeNode
|
||||
|
||||
class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) {
|
||||
companion object {
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果要重新赋值,记得修改 [Host.updateDate] 否则下次取出时可能时缓存的
|
||||
*/
|
||||
var host: Host
|
||||
get() {
|
||||
val cacheHost = hostManager.getHost((userObject as Host).id)
|
||||
val myHost = userObject as Host
|
||||
if (cacheHost == null) {
|
||||
return myHost
|
||||
}
|
||||
return if (cacheHost.updateDate > myHost.updateDate) cacheHost else myHost
|
||||
}
|
||||
set(value) = setUserObject(value)
|
||||
|
||||
val folderCount
|
||||
get() = children().toList().count { if (it is HostTreeNode) it.host.protocol == Protocol.Folder else false }
|
||||
|
||||
override fun getParent(): HostTreeNode? {
|
||||
return super.getParent() as HostTreeNode?
|
||||
}
|
||||
|
||||
fun getAllChildren(): List<HostTreeNode> {
|
||||
val children = mutableListOf<HostTreeNode>()
|
||||
for (child in children()) {
|
||||
if (child is HostTreeNode) {
|
||||
children.add(child)
|
||||
children.addAll(child.getAllChildren())
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
fun childrenNode(): List<HostTreeNode> {
|
||||
return children?.map { it as HostTreeNode } ?: emptyList()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 深度克隆
|
||||
* @param scopes 克隆的范围
|
||||
*/
|
||||
fun clone(scopes: Set<Protocol> = emptySet()): HostTreeNode {
|
||||
val newNode = clone() as HostTreeNode
|
||||
deepClone(newNode, this, scopes)
|
||||
return newNode
|
||||
}
|
||||
|
||||
private fun deepClone(newNode: HostTreeNode, oldNode: HostTreeNode, scopes: Set<Protocol> = emptySet()) {
|
||||
for (child in oldNode.childrenNode()) {
|
||||
if (scopes.isNotEmpty() && !scopes.contains(child.host.protocol)) continue
|
||||
val newChildNode = child.clone() as HostTreeNode
|
||||
deepClone(newChildNode, child, scopes)
|
||||
newNode.add(newChildNode)
|
||||
}
|
||||
}
|
||||
|
||||
override fun clone(): Any {
|
||||
val newNode = HostTreeNode(host)
|
||||
newNode.children = null
|
||||
newNode.parent = null
|
||||
return newNode
|
||||
}
|
||||
|
||||
override fun isNodeChild(aNode: TreeNode?): Boolean {
|
||||
if (aNode is HostTreeNode) {
|
||||
for (node in childrenNode()) {
|
||||
if (node.host == aNode.host) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.isNodeChild(aNode)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as HostTreeNode
|
||||
|
||||
return host == other.host
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return host.hashCode()
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,15 @@ package app.termora
|
||||
object Icons {
|
||||
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
|
||||
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
|
||||
val closeSmall by lazy { DynamicIcon("icons/closeSmall.svg", "icons/closeSmall_dark.svg") }
|
||||
val closeSmallHovered by lazy { DynamicIcon("icons/closeSmallHovered.svg", "icons/closeSmallHovered_dark.svg") }
|
||||
val plugin by lazy { DynamicIcon("icons/plugin.svg", "icons/plugin_dark.svg") }
|
||||
val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") }
|
||||
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
|
||||
val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") }
|
||||
val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") }
|
||||
val openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") }
|
||||
val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_dark.svg") }
|
||||
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
|
||||
val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") }
|
||||
val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") }
|
||||
@@ -23,6 +28,9 @@ object Icons {
|
||||
val empty by lazy { DynamicIcon("icons/empty.svg") }
|
||||
val changelog by lazy { DynamicIcon("icons/changelog.svg", "icons/changelog_dark.svg") }
|
||||
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
|
||||
val locate by lazy { DynamicIcon("icons/locate.svg", "icons/locate_dark.svg") }
|
||||
val percentage by lazy { DynamicIcon("icons/percentage.svg", "icons/percentage_dark.svg") }
|
||||
val text by lazy { DynamicIcon("icons/text.svg", "icons/text_dark.svg") }
|
||||
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
|
||||
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
|
||||
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
|
||||
@@ -47,6 +55,7 @@ object Icons {
|
||||
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }
|
||||
val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") }
|
||||
val terminal by lazy { DynamicIcon("icons/terminal.svg", "icons/terminal_dark.svg") }
|
||||
val fileFormat by lazy { DynamicIcon("icons/fileFormat.svg", "icons/fileFormat_dark.svg") }
|
||||
val azure by lazy { DynamicIcon("icons/azure.svg", "icons/azure_dark.svg") }
|
||||
val revert by lazy { DynamicIcon("icons/revert.svg", "icons/revert_dark.svg") }
|
||||
val edit by lazy { DynamicIcon("icons/edit.svg", "icons/edit_dark.svg") }
|
||||
@@ -67,6 +76,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") }
|
||||
@@ -109,5 +119,6 @@ object Icons {
|
||||
val listKey by lazy { DynamicIcon("icons/listKey.svg", "icons/listKey_dark.svg") }
|
||||
val forwardPorts by lazy { DynamicIcon("icons/forwardPorts.svg", "icons/forwardPorts_dark.svg") }
|
||||
val showWriteAccess by lazy { DynamicIcon("icons/showWriteAccess.svg", "icons/showWriteAccess_dark.svg") }
|
||||
val nvidia by lazy { DynamicIcon("icons/nvidia.svg", "icons/nvidia_dark.svg") }
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -12,6 +12,10 @@ fun main() {
|
||||
setupNativeLibraries()
|
||||
}
|
||||
|
||||
if (SystemUtils.IS_OS_MAC_OSX) {
|
||||
System.setProperty("apple.awt.application.name", Application.getName())
|
||||
}
|
||||
|
||||
ApplicationRunner().run()
|
||||
}
|
||||
|
||||
@@ -41,4 +45,14 @@ private fun setupNativeLibraries() {
|
||||
if (pty4j.exists()) {
|
||||
System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath)
|
||||
}
|
||||
|
||||
val jSerialComm = FileUtils.getFile(dylib, "jSerialComm")
|
||||
if (jSerialComm.exists()) {
|
||||
System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath)
|
||||
}
|
||||
|
||||
val restart4j = FileUtils.getFile(dylib, "restart4j", "restarter")
|
||||
if (restart4j.exists()) {
|
||||
System.setProperty("restarter.path", restart4j.absolutePath)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import app.termora.actions.ActionManager
|
||||
import app.termora.terminal.Terminal
|
||||
import app.termora.terminal.TerminalColor
|
||||
import app.termora.terminal.TextStyle
|
||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||
import app.termora.terminal.panel.TerminalDisplay
|
||||
import app.termora.terminal.panel.TerminalPaintListener
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
@@ -32,13 +33,25 @@ class MultipleTerminalListener : TerminalPaintListener {
|
||||
// 正在搜索那么需要下移
|
||||
val finding = terminal.getTerminalModel().getData(TerminalPanel.Finding, false)
|
||||
|
||||
// 如果悬浮窗正在显示,那么需要下移
|
||||
val floatingToolBar = terminalPanel.getData(FloatingToolbarPanel.FloatingToolbar)?.isVisible == true
|
||||
|
||||
var y = g.fontMetrics.ascent
|
||||
if (finding) {
|
||||
y += g.fontMetrics.height + g.fontMetrics.ascent / 2
|
||||
}
|
||||
|
||||
if (floatingToolBar) {
|
||||
y += g.fontMetrics.height + g.fontMetrics.ascent / 2
|
||||
}
|
||||
|
||||
|
||||
g.font = font
|
||||
g.color = Color(colorPalette.getColor(TerminalColor.Normal.RED))
|
||||
g.drawString(
|
||||
text,
|
||||
terminalDisplay.width - width - terminalPanel.getAverageCharWidth() / 2,
|
||||
g.fontMetrics.ascent + if (finding)
|
||||
g.fontMetrics.height + g.fontMetrics.ascent / 2 else 0
|
||||
y
|
||||
)
|
||||
g.font = oldFont
|
||||
}
|
||||
|
||||
@@ -1,12 +1,278 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviders
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.*
|
||||
import java.awt.event.*
|
||||
import java.awt.image.BufferedImage
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
import kotlin.math.abs
|
||||
|
||||
class MyTabbedPane : FlatTabbedPane() {
|
||||
|
||||
private val dragMouseAdaptor = DragMouseAdaptor()
|
||||
private val terminalTabbedManager
|
||||
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
|
||||
.getData(DataProviders.TerminalTabbedManager)
|
||||
private val owner
|
||||
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
|
||||
.getData(DataProviders.TermoraFrame) as TermoraFrame
|
||||
|
||||
init {
|
||||
initEvents()
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
styleMap = mapOf(
|
||||
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
|
||||
"hoverColor" to UIManager.getColor("TabbedPane.background"),
|
||||
)
|
||||
super.updateUI()
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
addMouseListener(dragMouseAdaptor)
|
||||
addMouseMotionListener(dragMouseAdaptor)
|
||||
}
|
||||
|
||||
override fun processMouseEvent(e: MouseEvent) {
|
||||
// Shift + Click ===> close tab
|
||||
if (e.id == MouseEvent.MOUSE_CLICKED && SwingUtilities.isLeftMouseButton(e) && isShiftPressedOnly(e.modifiersEx)) {
|
||||
val index = indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
tabCloseCallback?.accept(this, index)
|
||||
return
|
||||
}
|
||||
} else if (e.id == MouseEvent.MOUSE_PRESSED && isShiftPressedOnly(e.modifiersEx)) {
|
||||
val index = indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
super.processMouseEvent(e)
|
||||
}
|
||||
|
||||
private fun isShiftPressedOnly(modifiersEx: Int): Boolean {
|
||||
return (modifiersEx and InputEvent.ALT_DOWN_MASK) == 0
|
||||
&& (modifiersEx and InputEvent.ALT_GRAPH_DOWN_MASK) == 0
|
||||
&& (modifiersEx and InputEvent.CTRL_DOWN_MASK) == 0
|
||||
&& (modifiersEx and InputEvent.SHIFT_DOWN_MASK) != 0
|
||||
}
|
||||
|
||||
override fun setSelectedIndex(index: Int) {
|
||||
val oldIndex = selectedIndex
|
||||
super.setSelectedIndex(index)
|
||||
firePropertyChange("selectedIndex", oldIndex, index)
|
||||
}
|
||||
|
||||
|
||||
private inner class DragMouseAdaptor : MouseAdapter(), KeyEventDispatcher {
|
||||
private var mousePressedPoint = Point()
|
||||
private var tabIndex = 0 - 1
|
||||
private var cancelled = false
|
||||
private var window: Window? = null
|
||||
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)
|
||||
if (index < 0 || !isTabClosable(index)) {
|
||||
tabIndex = -1
|
||||
mousePressedPoint = Point()
|
||||
return
|
||||
}
|
||||
tabIndex = index
|
||||
mousePressedPoint = e.point
|
||||
}
|
||||
|
||||
override fun mouseDragged(e: MouseEvent) {
|
||||
// 如果正在拖拽中,那么修改 Window 的位置
|
||||
if (isDragging) {
|
||||
window?.location = e.locationOnScreen
|
||||
lastVisitTabIndex = indexAtLocation(e.x, e.y)
|
||||
} else if (tabIndex >= 0) { // 这里之所以判断是确保在 mousePressed 时已经确定了 Tab
|
||||
// 有的时候会太灵敏,这里容错一下
|
||||
val diff = 5
|
||||
if (abs(mousePressedPoint.y - e.y) >= diff || abs(mousePressedPoint.x - e.x) >= diff) {
|
||||
startDrag(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDrag(e: MouseEvent) {
|
||||
if (isDragging) return
|
||||
val terminalTabbedManager = terminalTabbedManager ?: return
|
||||
val window = JDialog(owner).also { this.window = it }
|
||||
window.isUndecorated = true
|
||||
val image = createTabImage(tabIndex)
|
||||
window.size = Dimension(image.width, image.height)
|
||||
window.add(JLabel(ImageIcon(image)))
|
||||
window.location = e.locationOnScreen
|
||||
window.addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosed(e: WindowEvent) {
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager()
|
||||
.removeKeyEventDispatcher(this@DragMouseAdaptor)
|
||||
}
|
||||
|
||||
override fun windowOpened(e: WindowEvent) {
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager()
|
||||
.addKeyEventDispatcher(this@DragMouseAdaptor)
|
||||
}
|
||||
})
|
||||
|
||||
// 暂时关闭 Tab
|
||||
terminalTabbedManager.closeTerminalTab(terminalTabbedManager.getTerminalTabs()[tabIndex].also {
|
||||
terminalTab = it
|
||||
}, false)
|
||||
|
||||
window.isVisible = true
|
||||
|
||||
isDragging = true
|
||||
cancelled = false
|
||||
}
|
||||
|
||||
private fun stopDrag() {
|
||||
if (!isDragging) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是取消,那么不需要移动到其它窗口
|
||||
val c = if (cancelled) owner else getTopMostWindowUnderMouse()
|
||||
|
||||
// 如果等于 null 表示在空地方释放,那么单独一个窗口
|
||||
if (c == null) {
|
||||
val window = TermoraFrameManager.getInstance().createWindow()
|
||||
dragToAnotherWindow(owner, window)
|
||||
window.location = releasedPoint
|
||||
window.isVisible = true
|
||||
} else if (c != owner && c is TermoraFrame) { // 如果在某个窗口内释放,那么就移动到某个窗口内
|
||||
dragToAnotherWindow(owner, c)
|
||||
} else {
|
||||
val tab = this.terminalTab
|
||||
val terminalTabbedManager = terminalTabbedManager
|
||||
if (tab != null && terminalTabbedManager != null) {
|
||||
moveTab(
|
||||
terminalTabbedManager,
|
||||
tab,
|
||||
lastVisitTabIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// reset
|
||||
window?.dispose()
|
||||
isDragging = false
|
||||
tabIndex = -1
|
||||
cancelled = false
|
||||
lastVisitTabIndex = -1
|
||||
}
|
||||
|
||||
override fun mouseReleased(e: MouseEvent) {
|
||||
releasedPoint = e.point
|
||||
stopDrag()
|
||||
}
|
||||
|
||||
private fun createTabImage(index: Int): BufferedImage {
|
||||
val tabBounds = getBoundsAt(index)
|
||||
val image = BufferedImage(tabBounds.width, tabBounds.height, BufferedImage.TYPE_INT_ARGB)
|
||||
val g2 = image.createGraphics()
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
|
||||
g2.translate(-tabBounds.x, -tabBounds.y)
|
||||
paint(g2)
|
||||
g2.dispose()
|
||||
return image
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(e: KeyEvent): Boolean {
|
||||
if (e.keyCode == KeyEvent.VK_ESCAPE) {
|
||||
cancelled = true
|
||||
stopDrag()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getTopMostWindowUnderMouse(): Window? {
|
||||
val mouseLocation = MouseInfo.getPointerInfo().location
|
||||
val owner = owner
|
||||
if (owner.isVisible && owner.bounds.contains(mouseLocation)) {
|
||||
return owner
|
||||
}
|
||||
|
||||
val windows = Window.getWindows()
|
||||
// 倒序遍历,最上层的窗口优先匹配
|
||||
for (i in windows.indices.reversed()) {
|
||||
val window = windows[i]
|
||||
if (window !is TermoraFrame) {
|
||||
continue
|
||||
}
|
||||
if (window.isVisible && window.bounds.contains(mouseLocation)) {
|
||||
val topComponent = SwingUtilities.getDeepestComponentAt(
|
||||
window,
|
||||
mouseLocation.x - window.x, mouseLocation.y - window.y
|
||||
)
|
||||
if (topComponent != null) {
|
||||
return SwingUtilities.getWindowAncestor(topComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
private fun dragToAnotherWindow(oldFrame: TermoraFrame, frame: TermoraFrame) {
|
||||
val tab = this.terminalTab ?: return
|
||||
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
|
||||
val tabbedManager = frame.getData(DataProviders.TerminalTabbed) ?: return
|
||||
val tabbedPane = frame.getData(DataProviders.TabbedPane) ?: return
|
||||
val windowScope = frame.getData(DataProviders.WindowScope) ?: return
|
||||
val oldWindowScope = oldFrame.getData(DataProviders.WindowScope) ?: return
|
||||
val location = Point(MouseInfo.getPointerInfo().location)
|
||||
SwingUtilities.convertPointFromScreen(location, tabbedPane)
|
||||
val index = tabbedPane.indexAtLocation(location.x, location.y)
|
||||
|
||||
|
||||
moveTab(
|
||||
tabbedManager,
|
||||
tab,
|
||||
index
|
||||
)
|
||||
|
||||
TerminalPanelFactory.getInstance(oldWindowScope).removeTerminalPanel(terminalPanel)
|
||||
TerminalPanelFactory.getInstance(windowScope).addTerminalPanel(terminalPanel)
|
||||
|
||||
|
||||
|
||||
if (frame.hasFocus()) {
|
||||
return
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
frame.requestFocus()
|
||||
tabbedPane.selectedComponent?.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveTab(terminalTabbedManager: TerminalTabbedManager, tab: TerminalTab, lastVisitTabIndex: Int) {
|
||||
// 如果是手动取消
|
||||
if (cancelled) {
|
||||
terminalTabbedManager.addTerminalTab(tabIndex, tab)
|
||||
} else if (lastVisitTabIndex > 0) {
|
||||
terminalTabbedManager.addTerminalTab(lastVisitTabIndex, tab)
|
||||
} else if (lastVisitTabIndex == 0) {
|
||||
terminalTabbedManager.addTerminalTab(1, tab)
|
||||
} else {
|
||||
terminalTabbedManager.addTerminalTab(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
1116
src/main/kotlin/app/termora/NewHostTree.kt
Normal file
1116
src/main/kotlin/app/termora/NewHostTree.kt
Normal file
File diff suppressed because it is too large
Load Diff
87
src/main/kotlin/app/termora/NewHostTreeDialog.kt
Normal file
87
src/main/kotlin/app/termora/NewHostTreeDialog.kt
Normal file
@@ -0,0 +1,87 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.util.function.Function
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JScrollPane
|
||||
import javax.swing.UIManager
|
||||
|
||||
class NewHostTreeDialog(
|
||||
owner: Window,
|
||||
) : DialogWrapper(owner) {
|
||||
var hosts = emptyList<Host>()
|
||||
var allowMulti = true
|
||||
|
||||
private var filter: Function<HostTreeNode, Boolean> = Function<HostTreeNode, Boolean> { true }
|
||||
private val tree = NewHostTree()
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
|
||||
isModal = true
|
||||
isResizable = false
|
||||
controlsVisible = false
|
||||
title = I18n.getString("termora.transport.sftp.select-host")
|
||||
|
||||
tree.contextmenu = false
|
||||
tree.doubleClickConnection = false
|
||||
tree.dragEnabled = false
|
||||
|
||||
|
||||
|
||||
init()
|
||||
setLocationRelativeTo(null)
|
||||
|
||||
}
|
||||
|
||||
fun setFilter(filter: Function<HostTreeNode, Boolean>) {
|
||||
tree.model = FilterableHostTreeModel(tree) { false }.apply {
|
||||
addFilter(filter)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
val scrollPane = JScrollPane(tree)
|
||||
scrollPane.border = BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor),
|
||||
BorderFactory.createEmptyBorder(4, 6, 4, 6)
|
||||
)
|
||||
|
||||
return scrollPane
|
||||
}
|
||||
|
||||
|
||||
override fun doCancelAction() {
|
||||
hosts = emptyList()
|
||||
super.doCancelAction()
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
hosts = tree.getSelectionHostTreeNodes(true)
|
||||
.filter { filter.apply(it) }
|
||||
.map { it.host }
|
||||
|
||||
if (hosts.isEmpty()) return
|
||||
if (!allowMulti && hosts.size > 1) return
|
||||
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
fun setTreeName(treeName: String) {
|
||||
Disposer.register(disposable, object : Disposable {
|
||||
private val key = "${treeName}.state"
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
|
||||
init {
|
||||
TreeUtils.loadExpansionState(tree, properties.getString(key, StringUtils.EMPTY))
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
properties.putString(key, TreeUtils.saveExpansionState(tree))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
83
src/main/kotlin/app/termora/NewHostTreeModel.kt
Normal file
83
src/main/kotlin/app/termora/NewHostTreeModel.kt
Normal file
@@ -0,0 +1,83 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import javax.swing.tree.DefaultTreeModel
|
||||
import javax.swing.tree.MutableTreeNode
|
||||
import javax.swing.tree.TreeNode
|
||||
|
||||
|
||||
class NewHostTreeModel : DefaultTreeModel(
|
||||
HostTreeNode(
|
||||
Host(
|
||||
id = "0",
|
||||
protocol = Protocol.Folder,
|
||||
name = I18n.getString("termora.welcome.my-hosts"),
|
||||
host = StringUtils.EMPTY,
|
||||
port = 0,
|
||||
remark = StringUtils.EMPTY,
|
||||
username = StringUtils.EMPTY
|
||||
)
|
||||
)
|
||||
) {
|
||||
private val Host.isRoot get() = this.parentId == "0" || this.parentId.isBlank()
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
|
||||
init {
|
||||
reload()
|
||||
}
|
||||
|
||||
|
||||
override fun getRoot(): HostTreeNode {
|
||||
return super.getRoot() as HostTreeNode
|
||||
}
|
||||
|
||||
|
||||
override fun reload(parent: TreeNode) {
|
||||
|
||||
if (parent !is HostTreeNode) {
|
||||
super.reload(parent)
|
||||
return
|
||||
}
|
||||
|
||||
parent.removeAllChildren()
|
||||
|
||||
val hosts = hostManager.hosts()
|
||||
val nodes = linkedMapOf<String, HostTreeNode>()
|
||||
|
||||
// 遍历 Host 列表,构建树节点
|
||||
for (host in hosts) {
|
||||
val node = HostTreeNode(host)
|
||||
nodes[host.id] = node
|
||||
}
|
||||
|
||||
for (host in hosts) {
|
||||
val node = nodes[host.id] ?: continue
|
||||
if (host.isRoot) continue
|
||||
val p = nodes[host.parentId] ?: continue
|
||||
p.add(node)
|
||||
}
|
||||
|
||||
for ((_, v) in nodes.entries) {
|
||||
if (parent.host.id == v.host.parentId) {
|
||||
parent.add(v)
|
||||
}
|
||||
}
|
||||
|
||||
super.reload(parent)
|
||||
}
|
||||
|
||||
override fun insertNodeInto(newChild: MutableTreeNode, parent: MutableTreeNode, index: Int) {
|
||||
super.insertNodeInto(newChild, parent, index)
|
||||
// 重置所有排序
|
||||
if (parent is HostTreeNode) {
|
||||
for ((i, c) in parent.children().toList().filterIsInstance<HostTreeNode>().withIndex()) {
|
||||
val sort = i.toLong()
|
||||
if (c.host.sort == sort) continue
|
||||
c.host = c.host.copy(sort = sort, updateDate = System.currentTimeMillis())
|
||||
hostManager.addHost(c.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jetbrains.JBR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.JXLabel
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Desktop
|
||||
@@ -57,6 +55,7 @@ object OptionPane {
|
||||
pane.selectInitialValue()
|
||||
}
|
||||
})
|
||||
dialog.setLocationRelativeTo(parentComponent)
|
||||
dialog.isVisible = true
|
||||
dialog.dispose()
|
||||
val selectedValue = pane.value
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.beans.PropertyChangeEvent
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.util.*
|
||||
|
||||
abstract class PropertyTerminalTab : TerminalTab {
|
||||
protected val listeners = mutableListOf<PropertyChangeListener>()
|
||||
@@ -26,6 +30,10 @@ abstract class PropertyTerminalTab : TerminalTab {
|
||||
|
||||
override fun onLostFocus() {
|
||||
hasFocus = false
|
||||
|
||||
// 切换标签时,尝试隐藏悬浮工具栏
|
||||
val evt = AnActionEvent(getJComponent(), StringUtils.EMPTY, EventObject(getJComponent()))
|
||||
evt.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerHide()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,20 @@ class PtyConnectorFactory : Disposable {
|
||||
rows: Int = 24, cols: Int = 80,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
charset: Charset = StandardCharsets.UTF_8
|
||||
): PtyConnector {
|
||||
val command = database.terminal.localShell
|
||||
val commands = mutableListOf(command)
|
||||
if (SystemUtils.IS_OS_UNIX) {
|
||||
commands.add("-l")
|
||||
}
|
||||
return createPtyConnector(commands.toTypedArray(), rows, cols, env, charset)
|
||||
}
|
||||
|
||||
fun createPtyConnector(
|
||||
commands: Array<String>,
|
||||
rows: Int = 24, cols: Int = 80,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
charset: Charset = StandardCharsets.UTF_8
|
||||
): PtyConnector {
|
||||
val envs = mutableMapOf<String, String>()
|
||||
envs.putAll(System.getenv())
|
||||
@@ -38,21 +52,17 @@ class PtyConnectorFactory : Disposable {
|
||||
val locale = Locale.getDefault()
|
||||
if (StringUtils.isNoneBlank(locale.language, locale.country)) {
|
||||
envs["LANG"] = "${locale.language}_${locale.country}.${Charset.defaultCharset().name()}"
|
||||
} else {
|
||||
envs["LANG"] = "en_US.UTF-8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val command = database.terminal.localShell
|
||||
val commands = mutableListOf(command)
|
||||
if (SystemUtils.IS_OS_UNIX) {
|
||||
commands.add("-l")
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("command: {} , envs: {}", commands.joinToString(" "), envs)
|
||||
}
|
||||
|
||||
val ptyProcess = PtyProcessBuilder(commands.toTypedArray())
|
||||
val ptyProcess = PtyProcessBuilder(commands)
|
||||
.setEnvironment(envs)
|
||||
.setInitialRows(rows)
|
||||
.setInitialColumns(cols)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.terminal.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
@@ -23,8 +24,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 {
|
||||
@@ -49,12 +51,16 @@ abstract class PtyHostTerminalTab(
|
||||
startPtyConnectorReader()
|
||||
|
||||
// 启动命令
|
||||
if (host.options.startupCommand.isNotBlank()) {
|
||||
if (host.options.startupCommand.isNotBlank() && host.protocol != Protocol.SFTPPty) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
delay(250.milliseconds)
|
||||
withContext(Dispatchers.Swing) {
|
||||
ptyConnector.write(host.options.startupCommand)
|
||||
ptyConnector.write(terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER)))
|
||||
val charset = ptyConnector.getCharset()
|
||||
ptyConnector.write(host.options.startupCommand.toByteArray(charset))
|
||||
ptyConnector.write(
|
||||
terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
|
||||
.toByteArray(charset)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +122,7 @@ abstract class PtyHostTerminalTab(
|
||||
|
||||
override fun dispose() {
|
||||
stop()
|
||||
terminalPanel
|
||||
super.dispose()
|
||||
|
||||
|
||||
@@ -129,4 +136,12 @@ abstract class PtyHostTerminalTab(
|
||||
}
|
||||
|
||||
abstract suspend fun openPtyConnector(): PtyConnector
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == DataProviders.TerminalPanel) {
|
||||
return terminalPanel as T?
|
||||
}
|
||||
return super.getData(dataKey)
|
||||
}
|
||||
}
|
||||
161
src/main/kotlin/app/termora/RequestAuthenticationDialog.kt
Normal file
161
src/main/kotlin/app/termora/RequestAuthenticationDialog.kt
Normal file
@@ -0,0 +1,161 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.keymgr.KeyManager
|
||||
import app.termora.keymgr.OhKeyPair
|
||||
import com.formdev.flatlaf.extras.components.FlatComboBox
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.awt.event.ItemEvent
|
||||
import javax.swing.*
|
||||
import kotlin.math.max
|
||||
|
||||
class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(owner) {
|
||||
|
||||
private val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
|
||||
private val rememberCheckBox = JCheckBox("Remember")
|
||||
private val passwordPanel = JPanel(BorderLayout())
|
||||
private val passwordPasswordField = OutlinePasswordField()
|
||||
private val usernameTextField = OutlineTextField()
|
||||
private val publicKeyComboBox = OutlineComboBox<OhKeyPair>()
|
||||
private val keyManager get() = KeyManager.getInstance()
|
||||
private var authentication = Authentication.No
|
||||
|
||||
init {
|
||||
isModal = true
|
||||
title = "SSH User Authentication"
|
||||
controlsVisible = false
|
||||
|
||||
init()
|
||||
|
||||
pack()
|
||||
|
||||
size = Dimension(max(380, size.width), size.height)
|
||||
|
||||
setLocationRelativeTo(null)
|
||||
|
||||
publicKeyComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
return super.getListCellRendererComponent(
|
||||
list,
|
||||
if (value is OhKeyPair) value.name else value,
|
||||
index,
|
||||
isSelected,
|
||||
cellHasFocus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (keyPair in keyManager.getOhKeyPairs()) {
|
||||
publicKeyComboBox.addItem(keyPair)
|
||||
}
|
||||
|
||||
authenticationTypeComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
switchPasswordComponent()
|
||||
}
|
||||
}
|
||||
|
||||
usernameTextField.text = host.username
|
||||
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.Password)
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.PublicKey)
|
||||
val formMargin = "7dlu"
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
switchPasswordComponent()
|
||||
|
||||
return FormBuilder.create().padding("$formMargin, $formMargin, $formMargin, $formMargin")
|
||||
.layout(layout)
|
||||
.add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, 1)
|
||||
.add(authenticationTypeComboBox).xy(3, 1)
|
||||
.add("${I18n.getString("termora.new-host.general.username")}:").xy(1, 3)
|
||||
.add(usernameTextField).xy(3, 3)
|
||||
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, 5)
|
||||
.add(passwordPanel).xy(3, 5)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun switchPasswordComponent() {
|
||||
passwordPanel.removeAll()
|
||||
if (authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
|
||||
passwordPanel.add(passwordPasswordField, BorderLayout.CENTER)
|
||||
} else if (authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
|
||||
passwordPanel.add(publicKeyComboBox, BorderLayout.CENTER)
|
||||
}
|
||||
passwordPanel.revalidate()
|
||||
passwordPanel.repaint()
|
||||
}
|
||||
|
||||
override fun createSouthPanel(): JComponent? {
|
||||
val box = super.createSouthPanel() ?: return null
|
||||
rememberCheckBox.isFocusable = false
|
||||
box.add(rememberCheckBox, 0)
|
||||
return box
|
||||
}
|
||||
|
||||
override fun doCancelAction() {
|
||||
authentication = Authentication.No
|
||||
super.doCancelAction()
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
val type = authenticationTypeComboBox.selectedItem as AuthenticationType
|
||||
|
||||
if (type == AuthenticationType.Password) {
|
||||
if (passwordPasswordField.password.isEmpty()) {
|
||||
passwordPasswordField.requestFocusInWindow()
|
||||
return
|
||||
}
|
||||
} else if (type == AuthenticationType.PublicKey) {
|
||||
if (publicKeyComboBox.selectedItem == null) {
|
||||
publicKeyComboBox.requestFocusInWindow()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
authentication = authentication.copy(
|
||||
type = type,
|
||||
password = if (type == AuthenticationType.Password) String(passwordPasswordField.password)
|
||||
else (publicKeyComboBox.selectedItem as OhKeyPair).id
|
||||
)
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
fun getAuthentication(): Authentication {
|
||||
isModal = true
|
||||
SwingUtilities.invokeLater {
|
||||
if (usernameTextField.text.isBlank()) {
|
||||
usernameTextField.requestFocusInWindow()
|
||||
} else {
|
||||
passwordPasswordField.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
isVisible = true
|
||||
return authentication
|
||||
}
|
||||
|
||||
fun isRemembered(): Boolean {
|
||||
return rememberCheckBox.isSelected
|
||||
}
|
||||
|
||||
fun getUsername(): String {
|
||||
return usernameTextField.text
|
||||
}
|
||||
|
||||
}
|
||||
201
src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt
Normal file
201
src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt
Normal file
@@ -0,0 +1,201 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.keymgr.KeyManager
|
||||
import app.termora.keymgr.OhKeyPairKeyPairProvider
|
||||
import app.termora.terminal.*
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.io.Charsets
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.sshd.client.ClientBuilder
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter
|
||||
import org.apache.sshd.common.util.net.SshdSocketAddress
|
||||
import java.awt.event.KeyEvent
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import javax.swing.Icon
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) {
|
||||
private val keyManager by lazy { KeyManager.getInstance() }
|
||||
private val tempFiles = mutableListOf<Path>()
|
||||
private var sshClient: SshClient? = null
|
||||
private var sshSession: ClientSession? = null
|
||||
private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null
|
||||
private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand
|
||||
|
||||
companion object {
|
||||
val canSupports by lazy {
|
||||
val process = if (SystemInfo.isWindows) {
|
||||
ProcessBuilder("cmd.exe", "/c", "where", "sftp").start()
|
||||
} else {
|
||||
ProcessBuilder("which", "sftp").start()
|
||||
}
|
||||
process.waitFor()
|
||||
return@lazy process.exitValue() == 0
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun openPtyConnector(): PtyConnector {
|
||||
|
||||
val useJumpHosts = host.options.jumpHosts.isNotEmpty() || host.proxy.type != ProxyType.No
|
||||
val commands = mutableListOf(StringUtils.defaultIfBlank(sftpCommand, "sftp"))
|
||||
var host = this.host
|
||||
|
||||
// 如果配置了跳板机或者代理,那么通过 SSH 的端口转发到本地
|
||||
if (useJumpHosts) {
|
||||
host = host.copy(
|
||||
updateDate = System.currentTimeMillis(),
|
||||
tunnelings = listOf(
|
||||
Tunneling(
|
||||
type = TunnelingType.Local,
|
||||
sourceHost = SshdSocketAddress.LOCALHOST_NAME,
|
||||
destinationHost = SshdSocketAddress.LOCALHOST_NAME,
|
||||
destinationPort = host.port,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val sshClient = SshClients.openClient(host).apply { sshClient = this }
|
||||
val sshSession = SshClients.openSession(host, sshClient).apply { sshSession = this }
|
||||
|
||||
// 打开通道
|
||||
for (tunneling in host.tunnelings) {
|
||||
val address = SshClients.openTunneling(sshSession, host, tunneling)
|
||||
host = host.copy(
|
||||
host = address.hostName,
|
||||
port = address.port,
|
||||
updateDate = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (useJumpHosts) {
|
||||
// 打开通道后忽略 key 检查
|
||||
commands.add("-o")
|
||||
commands.add("StrictHostKeyChecking=no")
|
||||
|
||||
// 不保存 known_hosts
|
||||
commands.add("-o")
|
||||
commands.add("UserKnownHostsFile=${if (SystemInfo.isWindows) "NUL" else "/dev/null"}")
|
||||
} else {
|
||||
// known_hosts
|
||||
commands.add("-o")
|
||||
commands.add("UserKnownHostsFile=${File(Application.getBaseDataDir(), "known_hosts").absolutePath}")
|
||||
}
|
||||
|
||||
// Compression
|
||||
commands.add("-o")
|
||||
commands.add("Compression=yes")
|
||||
|
||||
// HostKeyAlgorithms 让 SFTP 命令的顺序和 sshd 的一致 这样可以避免 known_hosts 文件不一致问题
|
||||
val hostKeyAlgorithms = ClientBuilder.setUpDefaultSignatureFactories(true).joinToString(",") { it.name }
|
||||
commands.add("-o")
|
||||
commands.add("HostKeyAlgorithms=${hostKeyAlgorithms}")
|
||||
|
||||
// 不使用配置文件
|
||||
commands.add("-F")
|
||||
commands.add("/dev/null")
|
||||
|
||||
// port
|
||||
commands.add("-P")
|
||||
commands.add(host.port.toString())
|
||||
|
||||
// 设置认证信息
|
||||
setAuthentication(commands, host)
|
||||
|
||||
|
||||
val envs = host.options.envs()
|
||||
if (envs.containsKey("CurrentDir")) {
|
||||
val currentDir = envs.getValue("CurrentDir")
|
||||
commands.add("${host.username}@${host.host}:${currentDir}")
|
||||
} else {
|
||||
commands.add("${host.username}@${host.host}")
|
||||
}
|
||||
|
||||
val winSize = terminalPanel.winSize()
|
||||
val ptyConnector = ptyConnectorFactory.createPtyConnector(
|
||||
commands.toTypedArray(),
|
||||
winSize.rows, winSize.cols,
|
||||
host.options.envs(),
|
||||
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
|
||||
)
|
||||
|
||||
return ptyConnector
|
||||
}
|
||||
|
||||
private fun setAuthentication(commands: MutableList<String>, host: Host) {
|
||||
// 如果通过公钥连接
|
||||
if (host.authentication.type == AuthenticationType.PublicKey) {
|
||||
val ohKeyPair = keyManager.getOhKeyPair(host.authentication.password)
|
||||
if (ohKeyPair != null) {
|
||||
val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(ohKeyPair)
|
||||
val privateKeyPath = Application.createSubTemporaryDir()
|
||||
val privateKeyFile = Files.createTempFile(privateKeyPath, Application.getName(), StringUtils.EMPTY)
|
||||
Files.newOutputStream(privateKeyFile)
|
||||
.use { OpenSSHKeyPairResourceWriter.INSTANCE.writePrivateKey(keyPair, null, null, it) }
|
||||
commands.add("-i")
|
||||
commands.add(privateKeyFile.toFile().absolutePath)
|
||||
tempFiles.add(privateKeyPath)
|
||||
}
|
||||
} else if (host.authentication.type == AuthenticationType.Password) {
|
||||
terminal.getTerminalModel().addDataListener(PasswordReporterDataListener(host).apply {
|
||||
lastPasswordReporterDataListener = this
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
// 删除密码监听
|
||||
lastPasswordReporterDataListener?.let { listener ->
|
||||
SwingUtilities.invokeLater { terminal.getTerminalModel().removeDataListener(listener) }
|
||||
}
|
||||
|
||||
IOUtils.closeQuietly(sshSession)
|
||||
IOUtils.closeQuietly(sshClient)
|
||||
|
||||
tempFiles.removeIf {
|
||||
FileUtils.deleteQuietly(it.toFile())
|
||||
true
|
||||
}
|
||||
|
||||
super.stop()
|
||||
}
|
||||
|
||||
override fun getIcon(): Icon {
|
||||
return Icons.fileFormat
|
||||
}
|
||||
|
||||
private inner class PasswordReporterDataListener(private val host: Host) : DataListener {
|
||||
override fun onChanged(key: DataKey<*>, data: Any) {
|
||||
if (key == VisualTerminal.Written && data is String) {
|
||||
|
||||
// 要求输入密码
|
||||
val line = terminal.getDocument().getScreenLine(terminal.getCursorModel().getPosition().y)
|
||||
if (line.getText().trim().trimIndent().startsWith("${host.username}@${host.host}'s password:")) {
|
||||
|
||||
// 删除密码监听
|
||||
terminal.getTerminalModel().removeDataListener(this)
|
||||
|
||||
val ptyConnector = getPtyConnector()
|
||||
|
||||
// password
|
||||
ptyConnector.write(host.authentication.password.toByteArray(ptyConnector.getCharset()))
|
||||
|
||||
// enter
|
||||
ptyConnector.write(
|
||||
terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
|
||||
.toByteArray(ptyConnector.getCharset())
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.transport.TransportDataProviders
|
||||
import app.termora.transport.TransportPanel
|
||||
import java.beans.PropertyChangeListener
|
||||
@@ -8,12 +10,13 @@ import javax.swing.JComponent
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class SFTPTerminalTab : Disposable, TerminalTab {
|
||||
class SFTPTerminalTab : Disposable, TerminalTab, DataProvider {
|
||||
|
||||
private val transportPanel by lazy {
|
||||
TransportPanel().apply {
|
||||
Disposer.register(this@SFTPTerminalTab, this)
|
||||
}
|
||||
private val sftp get() = Database.getDatabase().sftp
|
||||
private val transportPanel = TransportPanel()
|
||||
|
||||
init {
|
||||
Disposer.register(this, transportPanel)
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
@@ -41,6 +44,11 @@ class SFTPTerminalTab : Disposable, TerminalTab {
|
||||
|
||||
override fun canClose(): Boolean {
|
||||
assertEventDispatchThread()
|
||||
|
||||
if (sftp.pinTab) {
|
||||
return false
|
||||
}
|
||||
|
||||
val transportManager = transportPanel.getData(TransportDataProviders.TransportManager) ?: return true
|
||||
if (transportManager.getTransports().isEmpty()) {
|
||||
return true
|
||||
@@ -54,4 +62,12 @@ class SFTPTerminalTab : Disposable, TerminalTab {
|
||||
) == JOptionPane.OK_OPTION
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == TransportDataProviders.TransportPanel) {
|
||||
return transportPanel as T
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -24,23 +26,30 @@ import org.apache.sshd.common.channel.ChannelListener
|
||||
import org.apache.sshd.common.session.Session
|
||||
import org.apache.sshd.common.session.SessionListener
|
||||
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.*
|
||||
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)
|
||||
val SSHSession = DataKey(ClientSession::class)
|
||||
|
||||
private val log = LoggerFactory.getLogger(SSHTerminalTab::class.java)
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val tab = this
|
||||
|
||||
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
|
||||
@@ -80,9 +89,34 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
|
||||
terminal.write("SSH client is opening...\r\n")
|
||||
}
|
||||
|
||||
var host =
|
||||
this.host.copy(authentication = this.host.authentication.copy(), updateDate = System.currentTimeMillis())
|
||||
val owner = SwingUtilities.getWindowAncestor(terminalPanel)
|
||||
val client = SshClients.openClient(host).also { sshClient = it }
|
||||
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
|
||||
// keyboard interactive
|
||||
client.userInteraction = TerminalUserInteraction(SwingUtilities.getWindowAncestor(terminalPanel))
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
|
||||
if (host.authentication.type == AuthenticationType.No) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
val dialog = RequestAuthenticationDialog(owner, host)
|
||||
val authentication = dialog.getAuthentication()
|
||||
host = host.copy(
|
||||
authentication = authentication,
|
||||
username = dialog.getUsername(),
|
||||
updateDate = System.currentTimeMillis(),
|
||||
)
|
||||
// save
|
||||
if (dialog.isRemembered()) {
|
||||
HostManager.getInstance().addHost(
|
||||
tab.host.copy(
|
||||
authentication = authentication,
|
||||
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sessionListener = MySessionListener()
|
||||
val channelListener = MyChannelListener()
|
||||
@@ -119,13 +153,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(tab, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -159,35 +205,30 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
|
||||
}
|
||||
|
||||
for (tunneling in host.tunnelings) {
|
||||
if (tunneling.type == TunnelingType.Local) {
|
||||
session.startLocalPortForwarding(
|
||||
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
|
||||
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort)
|
||||
)
|
||||
} else if (tunneling.type == TunnelingType.Remote) {
|
||||
session.startRemotePortForwarding(
|
||||
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
|
||||
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort),
|
||||
)
|
||||
} else if (tunneling.type == TunnelingType.Dynamic) {
|
||||
session.startDynamicPortForwarding(
|
||||
SshdSocketAddress(
|
||||
tunneling.sourceHost,
|
||||
tunneling.sourcePort
|
||||
)
|
||||
)
|
||||
try {
|
||||
SshClients.openTunneling(session, host, tunneling)
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error("Start [${tunneling.name}] port forwarding failed: {}", e.message, e)
|
||||
}
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("Start [${tunneling.name}] port forwarding failed: ${e.message}\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("SSH [{}] started {} port forwarding.", host.name, tunneling.name)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == SSHSession) {
|
||||
return sshSession as T?
|
||||
}
|
||||
return super.getData(dataKey)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (mutex.tryLock()) {
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import javax.swing.event.TreeModelEvent
|
||||
import javax.swing.event.TreeModelListener
|
||||
import javax.swing.tree.TreeModel
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
class SearchableHostTreeModel(
|
||||
private val model: HostTreeModel,
|
||||
private val filter: (host: Host) -> Boolean = { true }
|
||||
) : TreeModel {
|
||||
private var text = String()
|
||||
|
||||
override fun getRoot(): Any {
|
||||
return model.root
|
||||
}
|
||||
|
||||
override fun getChild(parent: Any?, index: Int): Any {
|
||||
return getChildren(parent)[index]
|
||||
}
|
||||
|
||||
override fun getChildCount(parent: Any?): Int {
|
||||
return getChildren(parent).size
|
||||
}
|
||||
|
||||
override fun isLeaf(node: Any?): Boolean {
|
||||
return model.isLeaf(node)
|
||||
}
|
||||
|
||||
override fun valueForPathChanged(path: TreePath?, newValue: Any?) {
|
||||
return model.valueForPathChanged(path, newValue)
|
||||
}
|
||||
|
||||
override fun getIndexOfChild(parent: Any?, child: Any?): Int {
|
||||
return getChildren(parent).indexOf(child)
|
||||
}
|
||||
|
||||
override fun addTreeModelListener(l: TreeModelListener) {
|
||||
model.addTreeModelListener(l)
|
||||
}
|
||||
|
||||
override fun removeTreeModelListener(l: TreeModelListener) {
|
||||
model.removeTreeModelListener(l)
|
||||
}
|
||||
|
||||
|
||||
private fun getChildren(parent: Any?): List<Host> {
|
||||
val children = model.getChildren(parent)
|
||||
if (children.isEmpty()) return emptyList()
|
||||
return children.filter { e ->
|
||||
filter.invoke(e)
|
||||
&& e.name.contains(text, true)
|
||||
|| e.host.contains(text, true)
|
||||
|| TreeUtils.children(model, e, true).filterIsInstance<Host>().any { it.name.contains(text, true) || it.host.contains(text, true) }
|
||||
}
|
||||
}
|
||||
|
||||
fun search(text: String) {
|
||||
this.text = text
|
||||
model.listeners.forEach {
|
||||
it.treeStructureChanged(
|
||||
TreeModelEvent(
|
||||
this, TreePath(root),
|
||||
null, null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
61
src/main/kotlin/app/termora/SerialPortPtyConnector.kt
Normal file
61
src/main/kotlin/app/termora/SerialPortPtyConnector.kt
Normal file
@@ -0,0 +1,61 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.terminal.PtyConnector
|
||||
import com.fazecast.jSerialComm.SerialPort
|
||||
import com.fazecast.jSerialComm.SerialPortDataListener
|
||||
import com.fazecast.jSerialComm.SerialPortEvent
|
||||
import java.nio.charset.Charset
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SerialPortPtyConnector(
|
||||
private val serialPort: SerialPort,
|
||||
private val charset: Charset = Charsets.UTF_8
|
||||
) : PtyConnector, SerialPortDataListener {
|
||||
|
||||
private val queue = LinkedBlockingQueue<Char>()
|
||||
|
||||
init {
|
||||
serialPort.addDataListener(this)
|
||||
}
|
||||
|
||||
override fun read(buffer: CharArray): Int {
|
||||
buffer[0] = queue.poll(1, TimeUnit.SECONDS) ?: return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
override fun write(buffer: ByteArray, offset: Int, len: Int) {
|
||||
serialPort.writeBytes(buffer, len, offset)
|
||||
}
|
||||
|
||||
override fun resize(rows: Int, cols: Int) {
|
||||
|
||||
}
|
||||
|
||||
override fun waitFor(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
queue.clear()
|
||||
serialPort.closePort()
|
||||
}
|
||||
|
||||
override fun getListeningEvents(): Int {
|
||||
return SerialPort.LISTENING_EVENT_DATA_RECEIVED
|
||||
}
|
||||
|
||||
override fun serialEvent(event: SerialPortEvent) {
|
||||
if (event.eventType == SerialPort.LISTENING_EVENT_DATA_RECEIVED) {
|
||||
val data = event.receivedData
|
||||
if (data.isEmpty()) return
|
||||
for (c in String(data, charset).toCharArray()) {
|
||||
queue.add(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCharset(): Charset {
|
||||
return charset
|
||||
}
|
||||
}
|
||||
21
src/main/kotlin/app/termora/SerialTerminalTab.kt
Normal file
21
src/main/kotlin/app/termora/SerialTerminalTab.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.terminal.PtyConnector
|
||||
import org.apache.commons.io.Charsets
|
||||
import java.nio.charset.StandardCharsets
|
||||
import javax.swing.Icon
|
||||
|
||||
class SerialTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
PtyHostTerminalTab(windowScope, host) {
|
||||
override suspend fun openPtyConnector(): PtyConnector {
|
||||
val serialPort = Serials.openPort(host)
|
||||
return SerialPortPtyConnector(
|
||||
serialPort,
|
||||
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getIcon(): Icon {
|
||||
return Icons.plugin
|
||||
}
|
||||
}
|
||||
38
src/main/kotlin/app/termora/Serials.kt
Normal file
38
src/main/kotlin/app/termora/Serials.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
package app.termora
|
||||
|
||||
import com.fazecast.jSerialComm.SerialPort
|
||||
|
||||
object Serials {
|
||||
fun openPort(host: Host): SerialPort {
|
||||
val serialComm = host.options.serialComm
|
||||
val serialPort = SerialPort.getCommPort(serialComm.port)
|
||||
serialPort.setBaudRate(serialComm.baudRate)
|
||||
serialPort.setNumDataBits(serialComm.dataBits)
|
||||
|
||||
when (serialComm.parity) {
|
||||
SerialCommParity.None -> serialPort.setParity(SerialPort.NO_PARITY)
|
||||
SerialCommParity.Mark -> serialPort.setParity(SerialPort.MARK_PARITY)
|
||||
SerialCommParity.Even -> serialPort.setParity(SerialPort.EVEN_PARITY)
|
||||
SerialCommParity.Odd -> serialPort.setParity(SerialPort.ODD_PARITY)
|
||||
SerialCommParity.Space -> serialPort.setParity(SerialPort.SPACE_PARITY)
|
||||
}
|
||||
|
||||
when (serialComm.stopBits) {
|
||||
"1" -> serialPort.setNumStopBits(SerialPort.ONE_STOP_BIT)
|
||||
"1.5" -> serialPort.setNumStopBits(SerialPort.ONE_POINT_FIVE_STOP_BITS)
|
||||
"2" -> serialPort.setNumStopBits(SerialPort.TWO_STOP_BITS)
|
||||
}
|
||||
|
||||
when (serialComm.flowControl) {
|
||||
SerialCommFlowControl.None -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED)
|
||||
SerialCommFlowControl.RTS_CTS -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_RTS_ENABLED or SerialPort.FLOW_CONTROL_CTS_ENABLED)
|
||||
SerialCommFlowControl.XON_XOFF -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_XONXOFF_IN_ENABLED or SerialPort.FLOW_CONTROL_XONXOFF_OUT_ENABLED)
|
||||
}
|
||||
|
||||
if (!serialPort.openPort()) {
|
||||
throw IllegalStateException("Open serial port [${serialComm.port}] failed")
|
||||
}
|
||||
|
||||
return serialPort
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,15 @@ import app.termora.AES.encodeBase64String
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.highlight.KeywordHighlight
|
||||
import app.termora.highlight.KeywordHighlightManager
|
||||
import app.termora.keymap.Keymap
|
||||
import app.termora.keymap.KeymapManager
|
||||
import app.termora.keymap.KeymapPanel
|
||||
import app.termora.keymgr.KeyManager
|
||||
import app.termora.keymgr.OhKeyPair
|
||||
import app.termora.macro.Macro
|
||||
import app.termora.macro.MacroManager
|
||||
import app.termora.native.FileChooser
|
||||
import app.termora.sync.SyncConfig
|
||||
@@ -15,10 +21,14 @@ import app.termora.sync.SyncType
|
||||
import app.termora.sync.SyncerProvider
|
||||
import app.termora.terminal.CursorStyle
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
import app.termora.transport.SFTPAction
|
||||
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
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
@@ -26,18 +36,20 @@ import com.jthemedetecor.OsThemeDetector
|
||||
import com.sun.jna.LastErrorException
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.*
|
||||
import org.apache.commons.codec.binary.Base64
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import org.jdesktop.swingx.JXEditorPane
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.awt.event.ItemEvent
|
||||
import java.io.File
|
||||
@@ -54,6 +66,12 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
class SettingsOptionsPane : OptionsPane() {
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(this@SettingsOptionsPane)
|
||||
private val database get() = Database.getDatabase()
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
private val keymapManager get() = KeymapManager.getInstance()
|
||||
private val macroManager get() = MacroManager.getInstance()
|
||||
private val actionManager get() = ActionManager.getInstance()
|
||||
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
|
||||
private val keyManager get() = KeyManager.getInstance()
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SettingsOptionsPane::class.java)
|
||||
@@ -97,6 +115,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
addOption(AppearanceOption())
|
||||
addOption(TerminalOption())
|
||||
addOption(KeyShortcutsOption())
|
||||
addOption(SFTPOption())
|
||||
addOption(CloudSyncOption())
|
||||
addOption(DoormanOption())
|
||||
addOption(AboutOption())
|
||||
@@ -179,12 +198,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
appearance.language = languageComboBox.selectedItem as String
|
||||
SwingUtilities.invokeLater {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.settings.restart.message"),
|
||||
I18n.getString("termora.settings.restart.title"),
|
||||
messageType = JOptionPane.INFORMATION_MESSAGE,
|
||||
)
|
||||
TermoraRestarter.getInstance().scheduleRestart(owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,12 +302,16 @@ 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 cursorBlinkComboBox = 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()
|
||||
private val floatingToolbarComboBox = YesOrNoComboBox()
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -309,6 +327,26 @@ 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")
|
||||
|
||||
floatingToolbarComboBox.addItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.floatingToolbar = floatingToolbarComboBox.selectedItem as Boolean
|
||||
TerminalPanelFactory.getAllTerminalPanel().forEach { tp ->
|
||||
if (terminalSetting.floatingToolbar && FloatingToolbarPanel.isPined) {
|
||||
tp.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerShow()
|
||||
} else {
|
||||
tp.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerHide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectCopyComboBox.addItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.selectCopy = selectCopyComboBox.selectedItem as Boolean
|
||||
@@ -345,6 +383,19 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
|
||||
|
||||
beepComboBox.addItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.beep = beepComboBox.selectedItem as Boolean
|
||||
}
|
||||
}
|
||||
|
||||
cursorBlinkComboBox.addItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.cursorBlink = cursorBlinkComboBox.selectedItem as Boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
shellComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.localShell = shellComboBox.selectedItem as String
|
||||
@@ -379,6 +430,33 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
fontComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
init {
|
||||
preferredSize = Dimension(preferredSize.width, fontComboBox.preferredSize.height - 2)
|
||||
maximumSize = Dimension(preferredSize.width, preferredSize.height)
|
||||
}
|
||||
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
if (value is String) {
|
||||
return super.getListCellRendererComponent(
|
||||
list,
|
||||
"<html><font face='$value'>$value</font></html>",
|
||||
index,
|
||||
isSelected,
|
||||
cellHasFocus
|
||||
)
|
||||
}
|
||||
return super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
|
||||
}
|
||||
}
|
||||
fontComboBox.maximumSize = fontComboBox.preferredSize
|
||||
|
||||
cursorStyleComboBox.addItem(CursorStyle.Block)
|
||||
cursorStyleComboBox.addItem(CursorStyle.Bar)
|
||||
cursorStyleComboBox.addItem(CursorStyle.Underline)
|
||||
@@ -391,13 +469,25 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
shellComboBox.selectedItem = terminalSetting.localShell
|
||||
|
||||
fontComboBox.addItem("JetBrains Mono")
|
||||
fontComboBox.addItem("Source Code Pro")
|
||||
val fonts = linkedSetOf<String>("JetBrains Mono", "Source Code Pro", "Monospaced")
|
||||
FontUtils.getAllFonts().forEach {
|
||||
if (!fonts.contains(it.family)) {
|
||||
fonts.addLast(it.family)
|
||||
}
|
||||
}
|
||||
|
||||
for (font in fonts) {
|
||||
fontComboBox.addItem(font)
|
||||
}
|
||||
|
||||
fontComboBox.selectedItem = terminalSetting.font
|
||||
debugComboBox.selectedItem = terminalSetting.debug
|
||||
beepComboBox.selectedItem = terminalSetting.beep
|
||||
cursorBlinkComboBox.selectedItem = terminalSetting.cursorBlink
|
||||
cursorStyleComboBox.selectedItem = terminalSetting.cursor
|
||||
selectCopyComboBox.selectedItem = terminalSetting.selectCopy
|
||||
autoCloseTabComboBox.selectedItem = terminalSetting.autoCloseTabWhenDisconnected
|
||||
floatingToolbarComboBox.selectedItem = terminalSetting.floatingToolbar
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
@@ -415,9 +505,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, $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)
|
||||
@@ -430,10 +525,19 @@ 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.cursor-blink")}:").xy(1, rows)
|
||||
.add(cursorBlinkComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.floating-toolbar")}:").xy(1, rows)
|
||||
.add(floatingToolbarComboBox).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()
|
||||
@@ -451,6 +555,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
val domainTextField = OutlineTextField(255)
|
||||
val uploadConfigButton = JButton(I18n.getString("termora.settings.sync.push"), Icons.upload)
|
||||
val exportConfigButton = JButton(I18n.getString("termora.settings.sync.export"), Icons.export)
|
||||
val importConfigButton = JButton(I18n.getString("termora.settings.sync.import"), Icons.import)
|
||||
val downloadConfigButton = JButton(I18n.getString("termora.settings.sync.pull"), Icons.download)
|
||||
val lastSyncTimeLabel = JLabel()
|
||||
val sync get() = database.sync
|
||||
@@ -492,12 +597,6 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeComboBox.selectedItem == SyncType.Gitee) {
|
||||
gistTextField.trailingComponent = null
|
||||
} else {
|
||||
gistTextField.trailingComponent = visitGistBtn
|
||||
}
|
||||
|
||||
removeAll()
|
||||
add(getCenterComponent(), BorderLayout.CENTER)
|
||||
revalidate()
|
||||
@@ -562,6 +661,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
|
||||
exportConfigButton.addActionListener { export() }
|
||||
importConfigButton.addActionListener { import() }
|
||||
|
||||
keysCheckBox.addActionListener { refreshButtons() }
|
||||
hostsCheckBox.addActionListener { refreshButtons() }
|
||||
@@ -578,24 +678,228 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|| keywordHighlightsCheckBox.isSelected
|
||||
uploadConfigButton.isEnabled = downloadConfigButton.isEnabled
|
||||
exportConfigButton.isEnabled = downloadConfigButton.isEnabled
|
||||
importConfigButton.isEnabled = downloadConfigButton.isEnabled
|
||||
}
|
||||
|
||||
private fun export() {
|
||||
|
||||
assertEventDispatchThread()
|
||||
|
||||
val passwordField = OutlinePasswordField()
|
||||
val panel = object : JPanel(BorderLayout()) {
|
||||
override fun requestFocusInWindow(): Boolean {
|
||||
return passwordField.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
|
||||
val label = JLabel(I18n.getString("termora.settings.sync.export-encrypt") + StringUtils.SPACE.repeat(25))
|
||||
label.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
|
||||
panel.add(label, BorderLayout.NORTH)
|
||||
panel.add(passwordField, BorderLayout.CENTER)
|
||||
|
||||
var password = StringUtils.EMPTY
|
||||
|
||||
if (OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
panel,
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
initialValue = passwordField
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
password = String(passwordField.password).trim()
|
||||
}
|
||||
|
||||
|
||||
val fileChooser = FileChooser()
|
||||
fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY
|
||||
fileChooser.win32Filters.add(Pair("All Files", listOf("*")))
|
||||
fileChooser.win32Filters.add(Pair("JSON files", listOf("json")))
|
||||
fileChooser.showSaveDialog(owner, "${Application.getName()}.json").thenAccept { file ->
|
||||
if (file != null) {
|
||||
SwingUtilities.invokeLater { exportText(file) }
|
||||
SwingUtilities.invokeLater { exportText(file, password) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportText(file: File) {
|
||||
private fun import() {
|
||||
val fileChooser = FileChooser()
|
||||
fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY
|
||||
fileChooser.osxAllowedFileTypes = listOf("json")
|
||||
fileChooser.win32Filters.add(Pair("JSON files", listOf("json")))
|
||||
fileChooser.showOpenDialog(owner).thenAccept { files ->
|
||||
if (files.isNotEmpty()) {
|
||||
SwingUtilities.invokeLater { importFromFile(files.first()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
private fun importFromFile(file: File) {
|
||||
if (!file.exists()) {
|
||||
return
|
||||
}
|
||||
|
||||
val ranges = getSyncConfig().ranges
|
||||
if (ranges.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 最大 100MB
|
||||
if (file.length() >= 1024 * 1024 * 100) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, I18n.getString("termora.settings.sync.import.file-too-large"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val text = file.readText()
|
||||
val jsonResult = ohMyJson.runCatching { decodeFromString<JsonObject>(text) }
|
||||
if (jsonResult.isFailure) {
|
||||
val e = jsonResult.exceptionOrNull() ?: return
|
||||
OptionPane.showMessageDialog(
|
||||
owner, ExceptionUtils.getRootCauseMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var json = jsonResult.getOrNull() ?: return
|
||||
|
||||
// 如果加密了 则解密数据
|
||||
if (json["encryption"]?.jsonPrimitive?.booleanOrNull == true) {
|
||||
val data = json["data"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
|
||||
if (data.isBlank()) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, "Data file corruption",
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val passwordField = OutlinePasswordField()
|
||||
val panel = object : JPanel(BorderLayout()) {
|
||||
override fun requestFocusInWindow(): Boolean {
|
||||
return passwordField.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
|
||||
val label = JLabel("Please enter the password" + StringUtils.SPACE.repeat(25))
|
||||
label.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
|
||||
panel.add(label, BorderLayout.NORTH)
|
||||
panel.add(passwordField, BorderLayout.CENTER)
|
||||
|
||||
if (OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
panel,
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
initialValue = passwordField
|
||||
) != JOptionPane.YES_OPTION
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordField.password.isEmpty()) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, I18n.getString("termora.doorman.unlock-data"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
val password = String(passwordField.password)
|
||||
val key = PBKDF2.generateSecret(
|
||||
password.toCharArray(),
|
||||
password.toByteArray(), keyLength = 128
|
||||
)
|
||||
|
||||
try {
|
||||
val dataText = AES.ECB.decrypt(key, Base64.decodeBase64(data)).toString(Charsets.UTF_8)
|
||||
val dataJsonResult = ohMyJson.runCatching { decodeFromString<JsonObject>(dataText) }
|
||||
if (dataJsonResult.isFailure) {
|
||||
val e = dataJsonResult.exceptionOrNull() ?: return
|
||||
OptionPane.showMessageDialog(
|
||||
owner, ExceptionUtils.getRootCauseMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
json = dataJsonResult.getOrNull() ?: return
|
||||
break
|
||||
} catch (_: Exception) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, I18n.getString("termora.doorman.password-wrong"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.contains(SyncRange.Hosts)) {
|
||||
val hosts = json["hosts"]
|
||||
if (hosts is JsonArray) {
|
||||
ohMyJson.runCatching { decodeFromJsonElement<List<Host>>(hosts.jsonArray) }.onSuccess {
|
||||
for (host in it) {
|
||||
hostManager.addHost(host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.contains(SyncRange.KeyPairs)) {
|
||||
val keyPairs = json["keyPairs"]
|
||||
if (keyPairs is JsonArray) {
|
||||
ohMyJson.runCatching { decodeFromJsonElement<List<OhKeyPair>>(keyPairs.jsonArray) }.onSuccess {
|
||||
for (keyPair in it) {
|
||||
keyManager.addOhKeyPair(keyPair)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.contains(SyncRange.KeywordHighlights)) {
|
||||
val keywordHighlights = json["keywordHighlights"]
|
||||
if (keywordHighlights is JsonArray) {
|
||||
ohMyJson.runCatching { decodeFromJsonElement<List<KeywordHighlight>>(keywordHighlights.jsonArray) }
|
||||
.onSuccess {
|
||||
for (keyPair in it) {
|
||||
keywordHighlightManager.addKeywordHighlight(keyPair)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.contains(SyncRange.Macros)) {
|
||||
val macros = json["macros"]
|
||||
if (macros is JsonArray) {
|
||||
ohMyJson.runCatching { decodeFromJsonElement<List<Macro>>(macros.jsonArray) }.onSuccess {
|
||||
for (macro in it) {
|
||||
macroManager.addMacro(macro)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.contains(SyncRange.Keymap)) {
|
||||
val keymaps = json["keymaps"]
|
||||
if (keymaps is JsonArray) {
|
||||
for (keymap in keymaps.jsonArray.mapNotNull { Keymap.fromJSON(it.jsonObject) }) {
|
||||
keymapManager.addKeymap(keymap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionPane.showMessageDialog(
|
||||
owner, I18n.getString("termora.settings.sync.import.successful"),
|
||||
messageType = JOptionPane.INFORMATION_MESSAGE
|
||||
)
|
||||
}
|
||||
|
||||
private fun exportText(file: File, password: String) {
|
||||
val syncConfig = getSyncConfig()
|
||||
val text = ohMyJson.encodeToString(buildJsonObject {
|
||||
var text = ohMyJson.encodeToString(buildJsonObject {
|
||||
val now = System.currentTimeMillis()
|
||||
put("exporter", SystemUtils.USER_NAME)
|
||||
put("version", Application.getVersion())
|
||||
@@ -603,21 +907,29 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
put("os", SystemUtils.OS_NAME)
|
||||
put("exportDateHuman", DateFormatUtils.ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.format(Date(now)))
|
||||
if (syncConfig.ranges.contains(SyncRange.Hosts)) {
|
||||
put("hosts", ohMyJson.encodeToJsonElement(HostManager.getInstance().hosts()))
|
||||
put("hosts", ohMyJson.encodeToJsonElement(hostManager.hosts()))
|
||||
}
|
||||
if (syncConfig.ranges.contains(SyncRange.KeyPairs)) {
|
||||
put("keyPairs", ohMyJson.encodeToJsonElement(KeyManager.getInstance().getOhKeyPairs()))
|
||||
put("keyPairs", ohMyJson.encodeToJsonElement(keyManager.getOhKeyPairs()))
|
||||
}
|
||||
if (syncConfig.ranges.contains(SyncRange.KeywordHighlights)) {
|
||||
put(
|
||||
"keywordHighlights",
|
||||
ohMyJson.encodeToJsonElement(KeywordHighlightManager.getInstance().getKeywordHighlights())
|
||||
ohMyJson.encodeToJsonElement(keywordHighlightManager.getKeywordHighlights())
|
||||
)
|
||||
}
|
||||
if (syncConfig.ranges.contains(SyncRange.Macros)) {
|
||||
put(
|
||||
"macros",
|
||||
ohMyJson.encodeToJsonElement(MacroManager.getInstance().getMacros())
|
||||
ohMyJson.encodeToJsonElement(macroManager.getMacros())
|
||||
)
|
||||
}
|
||||
if (syncConfig.ranges.contains(SyncRange.Keymap)) {
|
||||
val keymaps = keymapManager.getKeymaps().filter { !it.isReadonly }
|
||||
.map { it.toJSONObject() }
|
||||
put(
|
||||
"keymaps",
|
||||
ohMyJson.encodeToJsonElement(keymaps)
|
||||
)
|
||||
}
|
||||
put("settings", buildJsonObject {
|
||||
@@ -626,6 +938,19 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
put("terminal", ohMyJson.encodeToJsonElement(database.terminal.getProperties()))
|
||||
})
|
||||
})
|
||||
|
||||
if (password.isNotBlank()) {
|
||||
val key = PBKDF2.generateSecret(
|
||||
password.toCharArray(),
|
||||
password.toByteArray(), keyLength = 128
|
||||
)
|
||||
|
||||
text = ohMyJson.encodeToString(buildJsonObject {
|
||||
put("encryption", true)
|
||||
put("data", AES.ECB.encrypt(key, text.toByteArray(Charsets.UTF_8)).encodeBase64String())
|
||||
})
|
||||
}
|
||||
|
||||
file.outputStream().use {
|
||||
IOUtils.write(text, it, StandardCharsets.UTF_8)
|
||||
OptionPane.openFileInFolder(
|
||||
@@ -662,6 +987,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
private suspend fun pushOrPull(push: Boolean) {
|
||||
|
||||
if (typeComboBox.selectedItem == SyncType.GitLab) {
|
||||
@@ -717,6 +1043,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
exportConfigButton.isEnabled = false
|
||||
importConfigButton.isEnabled = false
|
||||
downloadConfigButton.isEnabled = false
|
||||
uploadConfigButton.isEnabled = false
|
||||
typeComboBox.isEnabled = false
|
||||
@@ -752,6 +1079,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
withContext(Dispatchers.Swing) {
|
||||
downloadConfigButton.isEnabled = true
|
||||
exportConfigButton.isEnabled = true
|
||||
importConfigButton.isEnabled = true
|
||||
uploadConfigButton.isEnabled = true
|
||||
keysCheckBox.isEnabled = true
|
||||
hostsCheckBox.isEnabled = true
|
||||
@@ -813,6 +1141,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
|
||||
@@ -831,7 +1160,31 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
tokenTextField.text = sync.token
|
||||
domainTextField.trailingComponent = JButton(Icons.externalLink).apply {
|
||||
addActionListener {
|
||||
Application.browse(URI.create("https://docs.gitlab.com/ee/api/snippets.html"))
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,12 +1194,15 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
tokenTextField.trailingComponent = if (tokenTextField.password.isEmpty()) getTokenBtn else null
|
||||
|
||||
if (typeComboBox.selectedItem == SyncType.GitLab) {
|
||||
if (domainTextField.text.isBlank()) {
|
||||
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(
|
||||
@@ -892,29 +1248,50 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
val builder = FormBuilder.create().layout(layout).debug(false);
|
||||
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
|
||||
.add(
|
||||
FormBuilder.create()
|
||||
.layout(FormLayout("left:pref, $formMargin, left:pref, $formMargin, left:pref", "pref"))
|
||||
.layout(FormLayout("pref, 2dlu, pref, 2dlu, pref, 2dlu, pref", "pref"))
|
||||
.add(uploadConfigButton).xy(1, 1)
|
||||
.add(downloadConfigButton).xy(3, 1)
|
||||
.add(exportConfigButton).xy(5, 1)
|
||||
.add(importConfigButton).xy(7, 1)
|
||||
.build()
|
||||
).xy(3, rows, "center, fill").apply { rows += step }
|
||||
.add(lastSyncTimeLabel).xy(3, rows, "center, fill").apply { rows += step }
|
||||
@@ -925,6 +1302,105 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SFTPOption : JPanel(BorderLayout()), Option {
|
||||
|
||||
private val editCommandField = OutlineTextField(255)
|
||||
private val sftpCommandField = OutlineTextField(255)
|
||||
private val pinTabComboBox = YesOrNoComboBox()
|
||||
private val sftp get() = database.sftp
|
||||
private val sftpAction get() = actionManager.getAction(Actions.SFTP) as SFTPAction
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
add(getCenterComponent(), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
editCommandField.document.addDocumentListener(object : DocumentAdaptor() {
|
||||
override fun changedUpdate(e: DocumentEvent) {
|
||||
sftp.editCommand = editCommandField.text
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
sftpCommandField.document.addDocumentListener(object : DocumentAdaptor() {
|
||||
override fun changedUpdate(e: DocumentEvent) {
|
||||
sftp.sftpCommand = sftpCommandField.text
|
||||
}
|
||||
})
|
||||
|
||||
pinTabComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
sftp.pinTab = pinTabComboBox.selectedItem as Boolean
|
||||
for (window in TermoraFrameManager.getInstance().getWindows()) {
|
||||
val evt = AnActionEvent(window, StringUtils.EMPTY, EventObject(window))
|
||||
if (pinTabComboBox.selectedItem == true) {
|
||||
sftpAction.openOrCreateSFTPTerminalTab(evt)
|
||||
}
|
||||
val tabbed = evt.getData(DataProviders.TabbedPane) ?: continue
|
||||
val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue
|
||||
for ((index, tab) in manager.getTerminalTabs().withIndex()) {
|
||||
if (tab is SFTPTerminalTab) {
|
||||
tabbed.setTabClosable(index, pinTabComboBox.selectedItem != true)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun initView() {
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
editCommandField.placeholderText = "notepad {0}"
|
||||
} else if (SystemInfo.isMacOS) {
|
||||
editCommandField.placeholderText = "open -a TextEdit {0}"
|
||||
}
|
||||
|
||||
if (SystemInfo.isWindows) {
|
||||
sftpCommandField.placeholderText = "sftp.exe"
|
||||
} else {
|
||||
sftpCommandField.placeholderText = "sftp"
|
||||
}
|
||||
|
||||
editCommandField.text = sftp.editCommand
|
||||
sftpCommandField.text = sftp.sftpCommand
|
||||
pinTabComboBox.selectedItem = sftp.pinTab
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
return Icons.folder
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
return "SFTP"
|
||||
}
|
||||
|
||||
override fun getJComponent(): JComponent {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun getCenterComponent(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, 30dlu",
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val builder = FormBuilder.create().layout(layout).debug(false)
|
||||
builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, 1)
|
||||
builder.add(pinTabComboBox).xy(3, 1)
|
||||
builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, 3)
|
||||
builder.add(editCommandField).xy(3, 3)
|
||||
builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, 5)
|
||||
builder.add(sftpCommandField).xy(3, 5)
|
||||
|
||||
return builder.build()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private inner class AboutOption : JPanel(BorderLayout()), Option {
|
||||
|
||||
init {
|
||||
@@ -1009,8 +1485,6 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
private val tip = FlatLabel()
|
||||
private val safeBtn = FlatButton()
|
||||
private val doorman get() = Doorman.getInstance()
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
private val keyManager get() = KeyManager.getInstance()
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -1144,7 +1618,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
val key = doorman.work(passwordTextField.password)
|
||||
|
||||
hosts.forEach { hostManager.addHost(it, false) }
|
||||
hosts.forEach { hostManager.addHost(it) }
|
||||
keyPairs.forEach { keyManager.addOhKeyPair(it) }
|
||||
for (e in properties) {
|
||||
for ((k, v) in e.second) {
|
||||
|
||||
@@ -2,26 +2,49 @@ package app.termora
|
||||
|
||||
import app.termora.keymgr.OhKeyPairKeyPairProvider
|
||||
import app.termora.terminal.TerminalSize
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.sshd.client.ClientBuilder
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.channel.ChannelShell
|
||||
import org.apache.sshd.client.channel.ClientChannelEvent
|
||||
import org.apache.sshd.client.config.hosts.HostConfigEntry
|
||||
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
|
||||
import org.apache.sshd.client.config.hosts.KnownHostEntry
|
||||
import org.apache.sshd.client.kex.DHGClient
|
||||
import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier
|
||||
import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor
|
||||
import org.apache.sshd.client.keyverifier.ServerKeyVerifier
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.common.SshException
|
||||
import org.apache.sshd.common.channel.PtyChannelConfiguration
|
||||
import org.apache.sshd.common.config.keys.KeyUtils
|
||||
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
|
||||
import org.apache.sshd.server.forward.RejectAllForwardingFilter
|
||||
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
|
||||
import org.eclipse.jgit.internal.transport.sshd.JGitSshClient
|
||||
import org.eclipse.jgit.transport.CredentialsProvider
|
||||
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
|
||||
import org.eclipse.jgit.transport.sshd.ProxyData
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Window
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.SocketAddress
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.security.PublicKey
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.math.max
|
||||
|
||||
object SshClients {
|
||||
@@ -56,6 +79,34 @@ object SshClients {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一个命令
|
||||
*
|
||||
* @return first: exitCode , second: response
|
||||
*/
|
||||
fun execChannel(
|
||||
session: ClientSession,
|
||||
command: String
|
||||
): Pair<Int, String> {
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
val channel = session.createExecChannel(command)
|
||||
channel.out = baos
|
||||
|
||||
if (channel.open().verify(timeout).await(timeout)) {
|
||||
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout)
|
||||
}
|
||||
|
||||
IOUtils.closeQuietly(channel)
|
||||
|
||||
if (channel.exitStatus == null) {
|
||||
return Pair(-1, baos.toString())
|
||||
}
|
||||
|
||||
return Pair(channel.exitStatus, baos.toString())
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开一个会话
|
||||
*/
|
||||
@@ -86,7 +137,7 @@ object SshClients {
|
||||
val sessions = mutableListOf<ClientSession>()
|
||||
for (i in 0 until jumpHosts.size) {
|
||||
val currentHost = jumpHosts[i]
|
||||
sessions.add(doOpenSession(currentHost, client))
|
||||
sessions.add(doOpenSession(currentHost, client, i != 0))
|
||||
|
||||
// 如果有下一跳
|
||||
if (i < jumpHosts.size - 1) {
|
||||
@@ -100,15 +151,34 @@ object SshClients {
|
||||
log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}")
|
||||
}
|
||||
// 映射完毕之后修改Host和端口
|
||||
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port)
|
||||
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
return sessions.last()
|
||||
}
|
||||
|
||||
private fun doOpenSession(host: Host, client: SshClient): ClientSession {
|
||||
val session = client.connect(host.username, host.host, host.port)
|
||||
fun isMiddleware(session: ClientSession): Boolean {
|
||||
if (session is JGitClientSession) {
|
||||
if (session.hostConfigEntry.properties["Middleware"]?.toBoolean() == true) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param middleware 如果为 true 表示是跳板
|
||||
*/
|
||||
private fun doOpenSession(host: Host, client: SshClient, middleware: Boolean = false): ClientSession {
|
||||
val entry = HostConfigEntry()
|
||||
entry.port = host.port
|
||||
entry.username = host.username
|
||||
entry.hostName = host.host
|
||||
entry.setProperty("Middleware", middleware.toString())
|
||||
|
||||
val session = client.connect(entry)
|
||||
.verify(timeout).session
|
||||
if (host.authentication.type == AuthenticationType.Password) {
|
||||
session.addPasswordIdentity(host.authentication.password)
|
||||
@@ -124,6 +194,41 @@ object SshClients {
|
||||
return session
|
||||
}
|
||||
|
||||
fun openTunneling(session: ClientSession, host: Host, tunneling: Tunneling): SshdSocketAddress {
|
||||
|
||||
val sshdSocketAddress = if (tunneling.type == TunnelingType.Local) {
|
||||
session.startLocalPortForwarding(
|
||||
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
|
||||
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort)
|
||||
)
|
||||
} else if (tunneling.type == TunnelingType.Remote) {
|
||||
session.startRemotePortForwarding(
|
||||
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
|
||||
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort),
|
||||
)
|
||||
} else if (tunneling.type == TunnelingType.Dynamic) {
|
||||
session.startDynamicPortForwarding(
|
||||
SshdSocketAddress(
|
||||
tunneling.sourceHost,
|
||||
tunneling.sourcePort
|
||||
)
|
||||
)
|
||||
} else {
|
||||
SshdSocketAddress.LOCALHOST_ADDRESS
|
||||
}
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info(
|
||||
"SSH [{}] started {} port forwarding. host: {} , port: {}",
|
||||
host.name,
|
||||
tunneling.name,
|
||||
sshdSocketAddress.hostName,
|
||||
sshdSocketAddress.port
|
||||
)
|
||||
}
|
||||
|
||||
return sshdSocketAddress
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开一个客户端
|
||||
@@ -133,6 +238,18 @@ object SshClients {
|
||||
builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE))
|
||||
.factory { JGitSshClient() }
|
||||
|
||||
val keyExchangeFactories = ClientBuilder.setUpDefaultKeyExchanges(true).toMutableList()
|
||||
|
||||
// https://github.com/TermoraDev/termora/issues/123
|
||||
keyExchangeFactories.addAll(
|
||||
listOf(
|
||||
DHGClient.newFactory(BuiltinDHFactories.dhg1),
|
||||
DHGClient.newFactory(BuiltinDHFactories.dhg14),
|
||||
DHGClient.newFactory(BuiltinDHFactories.dhgex),
|
||||
)
|
||||
)
|
||||
builder.keyExchangeFactories(keyExchangeFactories)
|
||||
|
||||
if (host.tunnelings.isEmpty() && host.options.jumpHosts.isEmpty()) {
|
||||
builder.forwardingFilter(RejectAllForwardingFilter.INSTANCE)
|
||||
} else {
|
||||
@@ -142,8 +259,15 @@ 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)
|
||||
|
||||
sshClient.setKeyPasswordProviderFactory { IdentityPasswordProvider(CredentialsProvider.getDefault()) }
|
||||
|
||||
if (host.proxy.type != ProxyType.No) {
|
||||
@@ -169,4 +293,94 @@ object SshClients {
|
||||
sshClient.start()
|
||||
return sshClient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor {
|
||||
override fun verifyServerKey(
|
||||
clientSession: ClientSession,
|
||||
remoteAddress: SocketAddress,
|
||||
serverKey: PublicKey
|
||||
): Boolean {
|
||||
|
||||
if (SshClients.isMiddleware(clientSession)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val result = AtomicBoolean(false)
|
||||
|
||||
SwingUtilities.invokeAndWait {
|
||||
result.set(
|
||||
OptionPane.showConfirmDialog(
|
||||
parentComponent = owner,
|
||||
message = I18n.getString(
|
||||
"termora.host.verify-server-key",
|
||||
remoteAddress.toString().replace("/", StringUtils.EMPTY),
|
||||
KeyUtils.getKeyType(serverKey),
|
||||
KeyUtils.getFingerPrint(serverKey)
|
||||
),
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION,
|
||||
messageType = JOptionPane.WARNING_MESSAGE,
|
||||
) == JOptionPane.OK_OPTION
|
||||
)
|
||||
}
|
||||
|
||||
return result.get()
|
||||
}
|
||||
|
||||
override fun acceptModifiedServerKey(
|
||||
clientSession: ClientSession?,
|
||||
remoteAddress: SocketAddress?,
|
||||
entry: KnownHostEntry?,
|
||||
expected: PublicKey?,
|
||||
actual: PublicKey?
|
||||
): Boolean {
|
||||
val result = AtomicBoolean(false)
|
||||
|
||||
SwingUtilities.invokeAndWait {
|
||||
result.set(
|
||||
OptionPane.showConfirmDialog(
|
||||
parentComponent = owner,
|
||||
message = I18n.getString(
|
||||
"termora.host.modified-server-key",
|
||||
remoteAddress.toString().replace("/", StringUtils.EMPTY),
|
||||
KeyUtils.getKeyType(expected),
|
||||
KeyUtils.getFingerPrint(expected),
|
||||
KeyUtils.getKeyType(actual),
|
||||
KeyUtils.getFingerPrint(actual),
|
||||
),
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION,
|
||||
messageType = JOptionPane.WARNING_MESSAGE,
|
||||
) == JOptionPane.OK_OPTION
|
||||
)
|
||||
}
|
||||
|
||||
return result.get()
|
||||
}
|
||||
}
|
||||
|
||||
class DialogServerKeyVerifier(
|
||||
owner: Window,
|
||||
) : KnownHostsServerKeyVerifier(
|
||||
MyDialogServerKeyVerifier(owner),
|
||||
Paths.get(Application.getBaseDataDir().absolutePath, "known_hosts")
|
||||
) {
|
||||
init {
|
||||
modifiedServerKeyAcceptor = delegateVerifier as ModifiedServerKeyAcceptor
|
||||
}
|
||||
|
||||
override fun updateKnownHostsFile(
|
||||
clientSession: ClientSession?,
|
||||
remoteAddress: SocketAddress?,
|
||||
serverKey: PublicKey?,
|
||||
file: Path?,
|
||||
knownHosts: Collection<HostEntryPair?>?
|
||||
): KnownHostEntry? {
|
||||
if (clientSession is JGitClientSession) {
|
||||
if (SshClients.isMiddleware(clientSession)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,39 +1,67 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.highlight.KeywordHighlightPaintListener
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.Terminal
|
||||
import app.termora.terminal.panel.TerminalHyperlinkPaintListener
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
import kotlinx.coroutines.*
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.awt.event.ComponentListener
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class TerminalPanelFactory {
|
||||
class TerminalPanelFactory : Disposable {
|
||||
private val terminalPanels = mutableListOf<TerminalPanel>()
|
||||
|
||||
companion object {
|
||||
|
||||
private val Factory = DataKey(TerminalPanelFactory::class)
|
||||
|
||||
fun getInstance(scope: Scope): TerminalPanelFactory {
|
||||
return scope.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
|
||||
}
|
||||
|
||||
fun getAllTerminalPanel(): Array<TerminalPanel> {
|
||||
return ApplicationScope.forApplicationScope().windowScopes()
|
||||
.map { getInstance(it) }
|
||||
.flatMap { it.terminalPanels }.toTypedArray()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// repaint
|
||||
Painter.getInstance()
|
||||
}
|
||||
|
||||
|
||||
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
|
||||
val terminalPanel = TerminalPanel(terminal, ptyConnector)
|
||||
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
|
||||
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
|
||||
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
|
||||
terminalPanels.add(terminalPanel)
|
||||
terminal.getTerminalModel().setData(Factory, this)
|
||||
|
||||
Disposer.register(terminalPanel, object : Disposable {
|
||||
override fun dispose() {
|
||||
if (terminal.getTerminalModel().hasData(Factory)) {
|
||||
terminal.getTerminalModel().getData(Factory).removeTerminalPanel(terminalPanel)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addTerminalPanel(terminalPanel)
|
||||
return terminalPanel
|
||||
}
|
||||
|
||||
fun getTerminalPanels(): List<TerminalPanel> {
|
||||
return terminalPanels
|
||||
fun getTerminalPanels(): Array<TerminalPanel> {
|
||||
return terminalPanels.toTypedArray()
|
||||
}
|
||||
|
||||
fun repaintAll() {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
terminalPanels.forEach { it.repaintImmediate() }
|
||||
getTerminalPanels().forEach { it.repaintImmediate() }
|
||||
} else {
|
||||
SwingUtilities.invokeLater { repaintAll() }
|
||||
}
|
||||
@@ -47,4 +75,39 @@ class TerminalPanelFactory {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTerminalPanel(terminalPanel: TerminalPanel) {
|
||||
terminalPanels.remove(terminalPanel)
|
||||
}
|
||||
|
||||
fun addTerminalPanel(terminalPanel: TerminalPanel) {
|
||||
terminalPanels.add(terminalPanel)
|
||||
terminalPanel.terminal.getTerminalModel().setData(Factory, this)
|
||||
}
|
||||
|
||||
private class Painter : Disposable {
|
||||
companion object {
|
||||
fun getInstance(): Painter {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(Painter::class) { Painter() }
|
||||
}
|
||||
}
|
||||
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
init {
|
||||
coroutineScope.launch {
|
||||
while (coroutineScope.isActive) {
|
||||
delay(500.milliseconds)
|
||||
SwingUtilities.invokeLater {
|
||||
ApplicationScope.forApplicationScope().windowScopes()
|
||||
.map { getInstance(it) }.forEach { it.repaintAll() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,12 +10,14 @@ import app.termora.transport.TransportPanel
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.*
|
||||
import java.awt.event.AWTEventListener
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
|
||||
import kotlin.math.min
|
||||
@@ -30,7 +32,7 @@ class TerminalTabbed(
|
||||
private val toolbar = termoraToolBar.getJToolBar()
|
||||
private val actionManager = ActionManager.getInstance()
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
|
||||
private val titleProperty = UUID.randomUUID().toSimpleString()
|
||||
private val iconListener = PropertyChangeListener { e ->
|
||||
val source = e.source
|
||||
if (e.propertyName == "icon" && source is TerminalTab) {
|
||||
@@ -52,9 +54,6 @@ class TerminalTabbed(
|
||||
tabbedPane.isTabsClosable = true
|
||||
tabbedPane.tabType = FlatTabbedPane.TabType.card
|
||||
|
||||
tabbedPane.styleMap = mapOf(
|
||||
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground")
|
||||
)
|
||||
tabbedPane.trailingComponent = toolbar
|
||||
|
||||
add(tabbedPane, BorderLayout.CENTER)
|
||||
@@ -74,12 +73,17 @@ class TerminalTabbed(
|
||||
tabbedPane.addPropertyChangeListener("selectedIndex") { evt ->
|
||||
val oldIndex = evt.oldValue as Int
|
||||
val newIndex = evt.newValue as Int
|
||||
|
||||
if (oldIndex >= 0 && tabs.size > newIndex) {
|
||||
tabs[oldIndex].onLostFocus()
|
||||
}
|
||||
|
||||
if (newIndex >= 0 && tabs.size > newIndex) {
|
||||
tabs[newIndex].onGrabFocus()
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater { tabbedPane.getComponentAt(newIndex).requestFocusInWindow() }
|
||||
|
||||
}
|
||||
|
||||
// 选择变动
|
||||
@@ -175,6 +179,9 @@ class TerminalTabbed(
|
||||
// 新的获取到焦点
|
||||
tabs[tabbedPane.selectedIndex].onGrabFocus()
|
||||
|
||||
// 新的真正获取焦点
|
||||
tabbedPane.getComponentAt(tabbedPane.selectedIndex).requestFocusInWindow()
|
||||
|
||||
if (disposable) {
|
||||
Disposer.dispose(tab)
|
||||
}
|
||||
@@ -190,16 +197,16 @@ class TerminalTabbed(
|
||||
// 修改名称
|
||||
val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename"))
|
||||
rename.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
if (index > 0) {
|
||||
if (tabIndex > 0) {
|
||||
val dialog = InputDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
title = rename.text,
|
||||
text = tabbedPane.getTitleAt(index),
|
||||
text = tabbedPane.getTitleAt(tabIndex),
|
||||
)
|
||||
val text = dialog.getText()
|
||||
if (!text.isNullOrBlank()) {
|
||||
tabbedPane.setTitleAt(index, text)
|
||||
tabbedPane.setTitleAt(tabIndex, text)
|
||||
c.putClientProperty(titleProperty, text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +243,17 @@ class TerminalTabbed(
|
||||
}
|
||||
})
|
||||
|
||||
if (tab is HostTerminalTab) {
|
||||
val openHostAction = actionManager.getAction(OpenHostAction.OPEN_HOST)
|
||||
if (openHostAction != null) {
|
||||
if (tab.host.protocol == Protocol.SSH || tab.host.protocol == Protocol.SFTPPty) {
|
||||
popupMenu.addSeparator()
|
||||
val sftpCommand = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
|
||||
sftpCommand.addActionListener { openSFTPPtyTab(tab, openHostAction, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
popupMenu.addSeparator()
|
||||
|
||||
// 关闭
|
||||
@@ -262,7 +280,7 @@ class TerminalTabbed(
|
||||
}
|
||||
|
||||
|
||||
close.isEnabled = c !is WelcomePanel
|
||||
close.isEnabled = tab.canClose()
|
||||
rename.isEnabled = close.isEnabled
|
||||
clone.isEnabled = close.isEnabled
|
||||
openInNewWindow.isEnabled = close.isEnabled
|
||||
@@ -276,9 +294,8 @@ class TerminalTabbed(
|
||||
popupMenu.addSeparator()
|
||||
val reconnect = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.reconnect"))
|
||||
reconnect.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
if (index > 0) {
|
||||
tabs[index].reconnect()
|
||||
if (tabIndex > 0) {
|
||||
tabs[tabIndex].reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,21 +306,64 @@ class TerminalTabbed(
|
||||
}
|
||||
|
||||
|
||||
fun addTab(tab: TerminalTab) {
|
||||
tabbedPane.addTab(
|
||||
tab.getTitle(),
|
||||
private fun addTab(index: Int, tab: TerminalTab, selected: Boolean) {
|
||||
val c = tab.getJComponent()
|
||||
val title = (c.getClientProperty(titleProperty) ?: tab.getTitle()).toString()
|
||||
|
||||
tabbedPane.insertTab(
|
||||
title,
|
||||
tab.getIcon(),
|
||||
tab.getJComponent()
|
||||
c,
|
||||
StringUtils.EMPTY,
|
||||
index
|
||||
)
|
||||
|
||||
// 设置标题
|
||||
c.putClientProperty(titleProperty, title)
|
||||
// 监听 icons 变化
|
||||
tab.addPropertyChangeListener(iconListener)
|
||||
|
||||
tabs.add(tab)
|
||||
tabbedPane.selectedIndex = tabbedPane.tabCount - 1
|
||||
tabs.add(index, tab)
|
||||
|
||||
if (selected) {
|
||||
tabbedPane.selectedIndex = index
|
||||
}
|
||||
|
||||
tabbedPane.setTabClosable(index, tab.canClose())
|
||||
|
||||
Disposer.register(this, tab)
|
||||
}
|
||||
|
||||
private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
|
||||
if (!SFTPPtyTerminalTab.canSupports) {
|
||||
OptionPane.showMessageDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.tabbed.contextmenu.sftp-not-install"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var host = tab.host
|
||||
|
||||
if (host.protocol == Protocol.SSH) {
|
||||
val envs = tab.host.options.envs().toMutableMap()
|
||||
val currentDir = tab.getData(DataProviders.Terminal)?.getTerminalModel()
|
||||
?.getData(DataKey.CurrentDir, StringUtils.EMPTY) ?: StringUtils.EMPTY
|
||||
|
||||
if (currentDir.isNotBlank()) {
|
||||
envs["CurrentDir"] = currentDir
|
||||
}
|
||||
|
||||
host = host.copy(
|
||||
protocol = Protocol.SFTPPty, updateDate = System.currentTimeMillis(),
|
||||
options = host.options.copy(env = envs.toPropertiesString())
|
||||
)
|
||||
}
|
||||
|
||||
openHostAction.actionPerformed(OpenHostActionEvent(this, host, evt))
|
||||
}
|
||||
|
||||
/**
|
||||
* 对着 ToolBar 右键
|
||||
*/
|
||||
@@ -392,8 +452,12 @@ class TerminalTabbed(
|
||||
override fun dispose() {
|
||||
}
|
||||
|
||||
override fun addTerminalTab(tab: TerminalTab) {
|
||||
addTab(tab)
|
||||
override fun addTerminalTab(tab: TerminalTab, selected: Boolean) {
|
||||
addTab(tabs.size, tab, selected)
|
||||
}
|
||||
|
||||
override fun addTerminalTab(index: Int, tab: TerminalTab, selected: Boolean) {
|
||||
addTab(index, tab, selected)
|
||||
}
|
||||
|
||||
override fun getSelectedTerminalTab(): TerminalTab? {
|
||||
@@ -418,10 +482,10 @@ class TerminalTabbed(
|
||||
}
|
||||
}
|
||||
|
||||
override fun closeTerminalTab(tab: TerminalTab) {
|
||||
override fun closeTerminalTab(tab: TerminalTab, disposable: Boolean) {
|
||||
for (i in 0 until tabs.size) {
|
||||
if (tabs[i] == tab) {
|
||||
removeTabAt(i, true)
|
||||
removeTabAt(i, disposable)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package app.termora
|
||||
|
||||
interface TerminalTabbedManager {
|
||||
fun addTerminalTab(tab: TerminalTab)
|
||||
fun addTerminalTab(tab: TerminalTab, selected: Boolean = true)
|
||||
fun addTerminalTab(index: Int, tab: TerminalTab, selected: Boolean = true)
|
||||
fun getSelectedTerminalTab(): TerminalTab?
|
||||
fun getTerminalTabs(): List<TerminalTab>
|
||||
fun setSelectedTerminalTab(tab: TerminalTab)
|
||||
fun closeTerminalTab(tab: TerminalTab)
|
||||
fun closeTerminalTab(tab: TerminalTab, disposable: Boolean = true)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package app.termora
|
||||
|
||||
|
||||
import app.termora.actions.ActionManager
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviderSupport
|
||||
import app.termora.actions.DataProviders
|
||||
@@ -12,7 +11,6 @@ import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jetbrains.JBR
|
||||
import java.awt.Dimension
|
||||
import java.awt.Insets
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.*
|
||||
@@ -32,7 +30,6 @@ fun assertEventDispatchThread() {
|
||||
class TermoraFrame : JFrame(), DataProvider {
|
||||
|
||||
|
||||
private val actionManager get() = ActionManager.getInstance()
|
||||
private val id = UUID.randomUUID().toString()
|
||||
private val windowScope = ApplicationScope.forWindowScope(this)
|
||||
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
|
||||
@@ -42,7 +39,7 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() }
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val welcomePanel = WelcomePanel(windowScope)
|
||||
private val keyboardFocusManager by lazy { KeyboardFocusManager.getCurrentKeyboardFocusManager() }
|
||||
private val sftp get() = Database.getDatabase().sftp
|
||||
|
||||
|
||||
init {
|
||||
@@ -101,7 +98,14 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
}
|
||||
|
||||
minimumSize = Dimension(640, 400)
|
||||
terminalTabbed.addTab(welcomePanel)
|
||||
terminalTabbed.addTerminalTab(welcomePanel)
|
||||
|
||||
// 下一次事件循环检测是否固定 SFTP
|
||||
SwingUtilities.invokeLater {
|
||||
if (sftp.pinTab) {
|
||||
terminalTabbed.addTerminalTab(SFTPTerminalTab(), false)
|
||||
}
|
||||
}
|
||||
|
||||
// macOS 要避开左边的控制栏
|
||||
if (SystemInfo.isMacOS) {
|
||||
@@ -116,6 +120,7 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
Disposer.register(windowScope, terminalTabbed)
|
||||
add(terminalTabbed)
|
||||
|
||||
dataProviderSupport.addData(DataProviders.TabbedPane, tabbedPane)
|
||||
dataProviderSupport.addData(DataProviders.TermoraFrame, this)
|
||||
dataProviderSupport.addData(DataProviders.WindowScope, windowScope)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -18,23 +19,35 @@ class TermoraFrameManager {
|
||||
}
|
||||
}
|
||||
|
||||
private val frames = mutableListOf<TermoraFrame>()
|
||||
|
||||
fun createWindow(): TermoraFrame {
|
||||
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)
|
||||
frames.add(frame)
|
||||
return frame
|
||||
}
|
||||
|
||||
fun getWindows(): Array<TermoraFrame> {
|
||||
return frames.toTypedArray()
|
||||
}
|
||||
|
||||
|
||||
private fun registerCloseCallback(window: TermoraFrame) {
|
||||
window.addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosed(e: WindowEvent) {
|
||||
|
||||
// 删除
|
||||
frames.remove(window)
|
||||
|
||||
// dispose windowScope
|
||||
Disposer.dispose(ApplicationScope.forWindowScope(e.window))
|
||||
val windowScope = ApplicationScope.forWindowScope(e.window)
|
||||
Disposer.disposeChildren(windowScope, null)
|
||||
Disposer.dispose(windowScope)
|
||||
|
||||
val windowScopes = ApplicationScope.windowScopes()
|
||||
|
||||
@@ -43,6 +56,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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,7 +80,9 @@ class TermoraFrameManager {
|
||||
try {
|
||||
Disposer.getTree().assertIsEmpty(true)
|
||||
} catch (e: Exception) {
|
||||
log.error(e.message)
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
exitProcess(0)
|
||||
|
||||
155
src/main/kotlin/app/termora/TermoraRestarter.kt
Normal file
155
src/main/kotlin/app/termora/TermoraRestarter.kt
Normal file
@@ -0,0 +1,155 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.github.hstyi.restart4j.Restarter
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Component
|
||||
import java.nio.file.Paths
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class TermoraRestarter {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TermoraRestarter::class.java)
|
||||
|
||||
fun getInstance(): TermoraRestarter {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(TermoraRestarter::class) { TermoraRestarter() }
|
||||
}
|
||||
|
||||
init {
|
||||
Restarter.setProcessHandler { ProcessHandle.current().pid().toInt() }
|
||||
Restarter.setExecCommandsHandler { commands ->
|
||||
val pb = ProcessBuilder(commands)
|
||||
if (SystemInfo.isLinux) {
|
||||
// 去掉链接库变量
|
||||
pb.environment().remove("LD_LIBRARY_PATH")
|
||||
}
|
||||
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||
pb.redirectError(ProcessBuilder.Redirect.DISCARD)
|
||||
pb.directory(Paths.get(System.getProperty("user.home")).toFile())
|
||||
pb.start()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val restarting = AtomicBoolean(false)
|
||||
private val isSupported get() = !restarting.get() && checkIsSupported()
|
||||
private val isLinuxAppImage by lazy { System.getenv("LinuxAppImage")?.toBoolean() == true }
|
||||
private val startupCommand by lazy { ProcessHandle.current().info().command().getOrNull() }
|
||||
private val macOSApplicationPath by lazy {
|
||||
StringUtils.removeEndIgnoreCase(
|
||||
Application.getAppPath(),
|
||||
"/Contents/MacOS/Termora"
|
||||
)
|
||||
}
|
||||
|
||||
private fun restart(commands: List<String>) {
|
||||
if (!isSupported) return
|
||||
if (!restarting.compareAndSet(false, true)) return
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
try {
|
||||
doRestart(commands)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划重启,如果当前进程支持重启,那么会询问用户是否重启。如果不支持重启,那么弹窗提示需要手动重启。
|
||||
*/
|
||||
fun scheduleRestart(owner: Component?, commands: List<String> = emptyList()) {
|
||||
|
||||
if (isSupported) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
I18n.getString("termora.settings.restart.message"),
|
||||
I18n.getString("termora.settings.restart.title"),
|
||||
messageType = JOptionPane.QUESTION_MESSAGE,
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
options = arrayOf(
|
||||
I18n.getString("termora.settings.restart.title"),
|
||||
I18n.getString("termora.cancel")
|
||||
),
|
||||
initialValue = I18n.getString("termora.settings.restart.title")
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
restart(commands)
|
||||
}
|
||||
} else {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.settings.restart.message"),
|
||||
I18n.getString("termora.settings.restart.title"),
|
||||
messageType = JOptionPane.INFORMATION_MESSAGE,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun doRestart(commands: List<String>) {
|
||||
|
||||
if (commands.isEmpty()) {
|
||||
if (SystemInfo.isMacOS) {
|
||||
Restarter.restart(arrayOf("open", "-n", macOSApplicationPath))
|
||||
} else if (SystemInfo.isWindows && startupCommand != null) {
|
||||
Restarter.restart(arrayOf(startupCommand))
|
||||
} else if (SystemInfo.isLinux) {
|
||||
if (isLinuxAppImage) {
|
||||
Restarter.restart(arrayOf(System.getenv("APPIMAGE")))
|
||||
} else if (startupCommand != null) {
|
||||
Restarter.restart(arrayOf(startupCommand))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Restarter.restart(commands.toTypedArray())
|
||||
}
|
||||
|
||||
for (window in TermoraFrameManager.getInstance().getWindows()) {
|
||||
window.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun checkIsSupported(): Boolean {
|
||||
val appPath = Application.getAppPath()
|
||||
if (appPath.isBlank() || Application.isUnknownVersion()) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Restart not supported")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (SystemInfo.isWindows && startupCommand == null) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Restart not supported , ProcessHandle#info#command is null.")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (SystemInfo.isLinux) {
|
||||
if (isLinuxAppImage) {
|
||||
val appImage = System.getenv("APPIMAGE") ?: StringUtils.EMPTY
|
||||
return appImage.isNotBlank() && FileUtils.getFile(appImage).exists()
|
||||
}
|
||||
return startupCommand != null
|
||||
}
|
||||
|
||||
if (SystemInfo.isMacOS) {
|
||||
return Application.getAppPath().isNotBlank()
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -109,6 +109,10 @@ class TermoraToolBar(
|
||||
|
||||
toolbar.add(Box.createHorizontalGlue())
|
||||
|
||||
if (SystemInfo.isLinux || SystemInfo.isWindows) {
|
||||
toolbar.add(Box.createHorizontalStrut(16))
|
||||
}
|
||||
|
||||
|
||||
// update btn
|
||||
val updateBtn = actionContainerFactory.createButton(actionManager.getAction(Actions.APP_UPDATE))
|
||||
|
||||
@@ -99,6 +99,8 @@ class OutlinePasswordField(
|
||||
styleMap = mapOf(
|
||||
"showRevealButton" to true
|
||||
)
|
||||
|
||||
putClientProperty("JPasswordField.cutCopyAllowed", true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -69,6 +70,9 @@ class UpdaterManager private constructor() {
|
||||
.build()
|
||||
val response = Application.httpClient.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error("Failed to fetch latest version, response was ${response.code}")
|
||||
}
|
||||
return LatestVersion.self
|
||||
}
|
||||
|
||||
@@ -97,7 +101,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 +117,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;"
|
||||
}
|
||||
}
|
||||
@@ -143,8 +154,4 @@ class UpdaterManager private constructor() {
|
||||
fun ignore(version: String) {
|
||||
properties.putString("ignored.version.$version", "true")
|
||||
}
|
||||
|
||||
private fun doGetLatestVersion() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package app.termora
|
||||
|
||||
|
||||
import app.termora.actions.*
|
||||
import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
|
||||
import app.termora.findeverywhere.FindEverywhereProvider
|
||||
import app.termora.findeverywhere.FindEverywhereResult
|
||||
import app.termora.terminal.DataKey
|
||||
@@ -27,11 +26,15 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private val rootPanel = JPanel(BorderLayout())
|
||||
private val searchTextField = FlatTextField()
|
||||
private val hostTree = HostTree()
|
||||
private val hostTree = NewHostTree()
|
||||
private val bannerPanel = BannerPanel()
|
||||
private val toggle = FlatButton()
|
||||
private var fullContent = properties.getString("WelcomeFullContent", "false").toBoolean()
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val hostTreeModel = hostTree.model as NewHostTreeModel
|
||||
private val filterableHostTreeModel = FilterableHostTreeModel(hostTree) {
|
||||
searchTextField.text.isBlank()
|
||||
}
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -126,8 +129,6 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
})
|
||||
hostTree.showsRootHandles = true
|
||||
|
||||
Disposer.register(this, hostTree)
|
||||
|
||||
val scrollPane = JScrollPane(hostTree)
|
||||
scrollPane.verticalScrollBar.maximumSize = Dimension(0, 0)
|
||||
scrollPane.verticalScrollBar.preferredSize = Dimension(0, 0)
|
||||
@@ -138,6 +139,11 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
panel.add(scrollPane, BorderLayout.CENTER)
|
||||
panel.border = BorderFactory.createEmptyBorder(10, 0, 0, 0)
|
||||
|
||||
hostTree.model = filterableHostTreeModel
|
||||
TreeUtils.loadExpansionState(
|
||||
hostTree,
|
||||
properties.getString("Welcome.HostTree.state", StringUtils.EMPTY)
|
||||
)
|
||||
|
||||
return panel
|
||||
}
|
||||
@@ -163,37 +169,49 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
})
|
||||
|
||||
|
||||
FindEverywhereProvider.getFindEverywhereProviders(windowScope)
|
||||
.add(BasicFilterFindEverywhereProvider(object : FindEverywhereProvider {
|
||||
override fun find(pattern: String): List<FindEverywhereResult> {
|
||||
return TreeUtils.children(hostTree.model, hostTree.model.root)
|
||||
.filterIsInstance<Host>()
|
||||
.filter { it.protocol != Protocol.Folder }
|
||||
.map { HostFindEverywhereResult(it) }
|
||||
FindEverywhereProvider.getFindEverywhereProviders(windowScope).add(object : FindEverywhereProvider {
|
||||
override fun find(pattern: String): List<FindEverywhereResult> {
|
||||
var filter = hostTreeModel.root.getAllChildren()
|
||||
.map { it.host }
|
||||
.filter { it.protocol != Protocol.Folder }
|
||||
|
||||
if (pattern.isNotBlank()) {
|
||||
filter = filter.filter {
|
||||
if (it.protocol == Protocol.SSH) {
|
||||
it.name.contains(pattern, true) || it.host.contains(pattern, true)
|
||||
} else {
|
||||
it.name.contains(pattern, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun group(): String {
|
||||
return I18n.getString("termora.find-everywhere.groups.open-new-hosts")
|
||||
}
|
||||
return filter.map { HostFindEverywhereResult(it) }
|
||||
}
|
||||
|
||||
override fun order(): Int {
|
||||
return Integer.MIN_VALUE + 2
|
||||
}
|
||||
}))
|
||||
override fun group(): String {
|
||||
return I18n.getString("termora.find-everywhere.groups.open-new-hosts")
|
||||
}
|
||||
|
||||
override fun order(): Int {
|
||||
return Integer.MIN_VALUE + 2
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
filterableHostTreeModel.addFilter {
|
||||
val text = searchTextField.text
|
||||
val host = it.host
|
||||
text.isBlank() || host.name.contains(text, true)
|
||||
|| host.host.contains(text, true)
|
||||
|| host.username.contains(text, true)
|
||||
}
|
||||
|
||||
searchTextField.document.addDocumentListener(object : DocumentAdaptor() {
|
||||
private var state = StringUtils.EMPTY
|
||||
override fun changedUpdate(e: DocumentEvent) {
|
||||
val text = searchTextField.text
|
||||
if (text.isBlank()) {
|
||||
hostTree.setModel(hostTree.model)
|
||||
TreeUtils.loadExpansionState(hostTree, state)
|
||||
state = String()
|
||||
} else {
|
||||
if (state.isBlank()) state = TreeUtils.saveExpansionState(hostTree)
|
||||
hostTree.setModel(hostTree.searchableModel)
|
||||
hostTree.searchableModel.search(text)
|
||||
TreeUtils.expandAll(hostTree)
|
||||
filterableHostTreeModel.refresh()
|
||||
if (text.isNotBlank()) {
|
||||
hostTree.expandAll()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -241,11 +259,13 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
hostTree.setModel(null)
|
||||
properties.putString("WelcomeFullContent", fullContent.toString())
|
||||
properties.putString("Welcome.HostTree.state", TreeUtils.saveExpansionState(hostTree))
|
||||
}
|
||||
|
||||
private class HostFindEverywhereResult(val host: Host) : FindEverywhereResult {
|
||||
private inner class HostFindEverywhereResult(val host: Host) : FindEverywhereResult {
|
||||
private val showMoreInfo get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
|
||||
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
ActionManager.getInstance()
|
||||
.getAction(OpenHostAction.OPEN_HOST)
|
||||
@@ -261,7 +281,18 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
return Icons.terminal
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
override fun getText(isSelected: Boolean): String {
|
||||
if (showMoreInfo) {
|
||||
val color = UIManager.getColor(if (isSelected) "textHighlightText" else "textInactiveText")
|
||||
val moreInfo = when (host.protocol) {
|
||||
Protocol.SSH -> "${host.username}@${host.host}"
|
||||
Protocol.Serial -> host.options.serialComm.port
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
if (moreInfo.isNotBlank()) {
|
||||
return "<html>${host.name} <font color=rgb(${color.red},${color.green},${color.blue})>${moreInfo}</font></html>"
|
||||
}
|
||||
}
|
||||
return host.name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() {
|
||||
addAction(FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction())
|
||||
|
||||
addAction(Actions.MULTIPLE, MultipleAction())
|
||||
addAction(Actions.APP_UPDATE, AppUpdateAction())
|
||||
addAction(Actions.APP_UPDATE, AppUpdateAction.getInstance())
|
||||
addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction())
|
||||
addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
|
||||
addAction(Actions.SFTP, SFTPAction())
|
||||
addAction(SFTPCommandAction.SFTP_COMMAND, SFTPCommandAction())
|
||||
addAction(Actions.MACRO, MacroAction())
|
||||
addAction(Actions.KEY_MANAGER, KeyManagerAction())
|
||||
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.Application.httpClient
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.sun.jna.platform.win32.Advapi32
|
||||
import com.sun.jna.platform.win32.WinError
|
||||
import com.sun.jna.platform.win32.WinNT
|
||||
import com.sun.jna.platform.win32.WinReg
|
||||
import io.github.g00fy2.versioncompare.Version
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import okhttp3.Request
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.JXEditorPane
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Dimension
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.io.File
|
||||
import java.net.ProxySelector
|
||||
import java.net.URI
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.JScrollPane
|
||||
@@ -18,11 +32,20 @@ import kotlin.concurrent.fixedRateTimer
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class AppUpdateAction : AnAction(
|
||||
class AppUpdateAction private constructor() : AnAction(
|
||||
StringUtils.EMPTY,
|
||||
Icons.ideUpdate
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(AppUpdateAction::class.java)
|
||||
private const val PKG_FILE_KEY = "pkgFile"
|
||||
|
||||
fun getInstance(): AppUpdateAction {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(AppUpdateAction::class) { AppUpdateAction() }
|
||||
}
|
||||
}
|
||||
|
||||
private val updaterManager get() = UpdaterManager.getInstance()
|
||||
|
||||
init {
|
||||
@@ -63,11 +86,75 @@ class AppUpdateAction : AnAction(
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
ActionManager.getInstance()
|
||||
.setEnabled(Actions.APP_UPDATE, true)
|
||||
try {
|
||||
downloadLatestPkg(latestVersion)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
withContext(Dispatchers.Swing) { isEnabled = true }
|
||||
|
||||
}
|
||||
|
||||
|
||||
private suspend fun downloadLatestPkg(latestVersion: UpdaterManager.LatestVersion) {
|
||||
if (SystemInfo.isLinux) return
|
||||
|
||||
super.putValue(PKG_FILE_KEY, null)
|
||||
val arch = if (SystemInfo.isAARCH64) "aarch64" else "x86-64"
|
||||
val osName = if (SystemInfo.isWindows) "windows" else "osx"
|
||||
val suffix = if (SystemInfo.isWindows) "exe" else "dmg"
|
||||
val filename = "termora-${latestVersion.version}-${osName}-${arch}.${suffix}"
|
||||
val asset = latestVersion.assets.find { it.name == filename } ?: return
|
||||
|
||||
val response = httpClient
|
||||
.newBuilder()
|
||||
.callTimeout(15, TimeUnit.MINUTES)
|
||||
.readTimeout(15, TimeUnit.MINUTES)
|
||||
.proxySelector(ProxySelector.getDefault())
|
||||
.build()
|
||||
.newCall(Request.Builder().url(asset.downloadUrl).build())
|
||||
.execute()
|
||||
if (!response.isSuccessful) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.warn("Failed to download latest version ${latestVersion.version}, response code ${response.code}")
|
||||
}
|
||||
IOUtils.closeQuietly(response)
|
||||
return
|
||||
}
|
||||
|
||||
val body = response.body
|
||||
val input = body?.byteStream()
|
||||
val file = FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}-${filename}")
|
||||
val output = file.outputStream()
|
||||
|
||||
val downloaded = runCatching { IOUtils.copy(input, output) }.isSuccess
|
||||
IOUtils.closeQuietly(input, output, body, response)
|
||||
|
||||
if (!downloaded) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error("Failed to download latest version to $filename")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Successfully downloaded latest version to $file")
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) { setLatestPkgFile(file) }
|
||||
|
||||
}
|
||||
|
||||
private fun setLatestPkgFile(file: File) {
|
||||
putValue(PKG_FILE_KEY, file)
|
||||
}
|
||||
|
||||
private fun getLatestPkgFile(): File? {
|
||||
return getValue(PKG_FILE_KEY) as? File
|
||||
}
|
||||
|
||||
private fun showUpdateDialog() {
|
||||
@@ -106,12 +193,59 @@ class AppUpdateAction : AnAction(
|
||||
if (option == JOptionPane.CANCEL_OPTION) {
|
||||
return
|
||||
} else if (option == JOptionPane.NO_OPTION) {
|
||||
ActionManager.getInstance().setEnabled(Actions.APP_UPDATE, false)
|
||||
updaterManager.ignore(updaterManager.lastVersion.version)
|
||||
isEnabled = false
|
||||
updaterManager.ignore(lastVersion.version)
|
||||
} else if (option == JOptionPane.YES_OPTION) {
|
||||
ActionManager.getInstance()
|
||||
.setEnabled(Actions.APP_UPDATE, false)
|
||||
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${lastVersion.version}"))
|
||||
updateSelf(lastVersion)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSelf(latestVersion: UpdaterManager.LatestVersion) {
|
||||
val file = getLatestPkgFile()
|
||||
if (SystemInfo.isLinux || file == null) {
|
||||
isEnabled = false
|
||||
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${latestVersion.version}"))
|
||||
return
|
||||
}
|
||||
|
||||
val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
|
||||
val commands = if (SystemInfo.isMacOS) listOf("open", "-n", file.absolutePath)
|
||||
// 如果安装过,那么直接静默安装和自动启动
|
||||
else if (isAppInstalled()) listOf(
|
||||
file.absolutePath,
|
||||
"/SILENT",
|
||||
"/AUTOSTART",
|
||||
"/NORESTART",
|
||||
"/FORCECLOSEAPPLICATIONS"
|
||||
)
|
||||
// 没有安装过 则打开安装向导
|
||||
else listOf(file.absolutePath)
|
||||
|
||||
println(commands)
|
||||
TermoraRestarter.getInstance().scheduleRestart(owner, commands)
|
||||
|
||||
}
|
||||
|
||||
private fun isAppInstalled(): Boolean {
|
||||
val keyPath = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${Application.getName()}_is1"
|
||||
val phkKey = WinReg.HKEYByReference()
|
||||
|
||||
// 尝试打开注册表键
|
||||
val result = Advapi32.INSTANCE.RegOpenKeyEx(
|
||||
WinReg.HKEY_LOCAL_MACHINE,
|
||||
keyPath,
|
||||
0,
|
||||
WinNT.KEY_READ,
|
||||
phkKey
|
||||
)
|
||||
|
||||
if (result == WinError.ERROR_SUCCESS) {
|
||||
// 键存在,关闭句柄
|
||||
Advapi32.INSTANCE.RegCloseKey(phkKey.getValue())
|
||||
return true
|
||||
} else {
|
||||
// 键不存在或无权限
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ object DataProviders {
|
||||
val Terminal = DataKey(app.termora.terminal.Terminal::class)
|
||||
val PtyConnector = DataKey(app.termora.terminal.PtyConnector::class)
|
||||
|
||||
val TabbedPane = DataKey(app.termora.MyTabbedPane::class)
|
||||
val TerminalTabbed = DataKey(app.termora.TerminalTabbed::class)
|
||||
val TerminalTab = DataKey(app.termora.TerminalTab::class)
|
||||
val TerminalTabbedManager = DataKey(app.termora.TerminalTabbedManager::class)
|
||||
@@ -16,6 +17,6 @@ object DataProviders {
|
||||
|
||||
|
||||
object Welcome {
|
||||
val HostTree = DataKey(app.termora.HostTree::class)
|
||||
val HostTree = DataKey(app.termora.NewHostTree::class)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.Host
|
||||
import app.termora.HostDialog
|
||||
import app.termora.HostManager
|
||||
import app.termora.Protocol
|
||||
import app.termora.*
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
class NewHostAction : AnAction() {
|
||||
@@ -20,27 +17,27 @@ class NewHostAction : AnAction() {
|
||||
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val tree = evt.getData(DataProviders.Welcome.HostTree) ?: return
|
||||
val model = tree.model
|
||||
var lastHost = tree.lastSelectedPathComponent ?: model.root
|
||||
if (lastHost !is Host) {
|
||||
return
|
||||
}
|
||||
|
||||
if (lastHost.protocol != Protocol.Folder) {
|
||||
val p = model.getParent(lastHost) ?: return
|
||||
lastHost = p
|
||||
var lastNode = (tree.lastSelectedPathComponent ?: tree.model.root) as? HostTreeNode ?: return
|
||||
if (lastNode.host.protocol != Protocol.Folder) {
|
||||
lastNode = lastNode.parent ?: return
|
||||
}
|
||||
|
||||
val lastHost = lastNode.host
|
||||
val dialog = HostDialog(evt.window)
|
||||
dialog.setLocationRelativeTo(evt.window)
|
||||
dialog.isVisible = true
|
||||
val host = (dialog.host ?: return).copy(parentId = lastHost.id)
|
||||
|
||||
hostManager.addHost(host)
|
||||
val newNode = HostTreeNode(host)
|
||||
|
||||
tree.expandNode(lastHost)
|
||||
val model = if (tree.model is FilterableHostTreeModel) (tree.model as FilterableHostTreeModel).getModel()
|
||||
else tree.model
|
||||
|
||||
tree.selectionPath = TreePath(model.getPathToRoot(host))
|
||||
if (model is NewHostTreeModel) {
|
||||
model.insertNodeInto(newNode, lastNode, lastNode.childCount)
|
||||
tree.selectionPath = TreePath(model.getPathToRoot(newNode))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.LocalTerminalTab
|
||||
import app.termora.OpenHostActionEvent
|
||||
import app.termora.Protocol
|
||||
import app.termora.SSHTerminalTab
|
||||
import app.termora.*
|
||||
|
||||
class OpenHostAction : AnAction() {
|
||||
companion object {
|
||||
@@ -18,9 +15,19 @@ class OpenHostAction : AnAction() {
|
||||
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
|
||||
val windowScope = evt.getData(DataProviders.WindowScope) ?: return
|
||||
|
||||
val tab = if (evt.host.protocol == Protocol.SSH)
|
||||
SSHTerminalTab(windowScope, evt.host)
|
||||
else LocalTerminalTab(windowScope, evt.host)
|
||||
// 如果不支持 SFTP 那么不处理这个响应
|
||||
if (evt.host.protocol == Protocol.SFTPPty) {
|
||||
if (!SFTPPtyTerminalTab.canSupports) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val tab = when (evt.host.protocol) {
|
||||
Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
|
||||
Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
|
||||
Protocol.SFTPPty -> SFTPPtyTerminalTab(windowScope, evt.host)
|
||||
else -> LocalTerminalTab(windowScope, evt.host)
|
||||
}
|
||||
|
||||
terminalTabbedManager.addTerminalTab(tab)
|
||||
tab.start()
|
||||
|
||||
30
src/main/kotlin/app/termora/actions/SFTPCommandAction.kt
Normal file
30
src/main/kotlin/app/termora/actions/SFTPCommandAction.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.HostTerminalTab
|
||||
import app.termora.I18n
|
||||
import app.termora.OpenHostActionEvent
|
||||
import app.termora.Protocol
|
||||
|
||||
class SFTPCommandAction : AnAction() {
|
||||
companion object {
|
||||
/**
|
||||
* 打开 SFTP command
|
||||
*/
|
||||
const val SFTP_COMMAND = "SFTPCommandAction"
|
||||
}
|
||||
|
||||
init {
|
||||
putValue(ACTION_COMMAND_KEY, SFTP_COMMAND)
|
||||
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.open-sftp-command"))
|
||||
}
|
||||
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val actionManager = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) ?: return
|
||||
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
|
||||
val tab = terminalTabbedManager.getSelectedTerminalTab() as? HostTerminalTab ?: return
|
||||
val host = tab.host
|
||||
if (!(host.protocol == Protocol.SSH || host.protocol == Protocol.SFTPPty)) return
|
||||
actionManager.actionPerformed(OpenHostActionEvent(evt.source, host.copy(protocol = Protocol.SFTPPty), evt))
|
||||
evt.consume()
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.I18n
|
||||
|
||||
class TerminalClearScreenAction : AnAction() {
|
||||
companion object {
|
||||
const val CLEAR_SCREEN = "ClearScreen"
|
||||
}
|
||||
|
||||
init {
|
||||
putValue(SHORT_DESCRIPTION, "Clear Terminal Buffer")
|
||||
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.clear-screen"))
|
||||
putValue(ACTION_COMMAND_KEY, CLEAR_SCREEN)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@ interface FindEverywhereResult : ActionListener {
|
||||
|
||||
fun getIcon(isSelected: Boolean): Icon = Icons.empty
|
||||
|
||||
fun getText(isSelected: Boolean) = toString()
|
||||
|
||||
}
|
||||
@@ -94,16 +94,16 @@ class FindEverywhereXList(private val model: DefaultListModel<FindEverywhereResu
|
||||
label.font = font.deriveFont(font.size - 2f)
|
||||
val box = Box.createHorizontalBox()
|
||||
box.add(label)
|
||||
/*box.add(object : JComponent() {
|
||||
override fun paintComponent(g: Graphics) {
|
||||
g.color = DynamicColor.BorderColor
|
||||
g.drawLine(10, height / 2, width, height / 2)
|
||||
}
|
||||
})*/
|
||||
return box
|
||||
}
|
||||
|
||||
val c = super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
|
||||
val c = super.getListCellRendererComponent(
|
||||
list,
|
||||
if (value is FindEverywhereResult) value.getText(isSelected) else value,
|
||||
index,
|
||||
isSelected,
|
||||
cellHasFocus
|
||||
)
|
||||
if (isSelected) {
|
||||
background = UIManager.getColor("List.selectionBackground")
|
||||
foreground = UIManager.getColor("List.selectionForeground")
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -76,6 +76,12 @@ class KeymapImpl(private val menuShortcutKeyMaskEx: Int) : Keymap("Keymap", null
|
||||
KeyShortcut(KeyStroke.getKeyStroke(KeyEvent.VK_R, menuShortcutKeyMaskEx or InputEvent.SHIFT_DOWN_MASK))
|
||||
)
|
||||
|
||||
// Command + Shift + P
|
||||
addShortcut(
|
||||
SFTPCommandAction.SFTP_COMMAND,
|
||||
KeyShortcut(KeyStroke.getKeyStroke(KeyEvent.VK_P, menuShortcutKeyMaskEx or InputEvent.SHIFT_DOWN_MASK))
|
||||
)
|
||||
|
||||
|
||||
// switch map
|
||||
for (i in KeyEvent.VK_1..KeyEvent.VK_9) {
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
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
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Container
|
||||
import java.awt.KeyEventDispatcher
|
||||
import java.awt.KeyEventPostProcessor
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.awt.event.KeyEvent
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JDialog
|
||||
import javax.swing.JPopupMenu
|
||||
import javax.swing.KeyStroke
|
||||
|
||||
class KeymapManager private constructor() : Disposable {
|
||||
@@ -23,24 +20,21 @@ class KeymapManager private constructor() : Disposable {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(KeymapManager::class.java)
|
||||
|
||||
const val PROCESS_GLOBAL_KEYMAP = "PROCESS_GLOBAL_KEYMAP"
|
||||
|
||||
fun getInstance(): KeymapManager {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(KeymapManager::class) { KeymapManager() }
|
||||
}
|
||||
}
|
||||
|
||||
private val myKeyEventPostProcessor = MyKeyEventPostProcessor()
|
||||
private val myKeyEventDispatcher = MyKeyEventDispatcher()
|
||||
private val keymapKeyEventDispatcher = KeymapKeyEventDispatcher()
|
||||
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 {
|
||||
keyboardFocusManager.addKeyEventPostProcessor(myKeyEventPostProcessor)
|
||||
keyboardFocusManager.addKeyEventDispatcher(myKeyEventDispatcher)
|
||||
keyboardFocusManager.addKeyEventDispatcher(keymapKeyEventDispatcher)
|
||||
|
||||
try {
|
||||
for (keymap in database.getKeymaps()) {
|
||||
@@ -97,73 +91,69 @@ class KeymapManager private constructor() : Disposable {
|
||||
database.removeKeymap(name)
|
||||
}
|
||||
|
||||
private inner class MyKeyEventPostProcessor : KeyEventPostProcessor {
|
||||
override fun postProcessKeyEvent(e: KeyEvent): Boolean {
|
||||
// 只处理 PRESSED 和 带有 modifiers 键的事件
|
||||
if (!e.isConsumed && e.id == KeyEvent.KEY_PRESSED && e.modifiersEx != 0) {
|
||||
val shortcuts = getActiveKeymap()
|
||||
val actionIds = shortcuts.getActionIds(KeyShortcut(KeyStroke.getKeyStrokeForEvent(e)))
|
||||
if (actionIds.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val focusedWindow = keyboardFocusManager.focusedWindow
|
||||
if (focusedWindow is DialogWrapper) {
|
||||
if (!focusedWindow.processGlobalKeymap) {
|
||||
return false
|
||||
}
|
||||
} else if (focusedWindow is JDialog) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
val evt = AnActionEvent(e.source, StringUtils.EMPTY, e)
|
||||
for (actionId in actionIds) {
|
||||
val action = ActionManager.getInstance().getAction(actionId) ?: continue
|
||||
if (!action.isEnabled) {
|
||||
continue
|
||||
}
|
||||
action.actionPerformed(evt)
|
||||
if (evt.isConsumed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private inner class MyKeyEventDispatcher : KeyEventDispatcher {
|
||||
// double shift
|
||||
private var lastTime = -1L
|
||||
private inner class KeymapKeyEventDispatcher : KeyEventDispatcher {
|
||||
|
||||
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
|
||||
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))
|
||||
}
|
||||
lastTime = now
|
||||
}
|
||||
} else if (e.keyCode != KeyEvent.VK_SHIFT) { // 如果不是 Shift 键,那么就阻断了连续性,重置时间
|
||||
lastTime = -1
|
||||
if (e.isConsumed || e.id != KeyEvent.KEY_PRESSED || e.modifiersEx == 0) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
|
||||
val keyStroke = KeyStroke.getKeyStrokeForEvent(e)
|
||||
val component = e.source
|
||||
|
||||
if (component is JComponent) {
|
||||
// 如果这个键已经被组件注册了,那么忽略
|
||||
if (component.getConditionForKeyStroke(keyStroke) != JComponent.UNDEFINED_CONDITION) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val shortcuts = getActiveKeymap()
|
||||
val actionIds = shortcuts.getActionIds(KeyShortcut(keyStroke))
|
||||
if (actionIds.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val focusedWindow = keyboardFocusManager.focusedWindow
|
||||
if (focusedWindow is DialogWrapper) {
|
||||
if (!focusedWindow.processGlobalKeymap) {
|
||||
return false
|
||||
}
|
||||
} else if (focusedWindow is JDialog) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果当前有 Popup ,那么不派发事件
|
||||
val c = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
|
||||
if (c is Container) {
|
||||
val popups: List<JPopupMenu> = SwingUtils.getDescendantsOfType(
|
||||
JPopupMenu::class.java,
|
||||
c, true
|
||||
)
|
||||
if (popups.isNotEmpty()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val evt = AnActionEvent(e.source, StringUtils.EMPTY, e)
|
||||
for (actionId in actionIds) {
|
||||
val action = ActionManager.getInstance().getAction(actionId) ?: continue
|
||||
if (!action.isEnabled) {
|
||||
continue
|
||||
}
|
||||
action.actionPerformed(evt)
|
||||
if (evt.isConsumed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun dispose() {
|
||||
keyboardFocusManager.removeKeyEventPostProcessor(myKeyEventPostProcessor)
|
||||
keyboardFocusManager.removeKeyEventDispatcher(myKeyEventDispatcher)
|
||||
keyboardFocusManager.removeKeyEventDispatcher(keymapKeyEventDispatcher)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ class KeymapTableModel : DefaultTableModel() {
|
||||
FindEverywhereAction.FIND_EVERYWHERE,
|
||||
NewWindowAction.NEW_WINDOW,
|
||||
TabReconnectAction.RECONNECT_TAB,
|
||||
TerminalClearScreenAction.CLEAR_SCREEN,
|
||||
SFTPCommandAction.SFTP_COMMAND,
|
||||
SwitchTabAction.SWITCH_TAB,
|
||||
)) {
|
||||
val action = actionManager.getAction(id) ?: continue
|
||||
|
||||
@@ -21,6 +21,7 @@ class KeyManager private constructor() {
|
||||
if (keyPair == OhKeyPair.empty) {
|
||||
return
|
||||
}
|
||||
keyPairs.remove(keyPair)
|
||||
keyPairs.add(keyPair)
|
||||
database.addKeyPair(keyPair)
|
||||
}
|
||||
|
||||
@@ -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 = NewHostTreeDialog(owner)
|
||||
hostTreeDialog.setFilter { it.host.protocol == Protocol.SSH }
|
||||
hostTreeDialog.setTreeName("KeyManagerPanel.SSHCopyIdTree")
|
||||
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>()
|
||||
|
||||
198
src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt
Normal file
198
src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt
Normal file
@@ -0,0 +1,198 @@
|
||||
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())
|
||||
.apply { enableFloatingToolbar = false }
|
||||
}
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,10 @@ class FileChooser {
|
||||
var allowsOtherFileTypes = true
|
||||
var canCreateDirectories = true
|
||||
var win32Filters = mutableListOf<Pair<String, List<String>>>()
|
||||
|
||||
/**
|
||||
* e.g. listOf("json")
|
||||
*/
|
||||
var osxAllowedFileTypes = emptyList<String>()
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,22 +113,11 @@ 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)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push keymaps: {}", keymapsContent)
|
||||
}
|
||||
gistFiles.add(GistFile("Keymaps", keymapsContent))
|
||||
val keymapsContent = encodeKeymaps()
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push keymaps: {}", keymapsContent)
|
||||
}
|
||||
gistFiles.add(GistFile("Keymaps", keymapsContent))
|
||||
}
|
||||
|
||||
if (gistFiles.isEmpty()) {
|
||||
|
||||
299
src/main/kotlin/app/termora/sync/SafetySyncer.kt
Normal file
299
src/main/kotlin/app/termora/sync/SafetySyncer.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ enum class SyncType {
|
||||
GitLab,
|
||||
GitHub,
|
||||
Gitee,
|
||||
WebDAV,
|
||||
}
|
||||
|
||||
enum class SyncRange {
|
||||
|
||||
@@ -15,6 +15,7 @@ class SyncerProvider private constructor() {
|
||||
SyncType.GitHub -> GitHubSyncer.getInstance()
|
||||
SyncType.Gitee -> GiteeSyncer.getInstance()
|
||||
SyncType.GitLab -> GitLabSyncer.getInstance()
|
||||
SyncType.WebDAV -> WebDAVSyncer.getInstance()
|
||||
}
|
||||
}
|
||||
}
|
||||
152
src/main/kotlin/app/termora/sync/WebDAVSyncer.kt
Normal file
152
src/main/kotlin/app/termora/sync/WebDAVSyncer.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -485,9 +485,11 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
|
||||
val m = args.first()
|
||||
if (m == '6') {
|
||||
val position = terminal.getCursorModel().getPosition()
|
||||
ptyConnector.write("${ControlCharacters.ESC}[${position.y};${position.x}R")
|
||||
val bytes = "${ControlCharacters.ESC}[${position.y};${position.x}R".toByteArray(ptyConnector.getCharset())
|
||||
ptyConnector.write(bytes)
|
||||
} else if (m == '5') {
|
||||
ptyConnector.write("${ControlCharacters.ESC}[0n")
|
||||
val bytes = "${ControlCharacters.ESC}[0n".toByteArray(ptyConnector.getCharset())
|
||||
ptyConnector.write(bytes)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -689,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)
|
||||
@@ -922,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ data class CursorStore(
|
||||
*/
|
||||
val textStyle: TextStyle,
|
||||
/**
|
||||
* 如果为 null 表示没有设置
|
||||
*
|
||||
* @see [DataKey.AutoWrapMode]
|
||||
*/
|
||||
val autoWarpMode: Boolean,
|
||||
val autoWarpMode: Boolean?,
|
||||
/**
|
||||
* @see [DataKey.OriginMode]
|
||||
*/
|
||||
|
||||
68
src/main/kotlin/app/termora/terminal/CursorStoreStores.kt
Normal file
68
src/main/kotlin/app/termora/terminal/CursorStoreStores.kt
Normal file
@@ -0,0 +1,68 @@
|
||||
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)
|
||||
if (cursorStore.autoWarpMode != null) {
|
||||
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 = if (terminalModel.hasData(DataKey.AutoWrapMode)) terminalModel.getData(DataKey.AutoWrapMode) else null,
|
||||
originMode = terminalModel.isOriginMode(),
|
||||
graphicCharacterSet = graphicCharacterSet.copy(characterSets = characterSets),
|
||||
)
|
||||
|
||||
terminalModel.setData(DataKey.SaveCursor, cursorStore)
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Save Cursor (DECSC). $cursorStore")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,13 @@ class DataKey<T : Any>(val clazz: KClass<T>) {
|
||||
*/
|
||||
val Workdir = DataKey(String::class)
|
||||
|
||||
/**
|
||||
* OSC 1337 CurrentDir
|
||||
*
|
||||
* https://iterm2.com/documentation-escape-codes.html
|
||||
*/
|
||||
val CurrentDir = DataKey(String::class)
|
||||
|
||||
/**
|
||||
* true: alternate keypad.
|
||||
* false: Normal Keypad (DECKPNM)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
class DeviceControlProcessor(private val terminal: Terminal) : Processor {
|
||||
private val args = StringBuilder()
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(DeviceControlProcessor::class.java)
|
||||
}
|
||||
|
||||
|
||||
override fun process(ch: Char): ProcessorState {
|
||||
val state = when (ch) {
|
||||
ControlCharacters.ST -> {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Ignore DCS: {}", args)
|
||||
}
|
||||
TerminalState.READY
|
||||
}
|
||||
|
||||
else -> {
|
||||
args.append(ch)
|
||||
TerminalState.DCS
|
||||
}
|
||||
}
|
||||
|
||||
if (state == TerminalState.READY) {
|
||||
args.clear()
|
||||
}
|
||||
|
||||
return state
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
class DeviceControlStringProcessor(terminal: Terminal, reader: TerminalReader) : AbstractProcessor(terminal, reader) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(DeviceControlStringProcessor::class.java)
|
||||
}
|
||||
|
||||
private val systemCommandSequence = SystemCommandSequence()
|
||||
|
||||
override fun process(ch: Char): ProcessorState {
|
||||
// 回退回去,然后重新读取出来
|
||||
reader.addFirst(ch)
|
||||
|
||||
do {
|
||||
|
||||
if (systemCommandSequence.process(reader.read())) {
|
||||
break
|
||||
}
|
||||
|
||||
// 如果没有检测到结束,那么退出重新来
|
||||
if (reader.isEmpty()) {
|
||||
return TerminalState.DCS
|
||||
}
|
||||
|
||||
} while (reader.isNotEmpty())
|
||||
|
||||
processCommand(systemCommandSequence.getCommand())
|
||||
|
||||
systemCommandSequence.reset()
|
||||
|
||||
return TerminalState.READY
|
||||
}
|
||||
|
||||
|
||||
private fun processCommand(command: String) {
|
||||
if (command.isEmpty()) {
|
||||
return
|
||||
}
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Cannot process command: {}", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,9 +128,9 @@ class EscapeSequenceProcessor(terminal: Terminal, reader: TerminalReader) : Abst
|
||||
|
||||
}
|
||||
|
||||
// TODO Device Control String (DCS is 0x90).
|
||||
// Device Control String (DCS is 0x90).
|
||||
'P' -> {
|
||||
|
||||
state = TerminalState.DCS
|
||||
}
|
||||
|
||||
// Start of Guarded Area (SPA is 0x96).
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.event.KeyEvent
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataListener {
|
||||
|
||||
private val mapping = mutableMapOf<TerminalKeyEvent, String>()
|
||||
private val nothing = String()
|
||||
private val nothing = StringUtils.EMPTY
|
||||
|
||||
init {
|
||||
|
||||
@@ -27,6 +28,7 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
|
||||
|
||||
configureLeftRight()
|
||||
|
||||
// Ctrl + C
|
||||
putCode(TerminalKeyEvent(keyCode = 8), String(byteArrayOf(127)))
|
||||
|
||||
// Enter
|
||||
@@ -38,15 +40,15 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
|
||||
|
||||
|
||||
// Page Up
|
||||
putCode(TerminalKeyEvent(keyCode = 0x21), encode = "${ControlCharacters.ESC}[5~")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_PAGE_UP), encode = "${ControlCharacters.ESC}[5~")
|
||||
// Page Down
|
||||
putCode(TerminalKeyEvent(keyCode = 0x22), encode = "${ControlCharacters.ESC}[6~")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_PAGE_DOWN), encode = "${ControlCharacters.ESC}[6~")
|
||||
|
||||
|
||||
// Insert
|
||||
putCode(TerminalKeyEvent(keyCode = 0x9B), encode = "${ControlCharacters.ESC}[2~")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_INSERT), encode = "${ControlCharacters.ESC}[2~")
|
||||
// Delete
|
||||
putCode(TerminalKeyEvent(keyCode = 0x7F), encode = "${ControlCharacters.ESC}[3~")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_DELETE), encode = "${ControlCharacters.ESC}[3~")
|
||||
|
||||
// Function Keys
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F1), encode = "${ControlCharacters.ESC}OP")
|
||||
@@ -84,26 +86,29 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
|
||||
|
||||
fun arrowKeysApplicationSequences() {
|
||||
// Up
|
||||
putCode(TerminalKeyEvent(keyCode = 0x26), encode = "${ControlCharacters.ESC}OA")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_UP), encode = "${ControlCharacters.ESC}OA")
|
||||
// Down
|
||||
putCode(TerminalKeyEvent(keyCode = 0x28), encode = "${ControlCharacters.ESC}OB")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_DOWN), encode = "${ControlCharacters.ESC}OB")
|
||||
// Left
|
||||
putCode(TerminalKeyEvent(keyCode = 0x25), encode = "${ControlCharacters.ESC}OD")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_LEFT), encode = "${ControlCharacters.ESC}OD")
|
||||
// Right
|
||||
putCode(TerminalKeyEvent(keyCode = 0x27), encode = "${ControlCharacters.ESC}OC")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_RIGHT), encode = "${ControlCharacters.ESC}OC")
|
||||
}
|
||||
|
||||
fun arrowKeysAnsiCursorSequences() {
|
||||
// Up
|
||||
putCode(TerminalKeyEvent(keyCode = 0x26), encode = "${ControlCharacters.ESC}[A")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_UP), encode = "${ControlCharacters.ESC}[A")
|
||||
// Down
|
||||
putCode(TerminalKeyEvent(keyCode = 0x28), encode = "${ControlCharacters.ESC}[B")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_DOWN), encode = "${ControlCharacters.ESC}[B")
|
||||
// Left
|
||||
putCode(TerminalKeyEvent(keyCode = 0x25), encode = "${ControlCharacters.ESC}[D")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_LEFT), encode = "${ControlCharacters.ESC}[D")
|
||||
// Right
|
||||
putCode(TerminalKeyEvent(keyCode = 0x27), encode = "${ControlCharacters.ESC}[C")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_RIGHT), encode = "${ControlCharacters.ESC}[C")
|
||||
}
|
||||
|
||||
/**
|
||||
* Alt + Left/Right
|
||||
*/
|
||||
fun configureLeftRight() {
|
||||
if (SystemInfo.isMacOS) {
|
||||
putCode(
|
||||
@@ -141,32 +146,32 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
|
||||
|
||||
fun keypadApplicationSequences() {
|
||||
// Up
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE0), encode = "${ControlCharacters.ESC}OA")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_UP), encode = "${ControlCharacters.ESC}OA")
|
||||
// Down
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE1), encode = "${ControlCharacters.ESC}OB")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_DOWN), encode = "${ControlCharacters.ESC}OB")
|
||||
// Left
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE2), encode = "${ControlCharacters.ESC}OD")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_LEFT), encode = "${ControlCharacters.ESC}OD")
|
||||
// Right
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE3), encode = "${ControlCharacters.ESC}OC")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_RIGHT), encode = "${ControlCharacters.ESC}OC")
|
||||
// Home
|
||||
putCode(TerminalKeyEvent(keyCode = 0x24), encode = "${ControlCharacters.ESC}OH")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_HOME), encode = "${ControlCharacters.ESC}OH")
|
||||
// End
|
||||
putCode(TerminalKeyEvent(keyCode = 0x23), encode = "${ControlCharacters.ESC}OF")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_END), encode = "${ControlCharacters.ESC}OF")
|
||||
}
|
||||
|
||||
fun keypadAnsiSequences() {
|
||||
// Up
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE0), encode = "${ControlCharacters.ESC}[A")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_UP), encode = "${ControlCharacters.ESC}[A")
|
||||
// Down
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE1), encode = "${ControlCharacters.ESC}[B")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_DOWN), encode = "${ControlCharacters.ESC}[B")
|
||||
// Left
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE2), encode = "${ControlCharacters.ESC}[D")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_LEFT), encode = "${ControlCharacters.ESC}[D")
|
||||
// Right
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE3), encode = "${ControlCharacters.ESC}[C")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_RIGHT), encode = "${ControlCharacters.ESC}[C")
|
||||
// Home
|
||||
putCode(TerminalKeyEvent(keyCode = 0x24), encode = "${ControlCharacters.ESC}[H")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_HOME), encode = "${ControlCharacters.ESC}[H")
|
||||
// End
|
||||
putCode(TerminalKeyEvent(keyCode = 0x23), encode = "${ControlCharacters.ESC}[F")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_END), encode = "${ControlCharacters.ESC}[F")
|
||||
}
|
||||
|
||||
override fun onChanged(key: DataKey<*>, data: Any) {
|
||||
|
||||
@@ -4,10 +4,12 @@ import org.apache.commons.codec.binary.Base64
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.io.StringReader
|
||||
import java.util.*
|
||||
|
||||
class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader) :
|
||||
AbstractProcessor(terminal, reader) {
|
||||
private val args = StringBuilder()
|
||||
private val systemCommandSequence = SystemCommandSequence()
|
||||
private val colorPalette get() = terminal.getTerminalModel().getColorPalette()
|
||||
|
||||
companion object {
|
||||
@@ -20,14 +22,7 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
|
||||
|
||||
do {
|
||||
|
||||
val c = reader.read()
|
||||
args.append(c)
|
||||
if (c == ControlCharacters.BEL || c == ControlCharacters.ST) {
|
||||
args.deleteAt(args.lastIndex)
|
||||
break
|
||||
} else if (c == '\\' && args.length >= 2 && args[args.length - 2] == ControlCharacters.ESC) {
|
||||
args.deleteAt(args.lastIndex)
|
||||
args.deleteAt(args.lastIndex)
|
||||
if (systemCommandSequence.process(reader.read())) {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -42,7 +37,7 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
|
||||
// process osc
|
||||
processOperatingSystemCommandProcessor()
|
||||
|
||||
args.clear()
|
||||
systemCommandSequence.reset()
|
||||
|
||||
return TerminalState.READY
|
||||
}
|
||||
@@ -52,6 +47,7 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
|
||||
* https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
|
||||
*/
|
||||
private fun processOperatingSystemCommandProcessor() {
|
||||
val args = systemCommandSequence.getCommand()
|
||||
val idx = args.indexOfFirst { it == ';' }
|
||||
if (idx == -1) {
|
||||
return
|
||||
@@ -91,6 +87,19 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
|
||||
}
|
||||
}
|
||||
|
||||
// https://iterm2.com/documentation-escape-codes.html
|
||||
1337 -> {
|
||||
val properties = Properties()
|
||||
properties.load(StringReader(suffix))
|
||||
if (properties.containsKey("CurrentDir")) {
|
||||
val currentDir = properties.getProperty("CurrentDir")
|
||||
terminal.getTerminalModel().setData(DataKey.CurrentDir, currentDir)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("CurrentDir: $currentDir")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 11: background color
|
||||
// 10: foreground color
|
||||
11, 10 -> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.Charset
|
||||
|
||||
|
||||
interface PtyConnector {
|
||||
@@ -15,15 +16,18 @@ interface PtyConnector {
|
||||
*/
|
||||
fun write(buffer: ByteArray, offset: Int, len: Int)
|
||||
|
||||
/**
|
||||
* 写入数组。
|
||||
*
|
||||
* 如果要写入 String 字符串,请通过 [getCharset] 编码。
|
||||
*/
|
||||
fun write(buffer: ByteArray) {
|
||||
write(buffer, 0, buffer.size)
|
||||
}
|
||||
|
||||
fun write(buffer: String) {
|
||||
if (buffer.isEmpty()) return
|
||||
write(buffer.toByteArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入单个 Int
|
||||
*/
|
||||
fun write(buffer: Int) {
|
||||
write(ByteBuffer.allocate(Integer.BYTES).putInt(buffer).flip().array())
|
||||
}
|
||||
@@ -43,4 +47,8 @@ interface PtyConnector {
|
||||
*/
|
||||
fun close()
|
||||
|
||||
/**
|
||||
* 编码
|
||||
*/
|
||||
fun getCharset(): Charset = Charsets.UTF_8
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import java.nio.charset.Charset
|
||||
|
||||
open class PtyConnectorDelegate(
|
||||
@Volatile var ptyConnector: PtyConnector? = null
|
||||
) : PtyConnector {
|
||||
@@ -26,5 +28,7 @@ open class PtyConnectorDelegate(
|
||||
ptyConnector = null
|
||||
}
|
||||
|
||||
|
||||
override fun getCharset(): Charset {
|
||||
return ptyConnector?.getCharset() ?: super.getCharset()
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,6 @@ class PtyProcessConnector(private val process: PtyProcess, private val charset:
|
||||
output.flush()
|
||||
}
|
||||
|
||||
override fun write(buffer: String) {
|
||||
write(buffer.toByteArray(charset))
|
||||
}
|
||||
|
||||
override fun resize(rows: Int, cols: Int) {
|
||||
process.winSize = WinSize(cols, rows)
|
||||
@@ -38,5 +35,7 @@ class PtyProcessConnector(private val process: PtyProcess, private val charset:
|
||||
process.destroyForcibly()
|
||||
}
|
||||
|
||||
|
||||
override fun getCharset(): Charset {
|
||||
return charset
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package app.termora.terminal
|
||||
|
||||
class SystemCommandSequence {
|
||||
private var isTerminated = false
|
||||
private val command = StringBuilder()
|
||||
|
||||
/**
|
||||
* @return 返回 true 表示处理完毕
|
||||
*/
|
||||
fun process(c: Char): Boolean {
|
||||
|
||||
if (isTerminated) {
|
||||
throw UnsupportedOperationException("Cannot be processed, call the reset method")
|
||||
}
|
||||
|
||||
command.append(c)
|
||||
if (c == ControlCharacters.BEL || c == ControlCharacters.ST) {
|
||||
command.deleteAt(command.lastIndex)
|
||||
isTerminated = true
|
||||
} else if (c == '\\' && command.length >= 2 && command[command.length - 2] == ControlCharacters.ESC) {
|
||||
command.deleteAt(command.lastIndex)
|
||||
command.deleteAt(command.lastIndex)
|
||||
isTerminated = true
|
||||
}
|
||||
|
||||
return isTerminated
|
||||
}
|
||||
|
||||
fun getCommand(): String {
|
||||
return command.toString()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
isTerminated = false
|
||||
command.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Toolkit
|
||||
import kotlin.reflect.cast
|
||||
@@ -8,7 +9,7 @@ open class TerminalModelImpl(private val terminal: Terminal) : TerminalModel {
|
||||
private var rows: Int = 27
|
||||
private var cols: Int = 80
|
||||
private val data = mutableMapOf<DataKey<*>, Any>()
|
||||
private val listeners = mutableListOf<DataListener>()
|
||||
private var listeners = emptyArray<DataListener>()
|
||||
private val colorPalette = ColorPaletteImpl(terminal)
|
||||
|
||||
companion object {
|
||||
@@ -92,11 +93,11 @@ open class TerminalModelImpl(private val terminal: Terminal) : TerminalModel {
|
||||
}
|
||||
|
||||
override fun addDataListener(listener: DataListener) {
|
||||
listeners.add(listener)
|
||||
listeners = ArrayUtils.add(listeners, listener)
|
||||
}
|
||||
|
||||
override fun removeDataListener(listener: DataListener) {
|
||||
listeners.remove(listener)
|
||||
listeners = ArrayUtils.removeElement(listeners, listener)
|
||||
}
|
||||
|
||||
override fun bell() {
|
||||
@@ -129,9 +130,8 @@ open class TerminalModelImpl(private val terminal: Terminal) : TerminalModel {
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
protected fun <T : Any> fireDataChanged(key: DataKey<T>, data: T) {
|
||||
val size = listeners.size
|
||||
for (i in 0 until size) {
|
||||
listeners.getOrNull(i)?.onChanged(key, data)
|
||||
for (listener in listeners) {
|
||||
listener.onChanged(key, data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ private class MyProcessor(private val terminal: Terminal, reader: TerminalReader
|
||||
TerminalState.CSI to ControlSequenceIntroducerProcessor(terminal, reader),
|
||||
TerminalState.OSC to OperatingSystemCommandProcessor(terminal, reader),
|
||||
TerminalState.ESC_LPAREN to EscapeDesignateCharacterSetProcessor(terminal, reader),
|
||||
TerminalState.DCS to DeviceControlProcessor(terminal),
|
||||
TerminalState.DCS to DeviceControlStringProcessor(terminal, reader),
|
||||
TerminalState.Text to TextProcessor(terminal, reader),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package app.termora.terminal.panel
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.panel.vw.NvidiaSMIVisualWindow
|
||||
import app.termora.terminal.panel.vw.SystemInformationVisualWindow
|
||||
import com.formdev.flatlaf.extras.components.FlatToolBar
|
||||
import com.formdev.flatlaf.ui.FlatRoundBorder
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.event.ActionListener
|
||||
import javax.swing.JButton
|
||||
|
||||
class FloatingToolbarPanel : FlatToolBar(), Disposable {
|
||||
private val floatingToolbarEnable get() = Database.getDatabase().terminal.floatingToolbar
|
||||
private var closed = false
|
||||
|
||||
companion object {
|
||||
|
||||
val FloatingToolbar = DataKey(FloatingToolbarPanel::class)
|
||||
val isPined get() = pinAction.isSelected
|
||||
|
||||
private val pinAction by lazy {
|
||||
object : AnAction() {
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private val key = "FloatingToolbar.pined"
|
||||
|
||||
init {
|
||||
setStateAction()
|
||||
isSelected = properties.getString(key, StringUtils.EMPTY).toBoolean()
|
||||
}
|
||||
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
isSelected = !isSelected
|
||||
properties.putString(key, isSelected.toString())
|
||||
actionListeners.forEach { it.actionPerformed(evt) }
|
||||
|
||||
if (isSelected) {
|
||||
TerminalPanelFactory.getAllTerminalPanel().forEach {
|
||||
it.getData(FloatingToolbar)?.triggerShow()
|
||||
}
|
||||
} else {
|
||||
// 触发者的不隐藏
|
||||
val c = evt.getData(FloatingToolbar)
|
||||
TerminalPanelFactory.getAllTerminalPanel().forEach {
|
||||
val e = it.getData(FloatingToolbar)
|
||||
if (c != e) {
|
||||
e?.triggerHide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
border = FlatRoundBorder()
|
||||
isOpaque = false
|
||||
isFocusable = false
|
||||
isFloatable = false
|
||||
isVisible = false
|
||||
|
||||
if (floatingToolbarEnable) {
|
||||
if (pinAction.isSelected) {
|
||||
isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
initActions()
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
border = FlatRoundBorder()
|
||||
}
|
||||
|
||||
fun triggerShow() {
|
||||
if (!floatingToolbarEnable || closed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isVisible == false) {
|
||||
isVisible = true
|
||||
firePropertyChange("visible", false, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerHide() {
|
||||
if (floatingToolbarEnable && !closed) {
|
||||
if (pinAction.isSelected) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (isVisible == true) {
|
||||
isVisible = false
|
||||
firePropertyChange("visible", true, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initActions() {
|
||||
// Pin
|
||||
add(initPinActionButton())
|
||||
|
||||
// 服务器信息
|
||||
add(initServerInfoActionButton())
|
||||
|
||||
// Nvidia 显卡信息
|
||||
add(initNvidiaSMIActionButton())
|
||||
|
||||
// 重连
|
||||
add(initReconnectActionButton())
|
||||
|
||||
// 关闭
|
||||
add(initCloseActionButton())
|
||||
}
|
||||
|
||||
private fun initServerInfoActionButton(): JButton {
|
||||
val btn = JButton(Icons.infoOutline)
|
||||
btn.toolTipText = I18n.getString("termora.visual-window.system-information")
|
||||
btn.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val tab = evt.getData(DataProviders.TerminalTab) ?: return
|
||||
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
|
||||
|
||||
if (tab !is SSHTerminalTab) {
|
||||
terminalPanel.toast(I18n.getString("termora.floating-toolbar.not-supported"))
|
||||
return
|
||||
}
|
||||
|
||||
for (window in terminalPanel.getVisualWindows()) {
|
||||
if (window is SystemInformationVisualWindow) {
|
||||
terminalPanel.moveToFront(window)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val visualWindowPanel = SystemInformationVisualWindow(tab, terminalPanel)
|
||||
terminalPanel.addVisualWindow(visualWindowPanel)
|
||||
|
||||
}
|
||||
})
|
||||
return btn
|
||||
}
|
||||
|
||||
private fun initNvidiaSMIActionButton(): JButton {
|
||||
val btn = JButton(Icons.nvidia)
|
||||
btn.toolTipText = I18n.getString("termora.visual-window.nvidia-smi")
|
||||
btn.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val tab = evt.getData(DataProviders.TerminalTab) ?: return
|
||||
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
|
||||
|
||||
if (tab !is SSHTerminalTab) {
|
||||
terminalPanel.toast(I18n.getString("termora.floating-toolbar.not-supported"))
|
||||
return
|
||||
}
|
||||
|
||||
for (window in terminalPanel.getVisualWindows()) {
|
||||
if (window is NvidiaSMIVisualWindow) {
|
||||
terminalPanel.moveToFront(window)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val visualWindowPanel = NvidiaSMIVisualWindow(tab, terminalPanel)
|
||||
terminalPanel.addVisualWindow(visualWindowPanel)
|
||||
|
||||
}
|
||||
})
|
||||
return btn
|
||||
}
|
||||
|
||||
private fun initPinActionButton(): JButton {
|
||||
val btn = JButton(Icons.pin)
|
||||
btn.isSelected = pinAction.isSelected
|
||||
|
||||
val actionListener = ActionListener { btn.isSelected = pinAction.isSelected }
|
||||
pinAction.addActionListener(actionListener)
|
||||
btn.addActionListener(pinAction)
|
||||
|
||||
Disposer.register(this, object : Disposable {
|
||||
override fun dispose() {
|
||||
btn.removeActionListener(pinAction)
|
||||
pinAction.removeActionListener(actionListener)
|
||||
}
|
||||
})
|
||||
|
||||
return btn
|
||||
}
|
||||
|
||||
private fun initCloseActionButton(): JButton {
|
||||
val btn = JButton(Icons.closeSmall)
|
||||
btn.pressedIcon = Icons.closeSmallHovered
|
||||
btn.rolloverIcon = Icons.closeSmallHovered
|
||||
btn.addActionListener {
|
||||
closed = true
|
||||
triggerHide()
|
||||
}
|
||||
return btn
|
||||
}
|
||||
|
||||
private fun initReconnectActionButton(): JButton {
|
||||
val btn = JButton(Icons.refresh)
|
||||
btn.toolTipText = I18n.getString("termora.tabbed.contextmenu.reconnect")
|
||||
|
||||
btn.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val tab = evt.getData(DataProviders.TerminalTab) ?: return
|
||||
if (tab.canReconnect()) {
|
||||
tab.reconnect()
|
||||
}
|
||||
}
|
||||
})
|
||||
return btn
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
119
src/main/kotlin/app/termora/terminal/panel/TerminalBlink.kt
Normal file
119
src/main/kotlin/app/termora/terminal/panel/TerminalBlink.kt
Normal file
@@ -0,0 +1,119 @@
|
||||
package app.termora.terminal.panel
|
||||
|
||||
import app.termora.ApplicationScope
|
||||
import app.termora.Database
|
||||
import app.termora.Disposable
|
||||
import app.termora.terminal.*
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class TerminalBlink(terminal: Terminal) : Disposable {
|
||||
|
||||
|
||||
private var cursorBlinkJob: Job? = null
|
||||
private val terminalSettings get() = Database.getDatabase().terminal
|
||||
private val isDisposed = AtomicBoolean(false)
|
||||
private val globalBlink get() = GlobalBlink.getInstance()
|
||||
private val coroutineScope get() = globalBlink.coroutineScope
|
||||
|
||||
/**
|
||||
* 返回 true 表示可以显示某些内容 [TextStyle.blink]
|
||||
*/
|
||||
val blink get() = globalBlink.blink
|
||||
|
||||
/**
|
||||
* 这个与 [blink] 不同的是它是控制光标的
|
||||
*/
|
||||
@Volatile
|
||||
var cursorBlink = true
|
||||
private set
|
||||
|
||||
init {
|
||||
|
||||
reset()
|
||||
|
||||
// 如果有写入,那么显示光标 N 秒
|
||||
terminal.getTerminalModel().addDataListener(object : DataListener {
|
||||
override fun onChanged(key: DataKey<*>, data: Any) {
|
||||
// 写入后,重置光标
|
||||
if (key == VisualTerminal.Written) {
|
||||
reset()
|
||||
} else if (key == TerminalPanel.Focused) {
|
||||
// 获取焦点的一瞬间则立即重置
|
||||
if (data == true) {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
private fun reset() {
|
||||
if (isDisposed.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
cursorBlink = true
|
||||
cursorBlinkJob?.cancel()
|
||||
cursorBlinkJob = coroutineScope.launch {
|
||||
while (coroutineScope.isActive) {
|
||||
|
||||
delay(500.milliseconds)
|
||||
|
||||
if (isDisposed.get()) {
|
||||
break
|
||||
}
|
||||
|
||||
// 如果开启了光标闪烁才闪速
|
||||
cursorBlink = if (terminalSettings.cursorBlink) {
|
||||
!cursorBlink
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (isDisposed.compareAndSet(false, true)) {
|
||||
cursorBlinkJob?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class GlobalBlink : Disposable {
|
||||
|
||||
companion object {
|
||||
fun getInstance(): GlobalBlink {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(GlobalBlink::class) { GlobalBlink() }
|
||||
}
|
||||
}
|
||||
|
||||
val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) }
|
||||
|
||||
/**
|
||||
* 返回 true 表示可以显示某些内容 [TextStyle.blink]
|
||||
*/
|
||||
@Volatile
|
||||
var blink = true
|
||||
private set
|
||||
|
||||
|
||||
init {
|
||||
coroutineScope.launch {
|
||||
while (coroutineScope.isActive) {
|
||||
delay(500)
|
||||
blink = !blink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user