mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 02:12:58 +08:00
chore!: migrate to version 2.x
This commit is contained in:
994
src/main/java/app/termora/AntPathMatcher.java
Normal file
994
src/main/java/app/termora/AntPathMatcher.java
Normal file
@@ -0,0 +1,994 @@
|
||||
/*
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package app.termora;
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.StringTokenizer;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* <p>Part of this mapping code has been kindly borrowed from <a href="https://ant.apache.org">Apache Ant</a>.
|
||||
*
|
||||
* <p>The mapping matches URLs using the following rules:<br>
|
||||
* <ul>
|
||||
* <li>{@code ?} matches one character</li>
|
||||
* <li>{@code *} matches zero or more characters</li>
|
||||
* <li>{@code **} matches zero or more <em>directories</em> in a path</li>
|
||||
* <li>{@code {spring:[a-z]+}} matches the regexp {@code [a-z]+} as a path variable named "spring"</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>Examples</h3>
|
||||
* <ul>
|
||||
* <li>{@code com/t?st.jsp} — matches {@code com/test.jsp} but also
|
||||
* {@code com/tast.jsp} or {@code com/txst.jsp}</li>
|
||||
* <li>{@code com/*.jsp} — matches all {@code .jsp} files in the
|
||||
* {@code com} directory</li>
|
||||
* <li><code>com/**/test.jsp</code> — matches all {@code test.jsp}
|
||||
* files underneath the {@code com} path</li>
|
||||
* <li><code>org/springframework/**/*.jsp</code> — matches all
|
||||
* {@code .jsp} files underneath the {@code org/springframework} path</li>
|
||||
* <li><code>org/**/servlet/bla.jsp</code> — matches
|
||||
* {@code org/springframework/servlet/bla.jsp} but also
|
||||
* {@code org/springframework/testing/servlet/bla.jsp} and {@code org/servlet/bla.jsp}</li>
|
||||
* <li>{@code com/{filename:\\w+}.jsp} will match {@code com/test.jsp} and assign the value {@code test}
|
||||
* to the {@code filename} variable</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><strong>Note:</strong> a pattern and a path must both be absolute or must
|
||||
* both be relative in order for the two to match. Therefore, it is recommended
|
||||
* that users of this implementation to sanitize patterns in order to prefix
|
||||
* them with "/" as it makes sense in the context in which they're used.
|
||||
*
|
||||
* @author Alef Arendsen
|
||||
* @author Juergen Hoeller
|
||||
* @author Rob Harrop
|
||||
* @author Arjen Poutsma
|
||||
* @author Rossen Stoyanchev
|
||||
* @author Sam Brannen
|
||||
* @author Vladislav Kisel
|
||||
* @since 16.07.2003
|
||||
*/
|
||||
public class AntPathMatcher {
|
||||
|
||||
/**
|
||||
* Default path separator: "/".
|
||||
*/
|
||||
public static final String DEFAULT_PATH_SEPARATOR = "/";
|
||||
|
||||
private static final int CACHE_TURNOFF_THRESHOLD = 65536;
|
||||
|
||||
private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{[^/]+?\\}");
|
||||
|
||||
private static final char[] WILDCARD_CHARS = {'*', '?', '{'};
|
||||
|
||||
|
||||
private String pathSeparator;
|
||||
|
||||
private PathSeparatorPatternCache pathSeparatorPatternCache;
|
||||
|
||||
private boolean caseSensitive = true;
|
||||
|
||||
private boolean trimTokens = false;
|
||||
|
||||
private volatile @Nullable Boolean cachePatterns;
|
||||
|
||||
private final Map<String, String[]> tokenizedPatternCache = new ConcurrentHashMap<>(256);
|
||||
|
||||
final Map<String, AntPathStringMatcher> stringMatcherCache = new ConcurrentHashMap<>(256);
|
||||
|
||||
|
||||
/**
|
||||
* Create a new instance with the {@link #DEFAULT_PATH_SEPARATOR}.
|
||||
*/
|
||||
public AntPathMatcher() {
|
||||
this.pathSeparator = DEFAULT_PATH_SEPARATOR;
|
||||
this.pathSeparatorPatternCache = new PathSeparatorPatternCache(DEFAULT_PATH_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient, alternative constructor to use with a custom path separator.
|
||||
*
|
||||
* @param pathSeparator the path separator to use, must not be {@code null}.
|
||||
* @since 4.1
|
||||
*/
|
||||
public AntPathMatcher(String pathSeparator) {
|
||||
this.pathSeparator = pathSeparator;
|
||||
this.pathSeparatorPatternCache = new PathSeparatorPatternCache(pathSeparator);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the path separator to use for pattern parsing.
|
||||
* <p>Default is "/", as in Ant.
|
||||
*/
|
||||
public void setPathSeparator(@Nullable String pathSeparator) {
|
||||
this.pathSeparator = (pathSeparator != null ? pathSeparator : DEFAULT_PATH_SEPARATOR);
|
||||
this.pathSeparatorPatternCache = new PathSeparatorPatternCache(this.pathSeparator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify whether to perform pattern matching in a case-sensitive fashion.
|
||||
* <p>Default is {@code true}. Switch this to {@code false} for case-insensitive matching.
|
||||
*
|
||||
* @since 4.2
|
||||
*/
|
||||
public void setCaseSensitive(boolean caseSensitive) {
|
||||
this.caseSensitive = caseSensitive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify whether to trim tokenized paths and patterns.
|
||||
* <p>Default is {@code false}.
|
||||
*/
|
||||
public void setTrimTokens(boolean trimTokens) {
|
||||
this.trimTokens = trimTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify whether to cache parsed pattern metadata for patterns passed
|
||||
* into this matcher's {@link #match} method. A value of {@code true}
|
||||
* activates an unlimited pattern cache; a value of {@code false} turns
|
||||
* the pattern cache off completely.
|
||||
* <p>Default is for the cache to be on, but with the variant to automatically
|
||||
* turn it off when encountering too many patterns to cache at runtime
|
||||
* (the threshold is 65536), assuming that arbitrary permutations of patterns
|
||||
* are coming in, with little chance for encountering a recurring pattern.
|
||||
*
|
||||
* @see #getStringMatcher(String)
|
||||
* @since 4.0.1
|
||||
*/
|
||||
public void setCachePatterns(boolean cachePatterns) {
|
||||
this.cachePatterns = cachePatterns;
|
||||
}
|
||||
|
||||
private void deactivatePatternCache() {
|
||||
this.cachePatterns = false;
|
||||
this.tokenizedPatternCache.clear();
|
||||
this.stringMatcherCache.clear();
|
||||
}
|
||||
|
||||
|
||||
public boolean isPattern(@Nullable String path) {
|
||||
if (path == null) {
|
||||
return false;
|
||||
}
|
||||
boolean uriVar = false;
|
||||
for (int i = 0; i < path.length(); i++) {
|
||||
char c = path.charAt(i);
|
||||
if (c == '*' || c == '?') {
|
||||
return true;
|
||||
}
|
||||
if (c == '{') {
|
||||
uriVar = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '}' && uriVar) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public boolean match(String pattern, String path) {
|
||||
return doMatch(pattern, path, true, null);
|
||||
}
|
||||
|
||||
|
||||
public boolean matchStart(String pattern, String path) {
|
||||
return doMatch(pattern, path, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually match the given {@code path} against the given {@code pattern}.
|
||||
*
|
||||
* @param pattern the pattern to match against
|
||||
* @param path the path to test
|
||||
* @param fullMatch whether a full pattern match is required (else a pattern match
|
||||
* as far as the given base path goes is sufficient)
|
||||
* @return {@code true} if the supplied {@code path} matched, {@code false} if it didn't
|
||||
*/
|
||||
protected boolean doMatch(String pattern, @Nullable String path, boolean fullMatch,
|
||||
@Nullable Map<String, String> uriTemplateVariables) {
|
||||
|
||||
if (path == null || path.startsWith(this.pathSeparator) != pattern.startsWith(this.pathSeparator)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] pattDirs = tokenizePattern(pattern);
|
||||
if (fullMatch && this.caseSensitive && !isPotentialMatch(path, pattDirs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] pathDirs = tokenizePath(path);
|
||||
int pattIdxStart = 0;
|
||||
int pattIdxEnd = pattDirs.length - 1;
|
||||
int pathIdxStart = 0;
|
||||
int pathIdxEnd = pathDirs.length - 1;
|
||||
|
||||
// Match all elements up to the first **
|
||||
while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) {
|
||||
String pattDir = pattDirs[pattIdxStart];
|
||||
if ("**".equals(pattDir)) {
|
||||
break;
|
||||
}
|
||||
if (!matchStrings(pattDir, pathDirs[pathIdxStart], uriTemplateVariables)) {
|
||||
return false;
|
||||
}
|
||||
pattIdxStart++;
|
||||
pathIdxStart++;
|
||||
}
|
||||
|
||||
if (pathIdxStart > pathIdxEnd) {
|
||||
// Path is exhausted, only match if rest of pattern is * or **'s
|
||||
if (pattIdxStart > pattIdxEnd) {
|
||||
return (pattern.endsWith(this.pathSeparator) == path.endsWith(this.pathSeparator));
|
||||
}
|
||||
if (!fullMatch) {
|
||||
return true;
|
||||
}
|
||||
if (pattIdxStart == pattIdxEnd && pattDirs[pattIdxStart].equals("*") && path.endsWith(this.pathSeparator)) {
|
||||
return true;
|
||||
}
|
||||
for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
|
||||
if (!pattDirs[i].equals("**")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} else if (pattIdxStart > pattIdxEnd) {
|
||||
// String not exhausted, but pattern is. Failure.
|
||||
return false;
|
||||
} else if (!fullMatch && "**".equals(pattDirs[pattIdxStart])) {
|
||||
// Path start definitely matches due to "**" part in pattern.
|
||||
return true;
|
||||
}
|
||||
|
||||
// up to last '**'
|
||||
while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) {
|
||||
String pattDir = pattDirs[pattIdxEnd];
|
||||
if (pattDir.equals("**")) {
|
||||
break;
|
||||
}
|
||||
if (!matchStrings(pattDir, pathDirs[pathIdxEnd], uriTemplateVariables)) {
|
||||
return false;
|
||||
}
|
||||
if (pattIdxEnd == (pattDirs.length - 1) &&
|
||||
pattern.endsWith(this.pathSeparator) != path.endsWith(this.pathSeparator)) {
|
||||
return false;
|
||||
}
|
||||
pattIdxEnd--;
|
||||
pathIdxEnd--;
|
||||
}
|
||||
if (pathIdxStart > pathIdxEnd) {
|
||||
// String is exhausted
|
||||
for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
|
||||
if (!pattDirs[i].equals("**")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
while (pattIdxStart != pattIdxEnd && pathIdxStart <= pathIdxEnd) {
|
||||
int patIdxTmp = -1;
|
||||
for (int i = pattIdxStart + 1; i <= pattIdxEnd; i++) {
|
||||
if (pattDirs[i].equals("**")) {
|
||||
patIdxTmp = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (patIdxTmp == pattIdxStart + 1) {
|
||||
// '**/**' situation, so skip one
|
||||
pattIdxStart++;
|
||||
continue;
|
||||
}
|
||||
// Find the pattern between padIdxStart & padIdxTmp in str between
|
||||
// strIdxStart & strIdxEnd
|
||||
int patLength = (patIdxTmp - pattIdxStart - 1);
|
||||
int strLength = (pathIdxEnd - pathIdxStart + 1);
|
||||
int foundIdx = -1;
|
||||
|
||||
strLoop:
|
||||
for (int i = 0; i <= strLength - patLength; i++) {
|
||||
for (int j = 0; j < patLength; j++) {
|
||||
String subPat = pattDirs[pattIdxStart + j + 1];
|
||||
String subStr = pathDirs[pathIdxStart + i + j];
|
||||
if (!matchStrings(subPat, subStr, uriTemplateVariables)) {
|
||||
continue strLoop;
|
||||
}
|
||||
}
|
||||
foundIdx = pathIdxStart + i;
|
||||
break;
|
||||
}
|
||||
|
||||
if (foundIdx == -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
pattIdxStart = patIdxTmp;
|
||||
pathIdxStart = foundIdx + patLength;
|
||||
}
|
||||
|
||||
for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
|
||||
if (!pattDirs[i].equals("**")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isPotentialMatch(String path, String[] pattDirs) {
|
||||
if (!this.trimTokens) {
|
||||
int pos = 0;
|
||||
for (String pattDir : pattDirs) {
|
||||
int skipped = skipSeparator(path, pos, this.pathSeparator);
|
||||
pos += skipped;
|
||||
skipped = skipSegment(path, pos, pattDir);
|
||||
if (skipped < pattDir.length()) {
|
||||
return (skipped > 0 || (pattDir.length() > 0 && isWildcardChar(pattDir.charAt(0))));
|
||||
}
|
||||
pos += skipped;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private int skipSegment(String path, int pos, String prefix) {
|
||||
int skipped = 0;
|
||||
for (int i = 0; i < prefix.length(); i++) {
|
||||
char c = prefix.charAt(i);
|
||||
if (isWildcardChar(c)) {
|
||||
return skipped;
|
||||
}
|
||||
int currPos = pos + skipped;
|
||||
if (currPos >= path.length()) {
|
||||
return 0;
|
||||
}
|
||||
if (c == path.charAt(currPos)) {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
return skipped;
|
||||
}
|
||||
|
||||
private int skipSeparator(String path, int pos, String separator) {
|
||||
int skipped = 0;
|
||||
while (path.startsWith(separator, pos + skipped)) {
|
||||
skipped += separator.length();
|
||||
}
|
||||
return skipped;
|
||||
}
|
||||
|
||||
private boolean isWildcardChar(char c) {
|
||||
for (char candidate : WILDCARD_CHARS) {
|
||||
if (c == candidate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize the given path pattern into parts, based on this matcher's settings.
|
||||
* <p>Performs caching based on {@link #setCachePatterns}, delegating to
|
||||
* {@link #tokenizePath(String)} for the actual tokenization algorithm.
|
||||
*
|
||||
* @param pattern the pattern to tokenize
|
||||
* @return the tokenized pattern parts
|
||||
*/
|
||||
protected String[] tokenizePattern(String pattern) {
|
||||
String[] tokenized = null;
|
||||
Boolean cachePatterns = this.cachePatterns;
|
||||
if (cachePatterns == null || cachePatterns) {
|
||||
tokenized = this.tokenizedPatternCache.get(pattern);
|
||||
}
|
||||
if (tokenized == null) {
|
||||
tokenized = tokenizePath(pattern);
|
||||
if (cachePatterns == null && this.tokenizedPatternCache.size() >= CACHE_TURNOFF_THRESHOLD) {
|
||||
// Try to adapt to the runtime situation that we're encountering:
|
||||
// There are obviously too many different patterns coming in here...
|
||||
// So let's turn off the cache since the patterns are unlikely to be reoccurring.
|
||||
deactivatePatternCache();
|
||||
return tokenized;
|
||||
}
|
||||
if (cachePatterns == null || cachePatterns) {
|
||||
this.tokenizedPatternCache.put(pattern, tokenized);
|
||||
}
|
||||
}
|
||||
return tokenized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize the given path into parts, based on this matcher's settings.
|
||||
*
|
||||
* @param path the path to tokenize
|
||||
* @return the tokenized path parts
|
||||
*/
|
||||
protected String[] tokenizePath(String path) {
|
||||
return tokenizeToStringArray(path, this.pathSeparator, this.trimTokens, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a string matches against a pattern.
|
||||
*
|
||||
* @param pattern the pattern to match against (never {@code null})
|
||||
* @param str the String which must be matched against the pattern (never {@code null})
|
||||
* @return {@code true} if the string matches against the pattern, or {@code false} otherwise
|
||||
*/
|
||||
private boolean matchStrings(String pattern, String str,
|
||||
@Nullable Map<String, String> uriTemplateVariables) {
|
||||
|
||||
return getStringMatcher(pattern).matchStrings(str, uriTemplateVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build or retrieve an {@link AntPathStringMatcher} for the given pattern.
|
||||
* <p>The default implementation checks this AntPathMatcher's internal cache
|
||||
* (see {@link #setCachePatterns}), creating a new AntPathStringMatcher instance
|
||||
* if no cached copy is found.
|
||||
* <p>When encountering too many patterns to cache at runtime (the threshold is 65536),
|
||||
* it turns the default cache off, assuming that arbitrary permutations of patterns
|
||||
* are coming in, with little chance for encountering a recurring pattern.
|
||||
* <p>This method may be overridden to implement a custom cache strategy.
|
||||
*
|
||||
* @param pattern the pattern to match against (never {@code null})
|
||||
* @return a corresponding AntPathStringMatcher (never {@code null})
|
||||
* @see #setCachePatterns
|
||||
*/
|
||||
protected AntPathStringMatcher getStringMatcher(String pattern) {
|
||||
AntPathStringMatcher matcher = null;
|
||||
Boolean cachePatterns = this.cachePatterns;
|
||||
if (cachePatterns == null || cachePatterns) {
|
||||
matcher = this.stringMatcherCache.get(pattern);
|
||||
}
|
||||
if (matcher == null) {
|
||||
matcher = new AntPathStringMatcher(pattern, this.pathSeparator, this.caseSensitive);
|
||||
if (cachePatterns == null && this.stringMatcherCache.size() >= CACHE_TURNOFF_THRESHOLD) {
|
||||
// Try to adapt to the runtime situation that we're encountering:
|
||||
// There are obviously too many different patterns coming in here...
|
||||
// So let's turn off the cache since the patterns are unlikely to be reoccurring.
|
||||
deactivatePatternCache();
|
||||
return matcher;
|
||||
}
|
||||
if (cachePatterns == null || cachePatterns) {
|
||||
this.stringMatcherCache.put(pattern, matcher);
|
||||
}
|
||||
}
|
||||
return matcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a pattern and a full path, determine the pattern-mapped part. <p>For example: <ul>
|
||||
* <li>'{@code /docs/cvs/commit.html}' and '{@code /docs/cvs/commit.html} → ''</li>
|
||||
* <li>'{@code /docs/*}' and '{@code /docs/cvs/commit} → '{@code cvs/commit}'</li>
|
||||
* <li>'{@code /docs/cvs/*.html}' and '{@code /docs/cvs/commit.html} → '{@code commit.html}'</li>
|
||||
* <li>'{@code /docs/**}' and '{@code /docs/cvs/commit} → '{@code cvs/commit}'</li>
|
||||
* <li>'{@code /docs/**\/*.html}' and '{@code /docs/cvs/commit.html} → '{@code cvs/commit.html}'</li>
|
||||
* <li>'{@code /*.html}' and '{@code /docs/cvs/commit.html} → '{@code docs/cvs/commit.html}'</li>
|
||||
* <li>'{@code *.html}' and '{@code /docs/cvs/commit.html} → '{@code /docs/cvs/commit.html}'</li>
|
||||
* <li>'{@code *}' and '{@code /docs/cvs/commit.html} → '{@code /docs/cvs/commit.html}'</li> </ul>
|
||||
* <p>Assumes that {@link #match} returns {@code true} for '{@code pattern}' and '{@code path}', but
|
||||
* does <strong>not</strong> enforce this.
|
||||
*/
|
||||
|
||||
public String extractPathWithinPattern(String pattern, String path) {
|
||||
String[] patternParts = tokenizeToStringArray(pattern, this.pathSeparator, this.trimTokens, true);
|
||||
String[] pathParts = tokenizeToStringArray(path, this.pathSeparator, this.trimTokens, true);
|
||||
StringBuilder builder = new StringBuilder();
|
||||
boolean pathStarted = false;
|
||||
|
||||
for (int segment = 0; segment < patternParts.length; segment++) {
|
||||
String patternPart = patternParts[segment];
|
||||
if (patternPart.indexOf('*') > -1 || patternPart.indexOf('?') > -1) {
|
||||
for (; segment < pathParts.length; segment++) {
|
||||
if (pathStarted || (segment == 0 && !pattern.startsWith(this.pathSeparator))) {
|
||||
builder.append(this.pathSeparator);
|
||||
}
|
||||
builder.append(pathParts[segment]);
|
||||
pathStarted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
|
||||
public Map<String, String> extractUriTemplateVariables(String pattern, String path) {
|
||||
Map<String, String> variables = new LinkedHashMap<>();
|
||||
boolean result = doMatch(pattern, path, true, variables);
|
||||
if (!result) {
|
||||
throw new IllegalStateException("Pattern \"" + pattern + "\" is not a match for \"" + path + "\"");
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine two patterns into a new pattern.
|
||||
* <p>This implementation simply concatenates the two patterns, unless
|
||||
* the first pattern contains a file extension match (for example, {@code *.html}).
|
||||
* In that case, the second pattern will be merged into the first. Otherwise,
|
||||
* an {@code IllegalArgumentException} will be thrown.
|
||||
* <h4>Examples</h4>
|
||||
* <table border="1">
|
||||
* <tr><th>Pattern 1</th><th>Pattern 2</th><th>Result</th></tr>
|
||||
* <tr><td>{@code null}</td><td>{@code null}</td><td> </td></tr>
|
||||
* <tr><td>/hotels</td><td>{@code null}</td><td>/hotels</td></tr>
|
||||
* <tr><td>{@code null}</td><td>/hotels</td><td>/hotels</td></tr>
|
||||
* <tr><td>/hotels</td><td>/bookings</td><td>/hotels/bookings</td></tr>
|
||||
* <tr><td>/hotels</td><td>bookings</td><td>/hotels/bookings</td></tr>
|
||||
* <tr><td>/hotels/*</td><td>/bookings</td><td>/hotels/bookings</td></tr>
|
||||
* <tr><td>/hotels/**</td><td>/bookings</td><td>/hotels/**/bookings</td></tr>
|
||||
* <tr><td>/hotels</td><td>{hotel}</td><td>/hotels/{hotel}</td></tr>
|
||||
* <tr><td>/hotels/*</td><td>{hotel}</td><td>/hotels/{hotel}</td></tr>
|
||||
* <tr><td>/hotels/**</td><td>{hotel}</td><td>/hotels/**/{hotel}</td></tr>
|
||||
* <tr><td>/*.html</td><td>/hotels.html</td><td>/hotels.html</td></tr>
|
||||
* <tr><td>/*.html</td><td>/hotels</td><td>/hotels.html</td></tr>
|
||||
* <tr><td>/*.html</td><td>/*.txt</td><td>{@code IllegalArgumentException}</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* @param pattern1 the first pattern
|
||||
* @param pattern2 the second pattern
|
||||
* @return the combination of the two patterns
|
||||
* @throws IllegalArgumentException if the two patterns cannot be combined
|
||||
*/
|
||||
|
||||
public String combine(String pattern1, String pattern2) {
|
||||
if (!hasText(pattern1) && !hasText(pattern2)) {
|
||||
return "";
|
||||
}
|
||||
if (!hasText(pattern1)) {
|
||||
return pattern2;
|
||||
}
|
||||
if (!hasText(pattern2)) {
|
||||
return pattern1;
|
||||
}
|
||||
|
||||
boolean pattern1ContainsUriVar = (pattern1.indexOf('{') != -1);
|
||||
if (!pattern1.equals(pattern2) && !pattern1ContainsUriVar && match(pattern1, pattern2)) {
|
||||
// /* + /hotel -> /hotel ; "/*.*" + "/*.html" -> /*.html
|
||||
// However /user + /user -> /usr/user ; /{foo} + /bar -> /{foo}/bar
|
||||
return pattern2;
|
||||
}
|
||||
|
||||
// /hotels/* + /booking -> /hotels/booking
|
||||
// /hotels/* + booking -> /hotels/booking
|
||||
if (pattern1.endsWith(this.pathSeparatorPatternCache.getEndsOnWildCard())) {
|
||||
return concat(pattern1.substring(0, pattern1.length() - 2), pattern2);
|
||||
}
|
||||
|
||||
// /hotels/** + /booking -> /hotels/**/booking
|
||||
// /hotels/** + booking -> /hotels/**/booking
|
||||
if (pattern1.endsWith(this.pathSeparatorPatternCache.getEndsOnDoubleWildCard())) {
|
||||
return concat(pattern1, pattern2);
|
||||
}
|
||||
|
||||
int starDotPos1 = pattern1.indexOf("*.");
|
||||
if (pattern1ContainsUriVar || starDotPos1 == -1 || this.pathSeparator.equals(".")) {
|
||||
// simply concatenate the two patterns
|
||||
return concat(pattern1, pattern2);
|
||||
}
|
||||
|
||||
String ext1 = pattern1.substring(starDotPos1 + 1);
|
||||
int dotPos2 = pattern2.indexOf('.');
|
||||
String file2 = (dotPos2 == -1 ? pattern2 : pattern2.substring(0, dotPos2));
|
||||
String ext2 = (dotPos2 == -1 ? "" : pattern2.substring(dotPos2));
|
||||
boolean ext1All = (ext1.equals(".*") || ext1.isEmpty());
|
||||
boolean ext2All = (ext2.equals(".*") || ext2.isEmpty());
|
||||
if (!ext1All && !ext2All) {
|
||||
throw new IllegalArgumentException("Cannot combine patterns: " + pattern1 + " vs " + pattern2);
|
||||
}
|
||||
String ext = (ext1All ? ext2 : ext1);
|
||||
return file2 + ext;
|
||||
}
|
||||
|
||||
public static boolean hasText(@Nullable String str) {
|
||||
return (str != null && !str.isBlank());
|
||||
}
|
||||
|
||||
public static String[] tokenizeToStringArray(
|
||||
@Nullable String str, String delimiters, boolean trimTokens, boolean ignoreEmptyTokens) {
|
||||
|
||||
if (str == null) {
|
||||
return ArrayUtils.EMPTY_STRING_ARRAY;
|
||||
}
|
||||
|
||||
StringTokenizer st = new StringTokenizer(str, delimiters);
|
||||
List<String> tokens = new ArrayList<>();
|
||||
while (st.hasMoreTokens()) {
|
||||
String token = st.nextToken();
|
||||
if (trimTokens) {
|
||||
token = token.trim();
|
||||
}
|
||||
if (!ignoreEmptyTokens || !token.isEmpty()) {
|
||||
tokens.add(token);
|
||||
}
|
||||
}
|
||||
return toStringArray(tokens);
|
||||
}
|
||||
|
||||
public static String[] toStringArray(@Nullable Collection<String> collection) {
|
||||
return (!(collection == null || collection.isEmpty()) ? collection.toArray(ArrayUtils.EMPTY_STRING_ARRAY) : ArrayUtils.EMPTY_STRING_ARRAY);
|
||||
}
|
||||
|
||||
private String concat(String path1, String path2) {
|
||||
boolean path1EndsWithSeparator = path1.endsWith(this.pathSeparator);
|
||||
boolean path2StartsWithSeparator = path2.startsWith(this.pathSeparator);
|
||||
|
||||
if (path1EndsWithSeparator && path2StartsWithSeparator) {
|
||||
return path1 + path2.substring(1);
|
||||
} else if (path1EndsWithSeparator || path2StartsWithSeparator) {
|
||||
return path1 + path2;
|
||||
} else {
|
||||
return path1 + this.pathSeparator + path2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a full path, returns a {@link Comparator} suitable for sorting patterns in order of
|
||||
* explicitness.
|
||||
* <p>This {@code Comparator} will {@linkplain java.util.List#sort(Comparator) sort}
|
||||
* a list so that more specific patterns (without URI templates or wild cards) come before
|
||||
* generic patterns. So given a list with the following patterns, the returned comparator
|
||||
* will sort this list so that the order will be as indicated.
|
||||
* <ol>
|
||||
* <li>{@code /hotels/new}</li>
|
||||
* <li>{@code /hotels/{hotel}}</li>
|
||||
* <li>{@code /hotels/*}</li>
|
||||
* </ol>
|
||||
* <p>The full path given as parameter is used to test for exact matches. So when the given path
|
||||
* is {@code /hotels/2}, the pattern {@code /hotels/2} will be sorted before {@code /hotels/1}.
|
||||
*
|
||||
* @param path the full path to use for comparison
|
||||
* @return a comparator capable of sorting patterns in order of explicitness
|
||||
*/
|
||||
|
||||
public Comparator<String> getPatternComparator(String path) {
|
||||
return new AntPatternComparator(path, this.pathSeparator);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tests whether a string matches against a pattern via a {@link Pattern}.
|
||||
* <p>The pattern may contain special characters: '*' means zero or more characters; '?' means one and
|
||||
* only one character; '{' and '}' indicate a URI template pattern. For example {@code /users/{user}}.
|
||||
*/
|
||||
protected static class AntPathStringMatcher {
|
||||
|
||||
private static final String DEFAULT_VARIABLE_PATTERN = "((?s).*)";
|
||||
|
||||
private final String rawPattern;
|
||||
|
||||
private final boolean caseSensitive;
|
||||
|
||||
private final boolean exactMatch;
|
||||
|
||||
private final @Nullable Pattern pattern;
|
||||
|
||||
private final List<String> variableNames = new ArrayList<>();
|
||||
|
||||
protected AntPathStringMatcher(String pattern, String pathSeparator, boolean caseSensitive) {
|
||||
this.rawPattern = pattern;
|
||||
this.caseSensitive = caseSensitive;
|
||||
StringBuilder patternBuilder = new StringBuilder();
|
||||
Matcher matcher = getGlobPattern(pathSeparator).matcher(pattern);
|
||||
int end = 0;
|
||||
while (matcher.find()) {
|
||||
patternBuilder.append(quote(pattern, end, matcher.start()));
|
||||
String match = matcher.group();
|
||||
if ("?".equals(match)) {
|
||||
patternBuilder.append('.');
|
||||
} else if ("*".equals(match)) {
|
||||
patternBuilder.append(".*");
|
||||
} else if (match.startsWith("{") && match.endsWith("}")) {
|
||||
int colonIdx = match.indexOf(':');
|
||||
if (colonIdx == -1) {
|
||||
patternBuilder.append(DEFAULT_VARIABLE_PATTERN);
|
||||
this.variableNames.add(matcher.group(1));
|
||||
} else {
|
||||
String variablePattern = match.substring(colonIdx + 1, match.length() - 1);
|
||||
patternBuilder.append('(');
|
||||
patternBuilder.append(variablePattern);
|
||||
patternBuilder.append(')');
|
||||
String variableName = match.substring(1, colonIdx);
|
||||
this.variableNames.add(variableName);
|
||||
}
|
||||
}
|
||||
end = matcher.end();
|
||||
}
|
||||
// No glob pattern was found, this is an exact String match
|
||||
if (end == 0) {
|
||||
this.exactMatch = true;
|
||||
this.pattern = null;
|
||||
} else {
|
||||
this.exactMatch = false;
|
||||
patternBuilder.append(quote(pattern, end, pattern.length()));
|
||||
this.pattern = Pattern.compile(patternBuilder.toString(),
|
||||
Pattern.DOTALL | (this.caseSensitive ? 0 : Pattern.CASE_INSENSITIVE));
|
||||
}
|
||||
}
|
||||
|
||||
private static Pattern getGlobPattern(String pathSeparator) {
|
||||
String pattern = "\\?|\\*|\\{((?:\\{[^" + pathSeparator + "]+?\\}|[^" + pathSeparator + "{}]|\\\\[{}])+?)\\}";
|
||||
return Pattern.compile(pattern);
|
||||
}
|
||||
|
||||
private String quote(String s, int start, int end) {
|
||||
if (start == end) {
|
||||
return "";
|
||||
}
|
||||
return Pattern.quote(s.substring(start, end));
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
*
|
||||
* @return {@code true} if the string matches against the pattern, or {@code false} otherwise.
|
||||
*/
|
||||
public boolean matchStrings(String str, @Nullable Map<String, String> uriTemplateVariables) {
|
||||
if (this.exactMatch) {
|
||||
return this.caseSensitive ? this.rawPattern.equals(str) : this.rawPattern.equalsIgnoreCase(str);
|
||||
} else if (this.pattern != null) {
|
||||
Matcher matcher = this.pattern.matcher(str);
|
||||
if (matcher.matches()) {
|
||||
if (uriTemplateVariables != null) {
|
||||
if (this.variableNames.size() != matcher.groupCount()) {
|
||||
throw new IllegalArgumentException("The number of capturing groups in the pattern segment " +
|
||||
this.pattern + " does not match the number of URI template variables it defines, " +
|
||||
"which can occur if capturing groups are used in a URI template regex. " +
|
||||
"Use non-capturing groups instead.");
|
||||
}
|
||||
for (int i = 1; i <= matcher.groupCount(); i++) {
|
||||
String name = this.variableNames.get(i - 1);
|
||||
if (name.startsWith("*")) {
|
||||
throw new IllegalArgumentException("Capturing patterns (" + name + ") are not " +
|
||||
"supported by the AntPathMatcher. Use the PathPatternParser instead.");
|
||||
}
|
||||
String value = matcher.group(i);
|
||||
uriTemplateVariables.put(name, value);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The default {@link Comparator} implementation returned by
|
||||
* {@link #getPatternComparator(String)}.
|
||||
* <p>In order, the most "generic" pattern is determined by the following:
|
||||
* <ul>
|
||||
* <li>if it's null or a capture all pattern (i.e. it is equal to "/**")</li>
|
||||
* <li>if the other pattern is an actual match</li>
|
||||
* <li>if it's a catch-all pattern (i.e. it ends with "**"</li>
|
||||
* <li>if it's got more "*" than the other pattern</li>
|
||||
* <li>if it's got more "{foo}" than the other pattern</li>
|
||||
* <li>if it's shorter than the other pattern</li>
|
||||
* </ul>
|
||||
*/
|
||||
protected static class AntPatternComparator implements Comparator<String> {
|
||||
|
||||
private final String path;
|
||||
|
||||
private final String pathSeparator;
|
||||
|
||||
public AntPatternComparator(String path) {
|
||||
this(path, DEFAULT_PATH_SEPARATOR);
|
||||
}
|
||||
|
||||
public AntPatternComparator(String path, String pathSeparator) {
|
||||
this.path = path;
|
||||
this.pathSeparator = pathSeparator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two patterns to determine which should match first, i.e. which
|
||||
* is the most specific regarding the current path.
|
||||
*
|
||||
* @return a negative integer, zero, or a positive integer as pattern1 is
|
||||
* more specific, equally specific, or less specific than pattern2.
|
||||
*/
|
||||
|
||||
public int compare(String pattern1, String pattern2) {
|
||||
PatternInfo info1 = new PatternInfo(pattern1, this.pathSeparator);
|
||||
PatternInfo info2 = new PatternInfo(pattern2, this.pathSeparator);
|
||||
|
||||
if (info1.isLeastSpecific() && info2.isLeastSpecific()) {
|
||||
return 0;
|
||||
} else if (info1.isLeastSpecific()) {
|
||||
return 1;
|
||||
} else if (info2.isLeastSpecific()) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
boolean pattern1EqualsPath = pattern1.equals(this.path);
|
||||
boolean pattern2EqualsPath = pattern2.equals(this.path);
|
||||
if (pattern1EqualsPath && pattern2EqualsPath) {
|
||||
return 0;
|
||||
} else if (pattern1EqualsPath) {
|
||||
return -1;
|
||||
} else if (pattern2EqualsPath) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (info1.isPrefixPattern() && info2.isPrefixPattern()) {
|
||||
return info2.getLength() - info1.getLength();
|
||||
} else if (info1.isPrefixPattern() && info2.getDoubleWildcards() == 0) {
|
||||
return 1;
|
||||
} else if (info2.isPrefixPattern() && info1.getDoubleWildcards() == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (info1.getTotalCount() != info2.getTotalCount()) {
|
||||
return info1.getTotalCount() - info2.getTotalCount();
|
||||
}
|
||||
|
||||
if (info1.getLength() != info2.getLength()) {
|
||||
return info2.getLength() - info1.getLength();
|
||||
}
|
||||
|
||||
if (info1.getSingleWildcards() < info2.getSingleWildcards()) {
|
||||
return -1;
|
||||
} else if (info2.getSingleWildcards() < info1.getSingleWildcards()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (info1.getUriVars() < info2.getUriVars()) {
|
||||
return -1;
|
||||
} else if (info2.getUriVars() < info1.getUriVars()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Value class that holds information about the pattern, for example, number of
|
||||
* occurrences of "*", "**", and "{" pattern elements.
|
||||
*/
|
||||
private static class PatternInfo {
|
||||
|
||||
|
||||
private final @Nullable String pattern;
|
||||
|
||||
private int uriVars;
|
||||
|
||||
private int singleWildcards;
|
||||
|
||||
private int doubleWildcards;
|
||||
|
||||
private boolean catchAllPattern;
|
||||
|
||||
private boolean prefixPattern;
|
||||
|
||||
private @Nullable Integer length;
|
||||
|
||||
PatternInfo(@Nullable String pattern, String pathSeparator) {
|
||||
this.pattern = pattern;
|
||||
if (this.pattern != null) {
|
||||
initCounters();
|
||||
this.catchAllPattern = this.pattern.equals(pathSeparator + "**");
|
||||
this.prefixPattern = !this.catchAllPattern && this.pattern.endsWith(pathSeparator + "**");
|
||||
}
|
||||
if (this.uriVars == 0) {
|
||||
this.length = (this.pattern != null ? this.pattern.length() : 0);
|
||||
}
|
||||
}
|
||||
|
||||
protected void initCounters() {
|
||||
int pos = 0;
|
||||
if (this.pattern != null) {
|
||||
while (pos < this.pattern.length()) {
|
||||
if (this.pattern.charAt(pos) == '{') {
|
||||
this.uriVars++;
|
||||
pos++;
|
||||
} else if (this.pattern.charAt(pos) == '*') {
|
||||
if (pos + 1 < this.pattern.length() && this.pattern.charAt(pos + 1) == '*') {
|
||||
this.doubleWildcards++;
|
||||
pos += 2;
|
||||
} else if (pos > 0 && !this.pattern.substring(pos - 1).equals(".*")) {
|
||||
this.singleWildcards++;
|
||||
pos++;
|
||||
} else {
|
||||
pos++;
|
||||
}
|
||||
} else {
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int getUriVars() {
|
||||
return this.uriVars;
|
||||
}
|
||||
|
||||
public int getSingleWildcards() {
|
||||
return this.singleWildcards;
|
||||
}
|
||||
|
||||
public int getDoubleWildcards() {
|
||||
return this.doubleWildcards;
|
||||
}
|
||||
|
||||
public boolean isLeastSpecific() {
|
||||
return (this.pattern == null || this.catchAllPattern);
|
||||
}
|
||||
|
||||
public boolean isPrefixPattern() {
|
||||
return this.prefixPattern;
|
||||
}
|
||||
|
||||
public int getTotalCount() {
|
||||
return this.uriVars + this.singleWildcards + (2 * this.doubleWildcards);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the length of the given pattern, where template variables are considered to be 1 long.
|
||||
*/
|
||||
public int getLength() {
|
||||
if (this.length == null) {
|
||||
this.length = (this.pattern != null ?
|
||||
VARIABLE_PATTERN.matcher(this.pattern).replaceAll("#").length() : 0);
|
||||
}
|
||||
return this.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A simple cache for patterns that depend on the configured path separator.
|
||||
*/
|
||||
private static class PathSeparatorPatternCache {
|
||||
|
||||
private final String endsOnWildCard;
|
||||
|
||||
private final String endsOnDoubleWildCard;
|
||||
|
||||
public PathSeparatorPatternCache(String pathSeparator) {
|
||||
this.endsOnWildCard = pathSeparator + "*";
|
||||
this.endsOnDoubleWildCard = pathSeparator + "**";
|
||||
}
|
||||
|
||||
public String getEndsOnWildCard() {
|
||||
return this.endsOnWildCard;
|
||||
}
|
||||
|
||||
public String getEndsOnDoubleWildCard() {
|
||||
return this.endsOnDoubleWildCard;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package app.termora;
|
||||
|
||||
import org.apache.sshd.common.keyprovider.KeyIdentityProvider;
|
||||
import org.apache.sshd.common.session.SessionContext;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyPair;
|
||||
import java.util.*;
|
||||
|
||||
@Deprecated
|
||||
public class CombinedKeyIdentityProvider implements KeyIdentityProvider {
|
||||
|
||||
private final List<KeyIdentityProvider> providers = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public Iterable<KeyPair> loadKeys(SessionContext context) {
|
||||
return () -> new Iterator<>() {
|
||||
|
||||
private final Iterator<KeyIdentityProvider> factories = providers
|
||||
.iterator();
|
||||
private Iterator<KeyPair> current;
|
||||
|
||||
private Boolean hasElement;
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
if (hasElement != null) {
|
||||
return hasElement;
|
||||
}
|
||||
while (current == null || !current.hasNext()) {
|
||||
if (factories.hasNext()) {
|
||||
try {
|
||||
current = factories.next().loadKeys(context)
|
||||
.iterator();
|
||||
} catch (IOException | GeneralSecurityException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
} else {
|
||||
current = null;
|
||||
hasElement = Boolean.FALSE;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
hasElement = Boolean.TRUE;
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyPair next() {
|
||||
if ((hasElement == null && !hasNext()) || !hasElement) {
|
||||
throw new NoSuchElementException();
|
||||
}
|
||||
hasElement = null;
|
||||
KeyPair result;
|
||||
try {
|
||||
result = current.next();
|
||||
} catch (NoSuchElementException e) {
|
||||
result = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
public void addKeyKeyIdentityProvider(KeyIdentityProvider provider) {
|
||||
providers.add(Objects.requireNonNull(provider));
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package app.termora;
|
||||
|
||||
import com.formdev.flatlaf.ui.FlatTabbedPaneUI;
|
||||
import com.formdev.flatlaf.ui.FlatUIUtils;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.geom.Path2D;
|
||||
import java.awt.geom.Rectangle2D;
|
||||
|
||||
import static com.formdev.flatlaf.FlatClientProperties.*;
|
||||
import static com.formdev.flatlaf.util.UIScale.scale;
|
||||
|
||||
/**
|
||||
* 如果要升级 FlatLaf 需要检查是否兼容
|
||||
*/
|
||||
@Deprecated
|
||||
public class MyFlatTabbedPaneUI extends FlatTabbedPaneUI {
|
||||
@Override
|
||||
protected void paintContentBorder(Graphics g, int tabPlacement, int selectedIndex) {
|
||||
if (tabPane.getTabCount() <= 0 ||
|
||||
contentSeparatorHeight == 0 ||
|
||||
!clientPropertyBoolean(tabPane, TABBED_PANE_SHOW_CONTENT_SEPARATOR, showContentSeparator))
|
||||
return;
|
||||
|
||||
Insets insets = tabPane.getInsets();
|
||||
Insets tabAreaInsets = getTabAreaInsets(tabPlacement);
|
||||
|
||||
int x = insets.left;
|
||||
int y = insets.top;
|
||||
int w = tabPane.getWidth() - insets.right - insets.left;
|
||||
int h = tabPane.getHeight() - insets.top - insets.bottom;
|
||||
|
||||
// remove tabs from bounds
|
||||
switch (tabPlacement) {
|
||||
case BOTTOM:
|
||||
h -= calculateTabAreaHeight(tabPlacement, runCount, maxTabHeight);
|
||||
h += tabAreaInsets.top;
|
||||
break;
|
||||
|
||||
case LEFT:
|
||||
x += calculateTabAreaWidth(tabPlacement, runCount, maxTabWidth);
|
||||
x -= tabAreaInsets.right;
|
||||
w -= (x - insets.left);
|
||||
break;
|
||||
|
||||
case RIGHT:
|
||||
w -= calculateTabAreaWidth(tabPlacement, runCount, maxTabWidth);
|
||||
w += tabAreaInsets.left;
|
||||
break;
|
||||
|
||||
case TOP:
|
||||
default:
|
||||
y += calculateTabAreaHeight(tabPlacement, runCount, maxTabHeight);
|
||||
y -= tabAreaInsets.bottom;
|
||||
h -= (y - insets.top);
|
||||
break;
|
||||
}
|
||||
|
||||
// compute insets for separator or full border
|
||||
boolean hasFullBorder = clientPropertyBoolean(tabPane, TABBED_PANE_HAS_FULL_BORDER, this.hasFullBorder);
|
||||
int sh = scale(contentSeparatorHeight * 100); // multiply by 100 because rotateInsets() does not use floats
|
||||
Insets ci = new Insets(0, 0, 0, 0);
|
||||
rotateInsets(hasFullBorder ? new Insets(sh, sh, sh, sh) : new Insets(sh, 0, 0, 0), ci, tabPlacement);
|
||||
|
||||
// create path for content separator or full border
|
||||
Path2D path = new Path2D.Float(Path2D.WIND_EVEN_ODD);
|
||||
path.append(new Rectangle2D.Float(x, y, w, h), false);
|
||||
path.append(new Rectangle2D.Float(x + (ci.left / 100f), y + (ci.top / 100f),
|
||||
w - (ci.left / 100f) - (ci.right / 100f), h - (ci.top / 100f) - (ci.bottom / 100f)), false);
|
||||
|
||||
// add gap for selected tab to path
|
||||
if (getTabType() == TAB_TYPE_CARD && selectedIndex >= 0) {
|
||||
float csh = scale((float) contentSeparatorHeight);
|
||||
|
||||
Rectangle tabRect = getTabBounds(tabPane, selectedIndex);
|
||||
boolean componentHasFullBorder = false;
|
||||
if (tabPane.getComponentAt(selectedIndex) instanceof JComponent c) {
|
||||
componentHasFullBorder = c.getClientProperty(TABBED_PANE_HAS_FULL_BORDER) == Boolean.TRUE;
|
||||
}
|
||||
Rectangle2D.Float innerTabRect = new Rectangle2D.Float(tabRect.x + csh, tabRect.y + csh,
|
||||
componentHasFullBorder ? 0 : tabRect.width - (csh * 2), tabRect.height - (csh * 2));
|
||||
|
||||
// Ensure that the separator outside the tabViewport is present (doesn't get cutoff by the active tab)
|
||||
// If left unsolved the active tab is "visible" in the separator (the gap) even when outside the viewport
|
||||
if (tabViewport != null)
|
||||
Rectangle2D.intersect(tabViewport.getBounds(), innerTabRect, innerTabRect);
|
||||
|
||||
Rectangle2D.Float gap = null;
|
||||
if (isHorizontalTabPlacement(tabPlacement)) {
|
||||
if (innerTabRect.width > 0) {
|
||||
float y2 = (tabPlacement == TOP) ? y : y + h - csh;
|
||||
gap = new Rectangle2D.Float(innerTabRect.x, y2, innerTabRect.width, csh);
|
||||
}
|
||||
} else {
|
||||
if (innerTabRect.height > 0) {
|
||||
float x2 = (tabPlacement == LEFT) ? x : x + w - csh;
|
||||
gap = new Rectangle2D.Float(x2, innerTabRect.y, csh, innerTabRect.height);
|
||||
}
|
||||
}
|
||||
|
||||
if (gap != null) {
|
||||
path.append(gap, false);
|
||||
|
||||
// fill gap in case that the tab is colored (e.g. focused or hover)
|
||||
Color background = getTabBackground(tabPlacement, selectedIndex, true);
|
||||
g.setColor(FlatUIUtils.deriveColor(background, tabPane.getBackground()));
|
||||
((Graphics2D) g).fill(gap);
|
||||
}
|
||||
}
|
||||
|
||||
// paint content separator or full border
|
||||
g.setColor(contentAreaColor);
|
||||
((Graphics2D) g).fill(path);
|
||||
|
||||
// repaint selection in scroll-tab-layout because it may be painted before
|
||||
// the content border was painted (from BasicTabbedPaneUI$ScrollableTabPanel)
|
||||
if (isScrollTabLayout() && selectedIndex >= 0 && tabViewport != null) {
|
||||
Rectangle tabRect = getTabBounds(tabPane, selectedIndex);
|
||||
|
||||
// clip to "scrolling sides" of viewport
|
||||
// (left and right if horizontal, top and bottom if vertical)
|
||||
Shape oldClip = g.getClip();
|
||||
Rectangle vr = tabViewport.getBounds();
|
||||
if (isHorizontalTabPlacement(tabPlacement))
|
||||
g.clipRect(vr.x, 0, vr.width, tabPane.getHeight());
|
||||
else
|
||||
g.clipRect(0, vr.y, tabPane.getWidth(), vr.height);
|
||||
|
||||
paintTabSelection(g, tabPlacement, selectedIndex, tabRect.x, tabRect.y, tabRect.width, tabRect.height);
|
||||
g.setClip(oldClip);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private boolean isScrollTabLayout() {
|
||||
return tabPane.getTabLayoutPolicy() == JTabbedPane.SCROLL_TAB_LAYOUT;
|
||||
}
|
||||
|
||||
}
|
||||
463
src/main/java/app/termora/VerticalFlowLayout.java
Normal file
463
src/main/java/app/termora/VerticalFlowLayout.java
Normal file
@@ -0,0 +1,463 @@
|
||||
package app.termora;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import java.awt.Component;
|
||||
import java.awt.Container;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Insets;
|
||||
import java.awt.LayoutManager;
|
||||
|
||||
/**
|
||||
* A vertical flow layout arranges components in a top-to-bottom flow, much
|
||||
* like lines of text in a paragraph. Flow layouts are typically used
|
||||
* to arrange buttons in a panel. It will arrange
|
||||
* buttons top to bottom until no more buttons fit on the same line.
|
||||
* Each line is centered.
|
||||
*/
|
||||
public class VerticalFlowLayout implements LayoutManager, java.io.Serializable {
|
||||
|
||||
/**
|
||||
* This value indicates that each row of components
|
||||
* should be left-justified.
|
||||
*/
|
||||
public static final int TOP = 0;
|
||||
|
||||
/**
|
||||
* This value indicates that each row of components
|
||||
* should be centered.
|
||||
*/
|
||||
public static final int CENTER = 1;
|
||||
|
||||
/**
|
||||
* This value indicates that each row of components
|
||||
* should be right-justified.
|
||||
*/
|
||||
public static final int BOTTOM = 2;
|
||||
|
||||
/**
|
||||
* <code>align</code> is the property that determines
|
||||
* how each row distributes empty space.
|
||||
* It can be one of the following values:
|
||||
* <ul>
|
||||
* <code>TOP</code>
|
||||
* <code>BOTTOM</code>
|
||||
* <code>CENTER</code>
|
||||
* <code>LEADING</code>
|
||||
* <code>TRAILING</code>
|
||||
* </ul>
|
||||
*
|
||||
* @serial
|
||||
* @see #getAlignment
|
||||
* @see #setAlignment
|
||||
*/
|
||||
int alisgn; // This is for 1.1 serialization compatibility
|
||||
|
||||
/**
|
||||
* <code>newAlign</code> is the property that determines
|
||||
* how each row distributes empty space for the Java 2 platform,
|
||||
* v1.2 and greater.
|
||||
* It can be one of the following three values:
|
||||
* <ul>
|
||||
* <code>TOP</code>
|
||||
* <code>BOTTOM</code>
|
||||
* <code>CENTER</code>
|
||||
* <code>LEADING</code>
|
||||
* <code>TRAILING</code>
|
||||
* </ul>
|
||||
*
|
||||
* @serial
|
||||
* @see #getAlignment
|
||||
* @see #setAlignment
|
||||
* @since 1.2
|
||||
*/
|
||||
int newAlign; // This is the one we actually use
|
||||
|
||||
/**
|
||||
* The flow layout manager allows a seperation of
|
||||
* components with gaps. The horizontal gap will
|
||||
* specify the space between components.
|
||||
*
|
||||
* @serial
|
||||
* @see #getHgap()
|
||||
* @see #setHgap(int)
|
||||
*/
|
||||
protected int hgap;
|
||||
|
||||
/**
|
||||
* The flow layout manager allows a seperation of
|
||||
* components with gaps. The vertical gap will
|
||||
* specify the space between rows.
|
||||
*
|
||||
* @serial
|
||||
* @see #getHgap()
|
||||
* @see #setHgap(int)
|
||||
*/
|
||||
protected int vgap;
|
||||
|
||||
/**
|
||||
* Constructs a new <code>FlowLayout</code> with a centered alignment and a
|
||||
* default 5-unit horizontal and vertical gap.
|
||||
*/
|
||||
public VerticalFlowLayout() {
|
||||
this(CENTER, 5, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new <code>FlowLayout</code> with the specified
|
||||
* alignment and a default 5-unit horizontal and vertical gap.
|
||||
* The value of the alignment argument must be one of
|
||||
* <code>FlowLayout.TOP</code>, <code>FlowLayout.BOTTOM</code>,
|
||||
* or <code>FlowLayout.CENTER</code>.
|
||||
*
|
||||
* @param align the alignment value
|
||||
*/
|
||||
public VerticalFlowLayout(int align) {
|
||||
this(align, 5, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new flow layout manager with the indicated alignment
|
||||
* and the indicated horizontal and vertical gaps.
|
||||
* <p/>
|
||||
* The value of the alignment argument must be one of
|
||||
* <code>FlowLayout.TOP</code>, <code>FlowLayout.BOTTOM</code>,
|
||||
* or <code>FlowLayout.CENTER</code>.
|
||||
*
|
||||
* @param align the alignment value
|
||||
* @param hgap the horizontal gap between components
|
||||
* @param vgap the vertical gap between components
|
||||
*/
|
||||
public VerticalFlowLayout(int align, int hgap, int vgap) {
|
||||
this.hgap = hgap;
|
||||
this.vgap = vgap;
|
||||
|
||||
setAlignment(align);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the alignment for this layout.
|
||||
* Possible values are <code>FlowLayout.TOP</code>,
|
||||
* <code>FlowLayout.BOTTOM</code>, <code>FlowLayout.CENTER</code>,
|
||||
* <code>FlowLayout.LEADING</code>,
|
||||
* or <code>FlowLayout.TRAILING</code>.
|
||||
*
|
||||
* @return the alignment value for this layout
|
||||
* @see java.awt.FlowLayout#setAlignment
|
||||
* @since JDK1.1
|
||||
*/
|
||||
public int getAlignment() {
|
||||
return newAlign;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the alignment for this layout.
|
||||
* Possible values are
|
||||
* <ul>
|
||||
* <li><code>FlowLayout.TOP</code>
|
||||
* <li><code>FlowLayout.BOTTOM</code>
|
||||
* <li><code>FlowLayout.CENTER</code>
|
||||
* <li><code>FlowLayout.LEADING</code>
|
||||
* <li><code>FlowLayout.TRAILING</code>
|
||||
* </ul>
|
||||
*
|
||||
* @param align one of the alignment values shown above
|
||||
* @see #getAlignment()
|
||||
* @since JDK1.1
|
||||
*/
|
||||
public void setAlignment(int align) {
|
||||
this.newAlign = align;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the horizontal gap between components.
|
||||
*
|
||||
* @return the horizontal gap between components
|
||||
* @see java.awt.FlowLayout#setHgap
|
||||
* @since JDK1.1
|
||||
*/
|
||||
public int getHgap() {
|
||||
return hgap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the horizontal gap between components.
|
||||
*
|
||||
* @param hgap the horizontal gap between components
|
||||
* @see java.awt.FlowLayout#getHgap
|
||||
* @since JDK1.1
|
||||
*/
|
||||
public void setHgap(int hgap) {
|
||||
this.hgap = hgap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the vertical gap between components.
|
||||
*
|
||||
* @return the vertical gap between components
|
||||
* @see java.awt.FlowLayout#setVgap
|
||||
* @since JDK1.1
|
||||
*/
|
||||
public int getVgap() {
|
||||
return vgap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the vertical gap between components.
|
||||
*
|
||||
* @param vgap the vertical gap between components
|
||||
* @see java.awt.FlowLayout#getVgap
|
||||
* @since JDK1.1
|
||||
*/
|
||||
public void setVgap(int vgap) {
|
||||
this.vgap = vgap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the specified component to the layout. Not used by this class.
|
||||
*
|
||||
* @param name the name of the component
|
||||
* @param comp the component to be added
|
||||
*/
|
||||
public void addLayoutComponent(String name, Component comp) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the specified component from the layout. Not used by
|
||||
* this class.
|
||||
*
|
||||
* @param comp the component to remove
|
||||
* @see java.awt.Container#removeAll
|
||||
*/
|
||||
public void removeLayoutComponent(Component comp) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preferred dimensions for this layout given the
|
||||
* <i>visible</i> components in the specified target container.
|
||||
*
|
||||
* @param target the component which needs to be laid out
|
||||
* @return the preferred dimensions to lay out the
|
||||
* subcomponents of the specified container
|
||||
* @see java.awt.Container
|
||||
* @see #minimumLayoutSize
|
||||
* @see java.awt.Container#getPreferredSize
|
||||
*/
|
||||
public Dimension preferredLayoutSize(Container target) {
|
||||
synchronized (target.getTreeLock()) {
|
||||
Dimension dim = new Dimension(0, 0);
|
||||
int nmembers = target.getComponentCount();
|
||||
Boolean firstVisibleComponent = true;
|
||||
|
||||
for (int i = 0; i < nmembers; i++) {
|
||||
Component m = target.getComponent(i);
|
||||
if (m.isVisible()) {
|
||||
Dimension d = m.getPreferredSize();
|
||||
|
||||
firstVisibleComponent = dialWithDim4PreferredLayoutSize(dim, d, firstVisibleComponent);
|
||||
}
|
||||
}
|
||||
Insets insets = target.getInsets();
|
||||
dim.width += insets.left + insets.right + hgap * 2;
|
||||
dim.height += insets.top + insets.bottom + vgap * 2;
|
||||
return dim;
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean dialWithDim4PreferredLayoutSize(Dimension dim, Dimension d, boolean firstVisibleComponent) {
|
||||
dim.width = Math.max(dim.width, d.width);
|
||||
if (firstVisibleComponent) {
|
||||
firstVisibleComponent = false;
|
||||
} else {
|
||||
dim.height += vgap;
|
||||
}
|
||||
|
||||
dim.height += d.height;
|
||||
|
||||
return firstVisibleComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the minimum dimensions needed to layout the <i>visible</i>
|
||||
* components contained in the specified target container.
|
||||
*
|
||||
* @param target the component which needs to be laid out
|
||||
* @return the minimum dimensions to lay out the
|
||||
* subcomponents of the specified container
|
||||
* @see #preferredLayoutSize
|
||||
* @see java.awt.Container
|
||||
* @see java.awt.Container#doLayout
|
||||
*/
|
||||
public Dimension minimumLayoutSize(Container target) {
|
||||
synchronized (target.getTreeLock()) {
|
||||
Dimension dim = new Dimension(0, 0);
|
||||
int nmembers = target.getComponentCount();
|
||||
boolean firstVisibleComponent = true;
|
||||
|
||||
for (int i = 0; i < nmembers; i++) {
|
||||
Component m = target.getComponent(i);
|
||||
if (m.isVisible()) {
|
||||
Dimension d = m.getMinimumSize();
|
||||
|
||||
firstVisibleComponent = dialWithDim4MinimumLayoutSize(dim, d, i, firstVisibleComponent);
|
||||
}
|
||||
}
|
||||
Insets insets = target.getInsets();
|
||||
dim.width += insets.left + insets.right + hgap * 2;
|
||||
dim.height += insets.top + insets.bottom + vgap * 2;
|
||||
return dim;
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean dialWithDim4MinimumLayoutSize(Dimension dim, Dimension d, int i, boolean firstVisibleComponent) {
|
||||
dim.width = Math.max(dim.width, d.width);
|
||||
if (i > 0) {
|
||||
dim.height += vgap;
|
||||
}
|
||||
dim.height += d.height;
|
||||
|
||||
return firstVisibleComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centers the elements in the specified row, if there is any slack.
|
||||
*
|
||||
* @param target the component which needs to be moved
|
||||
* @param x the x coordinate
|
||||
* @param y the y coordinate
|
||||
* @param width the width dimensions
|
||||
* @param height the height dimensions
|
||||
* @param rowStart the beginning of the row
|
||||
* @param rowEnd the the ending of the row
|
||||
*/
|
||||
private void moveComponents(Container target, int x, int y, int width, int height,
|
||||
int rowStart, int rowEnd, boolean ltr) {
|
||||
synchronized (target.getTreeLock()) {
|
||||
switch (newAlign) {
|
||||
case TOP:
|
||||
y += ltr ? 0 : height;
|
||||
break;
|
||||
case CENTER:
|
||||
y += height / 2;
|
||||
break;
|
||||
case BOTTOM:
|
||||
y += ltr ? height : 0;
|
||||
break;
|
||||
}
|
||||
for (int i = rowStart; i < rowEnd; i++) {
|
||||
Component m = target.getComponent(i);
|
||||
if (m.isVisible()) {
|
||||
if (ltr) {
|
||||
m.setLocation(x + (width - m.getWidth()) / 2, y);
|
||||
} else {
|
||||
m.setLocation(x + (width - m.getWidth()) / 2, target.getHeight() - y - m.getHeight());
|
||||
}
|
||||
y += m.getHeight() + vgap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays out the container. This method lets each component take
|
||||
* its preferred size by reshaping the components in the
|
||||
* target container in order to satisfy the alignment of
|
||||
* this <code>FlowLayout</code> object.
|
||||
*
|
||||
* @param target the specified component being laid out
|
||||
* @see java.awt.Container
|
||||
* @see java.awt.Container#doLayout
|
||||
*/
|
||||
public void layoutContainer(Container target) {
|
||||
synchronized (target.getTreeLock()) {
|
||||
Insets insets = target.getInsets();
|
||||
|
||||
int maxlen = getMaxLen4LayoutContainer(target, insets);
|
||||
int nmembers = target.getComponentCount();
|
||||
int x = getX4LayoutContainer(insets), y = getY4LayoutContainer(insets);
|
||||
int roww = 0, start = 0;
|
||||
|
||||
boolean ltr = target.getComponentOrientation().isLeftToRight();
|
||||
|
||||
int[] rs;
|
||||
for (int i = 0; i < nmembers; i++) {
|
||||
Component m = target.getComponent(i);
|
||||
if (m.isVisible()) {
|
||||
Dimension d = getPreferredSize(target, m);
|
||||
if (target instanceof JComponent t) {
|
||||
m.setSize(t.getWidth(), d.height);
|
||||
d.width = m.getWidth();
|
||||
} else {
|
||||
m.setSize(d.width, d.height);
|
||||
}
|
||||
|
||||
rs = dealWithDim4LayoutContainer(target, insets, d, x, y, roww, start, maxlen, i, ltr);
|
||||
x = rs[0];
|
||||
y = rs[1];
|
||||
roww = rs[2];
|
||||
start = rs[3];
|
||||
}
|
||||
}
|
||||
|
||||
dealWithMC4LayoutContainer(target, insets, x, y, roww, start, maxlen, nmembers, ltr);
|
||||
}
|
||||
}
|
||||
|
||||
protected Dimension getPreferredSize(Container target, Component m) {
|
||||
return m.getPreferredSize();
|
||||
}
|
||||
|
||||
protected void dealWithMC4LayoutContainer(Container target, Insets insets, int x, int y, int roww, int start, int maxlen, int nmembers, boolean ltr) {
|
||||
moveComponents(target, x, insets.top + vgap, roww, maxlen - y, start, nmembers, ltr);
|
||||
}
|
||||
|
||||
protected int[] dealWithDim4LayoutContainer(Container target, Insets insets, Dimension d, int x, int y, int roww, int start, int maxlen, int i, boolean ltr) {
|
||||
if ((y == 0) || ((y + d.height) <= maxlen)) {
|
||||
if (y > 0) y += vgap;
|
||||
y += d.height;
|
||||
roww = Math.max(roww, d.width);
|
||||
} else {
|
||||
moveComponents(target, x, insets.top + vgap, roww, maxlen - y, start, i, ltr);
|
||||
y = d.height;
|
||||
x += hgap + roww;
|
||||
roww = d.width;
|
||||
start = i;
|
||||
}
|
||||
return new int[]{x, y, roww, start};
|
||||
}
|
||||
|
||||
protected int getMaxLen4LayoutContainer(Container target, Insets insets) {
|
||||
return target.getHeight() - (insets.top + insets.bottom + vgap * 2);
|
||||
}
|
||||
|
||||
protected int getX4LayoutContainer(Insets insets) {
|
||||
return insets.left + hgap;
|
||||
}
|
||||
|
||||
protected int getY4LayoutContainer(Insets insets) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a string representation of this <code>FlowLayout</code>
|
||||
* object and its values.
|
||||
*
|
||||
* @return a string representation of this layout
|
||||
*/
|
||||
public String toString() {
|
||||
String str = "";
|
||||
switch (this.newAlign) {
|
||||
case TOP:
|
||||
str = ",align=top";
|
||||
break;
|
||||
case CENTER:
|
||||
str = ",align=center";
|
||||
break;
|
||||
case BOTTOM:
|
||||
str = ",align=bottom";
|
||||
break;
|
||||
}
|
||||
|
||||
return getClass().getName() + "[hgap=" + hgap + ",vgap=" + vgap + str + "]";
|
||||
}
|
||||
}
|
||||
43
src/main/java/app/termora/plugin/ExtensionProxy.java
Normal file
43
src/main/java/app/termora/plugin/ExtensionProxy.java
Normal file
@@ -0,0 +1,43 @@
|
||||
package app.termora.plugin;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
|
||||
record ExtensionProxy(Plugin plugin, Extension extension) implements InvocationHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(ExtensionProxy.class);
|
||||
|
||||
public Object getProxy() {
|
||||
return Proxy.newProxyInstance(extension.getClass().getClassLoader(), extension.getClass().getInterfaces(), this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
|
||||
if (extension.getDispatchThread() == DispatchThread.EDT) {
|
||||
if (!SwingUtilities.isEventDispatchThread()) {
|
||||
if (log.isErrorEnabled()) {
|
||||
log.error("Event Dispatch Thread", new WrongThreadException("Event Dispatch Thread"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return method.invoke(extension, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
final Throwable target = e.getTargetException();
|
||||
// 尽可能避免抛出致命性错误
|
||||
if (target instanceof Error && !(target instanceof VirtualMachineError)) {
|
||||
if (log.isErrorEnabled()) {
|
||||
log.error("Error Invoking method {}", method.getName(), target);
|
||||
}
|
||||
throw new IllegalCallerException(target.getMessage(), target);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/main/kotlin/app/termora/AbstractI18n.kt
Normal file
37
src/main/kotlin/app/termora/AbstractI18n.kt
Normal file
@@ -0,0 +1,37 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.text.StringSubstitutor
|
||||
import org.slf4j.Logger
|
||||
import java.text.MessageFormat
|
||||
import java.util.*
|
||||
|
||||
abstract class AbstractI18n {
|
||||
private val log get() = getLogger()
|
||||
|
||||
private val substitutor by lazy { StringSubstitutor { key -> getString(key) } }
|
||||
|
||||
fun getString(key: String, vararg args: Any): String {
|
||||
val text = getString(key)
|
||||
if (args.isNotEmpty()) {
|
||||
return MessageFormat.format(text, *args)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
|
||||
fun getString(key: String): String {
|
||||
try {
|
||||
return substitutor.replace(getBundle().getString(key))
|
||||
} catch (e: MissingResourceException) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn(e.message, e)
|
||||
}
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected abstract fun getBundle(): ResourceBundle
|
||||
|
||||
protected abstract fun getLogger(): Logger
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.time.DateUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Desktop
|
||||
import java.io.File
|
||||
@@ -17,6 +18,7 @@ import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.pow
|
||||
|
||||
@@ -95,25 +97,55 @@ object Application {
|
||||
return dir
|
||||
}
|
||||
|
||||
fun getDatabaseFile(): File {
|
||||
return FileUtils.getFile(getBaseDataDir(), "storage")
|
||||
}
|
||||
|
||||
fun getVersion(): String {
|
||||
var version = System.getProperty("jpackage.app-version")
|
||||
var version = System.getProperty("app-version")
|
||||
|
||||
if (version.isNullOrBlank()) {
|
||||
version = System.getProperty("app-version")
|
||||
version = System.getProperty("jpackage.app-version")
|
||||
}
|
||||
|
||||
if (version.isNullOrBlank()) {
|
||||
version = "unknown"
|
||||
if (getAppPath().isBlank()) {
|
||||
val versionFile = File("VERSION")
|
||||
if (versionFile.exists() && versionFile.isFile) {
|
||||
version = versionFile.readText().trim()
|
||||
}
|
||||
}
|
||||
|
||||
if (version.isNullOrBlank()) {
|
||||
version = "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
/**
|
||||
* 未知版本通常是开发版本
|
||||
*/
|
||||
fun isUnknownVersion(): Boolean {
|
||||
return getVersion().contains("unknown")
|
||||
}
|
||||
|
||||
fun getUserAgent(): String {
|
||||
return "${getName()}/${getVersion()}; ${SystemUtils.OS_NAME}/${SystemUtils.OS_VERSION}(${SystemUtils.OS_ARCH}); ${SystemUtils.JAVA_VM_NAME}/${SystemUtils.JAVA_VERSION}"
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是测试版
|
||||
*/
|
||||
fun isBetaVersion(): Boolean {
|
||||
return getVersion().contains("beta")
|
||||
}
|
||||
|
||||
fun getReleaseDate(): Date {
|
||||
val releaseDate = System.getProperty("release-date")
|
||||
if (releaseDate.isNullOrBlank()) {
|
||||
return Date()
|
||||
}
|
||||
return runCatching { DateUtils.parseDate(releaseDate, "yyyy-MM-dd") }.getOrNull() ?: Date()
|
||||
}
|
||||
|
||||
fun getAppPath(): String {
|
||||
return StringUtils.defaultString(System.getProperty("jpackage.app-path"))
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.FlatSystemProperties
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.pty4j.util.PtyUtil
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.tinylog.configuration.Configuration
|
||||
import java.io.File
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class ApplicationInitializr {
|
||||
|
||||
@@ -29,7 +32,11 @@ class ApplicationInitializr {
|
||||
checkSingleton()
|
||||
|
||||
// 启动
|
||||
ApplicationRunner().run()
|
||||
val runtime = measureTimeMillis { ApplicationRunner().run() }
|
||||
val log = LoggerFactory.getLogger(javaClass)
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Application initialization ${runtime}ms")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -69,6 +76,17 @@ class ApplicationInitializr {
|
||||
if (restart4j.exists()) {
|
||||
System.setProperty("restarter.path", restart4j.absolutePath)
|
||||
}
|
||||
|
||||
val sqlite = FileUtils.getFile(dylib, "sqlite-jdbc")
|
||||
if (sqlite.exists()) {
|
||||
System.setProperty("org.sqlite.lib.path", sqlite.absolutePath)
|
||||
}
|
||||
|
||||
val flatlaf = FileUtils.getFile(dylib, "flatlaf")
|
||||
if (flatlaf.exists()) {
|
||||
System.setProperty(FlatSystemProperties.NATIVE_LIBRARY_PATH, flatlaf.absolutePath)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.ActionManager
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.keymap.KeymapManager
|
||||
import app.termora.vfs2.sftp.MySftpFileProvider
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import app.termora.plugin.PluginManager
|
||||
import app.termora.protocol.ProtocolProvider
|
||||
import app.termora.protocol.TransferProtocolProvider
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.FlatSystemProperties
|
||||
import com.formdev.flatlaf.extras.FlatDesktop
|
||||
@@ -21,7 +25,6 @@ import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import org.apache.commons.vfs2.cache.WeakRefFilesCache
|
||||
import org.apache.commons.vfs2.impl.DefaultFileSystemManager
|
||||
import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider
|
||||
import org.json.JSONObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.MenuItem
|
||||
@@ -38,68 +41,59 @@ import java.util.concurrent.CountDownLatch
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.*
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class ApplicationRunner {
|
||||
private val log by lazy { LoggerFactory.getLogger(ApplicationRunner::class.java) }
|
||||
|
||||
fun run() {
|
||||
measureTimeMillis {
|
||||
|
||||
// 打印系统信息
|
||||
val printSystemInfo = measureTimeMillis { printSystemInfo() }
|
||||
// 异步初始化
|
||||
val loadPluginThread = Thread.ofVirtual().start { PluginManager.getInstance() }
|
||||
|
||||
// 打开数据库
|
||||
val openDatabase = measureTimeMillis { openDatabase() }
|
||||
// 打印系统信息
|
||||
printSystemInfo()
|
||||
|
||||
// 加载设置
|
||||
val loadSettings = measureTimeMillis { loadSettings() }
|
||||
// 打开数据库
|
||||
openDatabase()
|
||||
|
||||
// 统计
|
||||
val enableAnalytics = measureTimeMillis { enableAnalytics() }
|
||||
// 加载设置
|
||||
loadSettings()
|
||||
|
||||
// init ActionManager、KeymapManager、VFS
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
ActionManager.getInstance()
|
||||
KeymapManager.getInstance()
|
||||
// 统计
|
||||
enableAnalytics()
|
||||
|
||||
val fileSystemManager = DefaultFileSystemManager()
|
||||
fileSystemManager.addProvider("sftp", MySftpFileProvider())
|
||||
fileSystemManager.addProvider("file", DefaultLocalFileProvider())
|
||||
fileSystemManager.filesCache = WeakRefFilesCache()
|
||||
fileSystemManager.init()
|
||||
VFS.setManager(fileSystemManager)
|
||||
|
||||
// async init
|
||||
BackgroundManager.getInstance().getBackgroundImage()
|
||||
}
|
||||
|
||||
// 设置 LAF
|
||||
val setupLaf = measureTimeMillis { setupLaf() }
|
||||
|
||||
// 解密数据
|
||||
val openDoor = measureTimeMillis { openDoor() }
|
||||
|
||||
// clear temporary
|
||||
clearTemporary()
|
||||
|
||||
// 启动主窗口
|
||||
val startMainFrame = measureTimeMillis { startMainFrame() }
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("printSystemInfo: {}ms", printSystemInfo)
|
||||
log.debug("openDatabase: {}ms", openDatabase)
|
||||
log.debug("loadSettings: {}ms", loadSettings)
|
||||
log.debug("enableAnalytics: {}ms", enableAnalytics)
|
||||
log.debug("setupLaf: {}ms", setupLaf)
|
||||
log.debug("openDoor: {}ms", openDoor)
|
||||
log.debug("startMainFrame: {}ms", startMainFrame)
|
||||
}
|
||||
}.let {
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("run: {}ms", it)
|
||||
}
|
||||
// init ActionManager、KeymapManager、VFS
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
ActionManager.getInstance()
|
||||
KeymapManager.getInstance()
|
||||
}
|
||||
|
||||
// 设置 LAF
|
||||
setupLaf()
|
||||
|
||||
// clear temporary
|
||||
clearTemporary()
|
||||
|
||||
// 等待插件加载完成
|
||||
loadPluginThread.join()
|
||||
|
||||
// 初始化 VFS
|
||||
val fileSystemManager = DefaultFileSystemManager()
|
||||
for (provider in ProtocolProvider.providers.filterIsInstance<TransferProtocolProvider>()) {
|
||||
fileSystemManager.addProvider(provider.getProtocol().lowercase(), provider.getFileProvider())
|
||||
}
|
||||
fileSystemManager.filesCache = WeakRefFilesCache()
|
||||
fileSystemManager.init()
|
||||
VFS.setManager(fileSystemManager)
|
||||
|
||||
// 准备就绪
|
||||
for (extension in ExtensionManager.getInstance().getExtensions(ApplicationRunnerExtension::class.java)) {
|
||||
extension.ready()
|
||||
}
|
||||
|
||||
// 启动主窗口
|
||||
SwingUtilities.invokeLater { startMainFrame() }
|
||||
|
||||
}
|
||||
|
||||
private fun clearTemporary() {
|
||||
@@ -110,14 +104,6 @@ class ApplicationRunner {
|
||||
|
||||
}
|
||||
|
||||
private fun openDoor() {
|
||||
if (Doorman.getInstance().isWorking()) {
|
||||
if (!DoormanDialog(null).open()) {
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startMainFrame() {
|
||||
|
||||
|
||||
@@ -130,8 +116,8 @@ class ApplicationRunner {
|
||||
// 设置 Dock
|
||||
setupMacOSDock()
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +128,9 @@ class ApplicationRunner {
|
||||
// 设置托盘
|
||||
SwingUtilities.invokeLater { setupSystemTray() }
|
||||
}
|
||||
|
||||
// 初始化 Scheme
|
||||
OpenURIHandlers.getInstance()
|
||||
}
|
||||
|
||||
private fun setupSystemTray() {
|
||||
@@ -186,7 +175,7 @@ class ApplicationRunner {
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val language = Database.getDatabase().appearance.language
|
||||
val language = DatabaseManager.getInstance().appearance.language
|
||||
val locale = runCatching { LocaleUtils.toLocale(language) }.getOrElse { Locale.getDefault() }
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Language: {} , Locale: {}", language, locale)
|
||||
@@ -206,7 +195,7 @@ class ApplicationRunner {
|
||||
}
|
||||
|
||||
val themeManager = ThemeManager.getInstance()
|
||||
val appearance = Database.getDatabase().appearance
|
||||
val appearance = DatabaseManager.getInstance().appearance
|
||||
var theme = appearance.theme
|
||||
// 如果是跟随系统
|
||||
if (appearance.followSystem) {
|
||||
@@ -317,7 +306,8 @@ class ApplicationRunner {
|
||||
|
||||
private fun openDatabase() {
|
||||
try {
|
||||
Database.getDatabase()
|
||||
// 初始化数据库
|
||||
DatabaseManager.getInstance()
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
@@ -365,10 +355,11 @@ class ApplicationRunner {
|
||||
}
|
||||
|
||||
private fun getAnalyticsUserID(): String {
|
||||
var id = Database.getDatabase().properties.getString("AnalyticsUserID")
|
||||
val properties = DatabaseManager.getInstance().properties
|
||||
var id = properties.getString("AnalyticsUserID")
|
||||
if (id.isNullOrBlank()) {
|
||||
id = UUID.randomUUID().toSimpleString()
|
||||
Database.getDatabase().properties.putString("AnalyticsUserID", id)
|
||||
id = randomUUID()
|
||||
properties.putString("AnalyticsUserID", id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
15
src/main/kotlin/app/termora/ApplicationRunnerExtension.kt
Normal file
15
src/main/kotlin/app/termora/ApplicationRunnerExtension.kt
Normal file
@@ -0,0 +1,15 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.plugin.DispatchThread
|
||||
import app.termora.plugin.Extension
|
||||
|
||||
interface ApplicationRunnerExtension : Extension {
|
||||
/**
|
||||
* 准备就绪,说明数据库、插件、i18n 等一切数据准备就绪,下一步就是启动窗口。
|
||||
*
|
||||
* 插件可以在这里初始化自己的数据
|
||||
*/
|
||||
fun ready()
|
||||
|
||||
override fun getDispatchThread() = DispatchThread.BGT
|
||||
}
|
||||
@@ -34,12 +34,12 @@ class ApplicationSingleton private constructor() : Disposable {
|
||||
try {
|
||||
synchronized(this) {
|
||||
singleton = this.isSingleton
|
||||
if (singleton != null) return singleton as Boolean
|
||||
if (singleton != null) return singleton
|
||||
|
||||
if (SystemInfo.isWindows) {
|
||||
val handle = Kernel32.INSTANCE.CreateMutex(null, false, Application.getName())
|
||||
singleton = handle != null && Kernel32.INSTANCE.GetLastError() != WinError.ERROR_ALREADY_EXISTS
|
||||
if (singleton == true) {
|
||||
if (singleton) {
|
||||
// 启动监听器,方便激活窗口
|
||||
Thread.ofVirtual().start(Win32HelperWindow.getInstance())
|
||||
} else {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.JPopupMenu
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class BackgroundManager private constructor() {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(BackgroundManager::class.java)
|
||||
fun getInstance(): BackgroundManager {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(BackgroundManager::class) { BackgroundManager() }
|
||||
}
|
||||
}
|
||||
|
||||
private val appearance get() = Database.getDatabase().appearance
|
||||
private var bufferedImage: BufferedImage? = null
|
||||
private var imageFilepath = StringUtils.EMPTY
|
||||
|
||||
fun setBackgroundImage(file: File) {
|
||||
synchronized(this) {
|
||||
try {
|
||||
bufferedImage = file.inputStream().use { ImageIO.read(it) }
|
||||
imageFilepath = file.absolutePath
|
||||
appearance.backgroundImage = file.absolutePath
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
for (window in TermoraFrameManager.getInstance().getWindows()) {
|
||||
SwingUtilities.updateComponentTreeUI(window)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getBackgroundImage(): BufferedImage? {
|
||||
val bg = doGetBackgroundImage()
|
||||
if (bg == null) {
|
||||
if (JPopupMenu.getDefaultLightWeightPopupEnabled()) {
|
||||
return null
|
||||
} else {
|
||||
JPopupMenu.setDefaultLightWeightPopupEnabled(true)
|
||||
}
|
||||
} else {
|
||||
if (JPopupMenu.getDefaultLightWeightPopupEnabled()) {
|
||||
JPopupMenu.setDefaultLightWeightPopupEnabled(false)
|
||||
}
|
||||
}
|
||||
return bg
|
||||
}
|
||||
|
||||
private fun doGetBackgroundImage(): BufferedImage? {
|
||||
synchronized(this) {
|
||||
if (bufferedImage == null || imageFilepath.isEmpty()) {
|
||||
if (appearance.backgroundImage.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val file = File(appearance.backgroundImage)
|
||||
if (file.exists()) {
|
||||
setBackgroundImage(file)
|
||||
}
|
||||
}
|
||||
|
||||
return bufferedImage
|
||||
}
|
||||
}
|
||||
|
||||
fun clearBackgroundImage() {
|
||||
synchronized(this) {
|
||||
bufferedImage = null
|
||||
imageFilepath = StringUtils.EMPTY
|
||||
appearance.backgroundImage = StringUtils.EMPTY
|
||||
SwingUtilities.invokeLater {
|
||||
for (window in TermoraFrameManager.getInstance().getWindows()) {
|
||||
SwingUtilities.updateComponentTreeUI(window)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/main/kotlin/app/termora/CheckBoxMenuItemColorIcon.kt
Normal file
29
src/main/kotlin/app/termora/CheckBoxMenuItemColorIcon.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
package app.termora
|
||||
|
||||
import java.awt.Component
|
||||
import java.awt.Graphics
|
||||
import javax.swing.Icon
|
||||
import javax.swing.UIManager
|
||||
|
||||
class CheckBoxMenuItemColorIcon(
|
||||
private val colorIcon: ColorIcon,
|
||||
private val selected: Boolean,
|
||||
) : Icon {
|
||||
private val checkIcon = UIManager.getIcon("CheckBoxMenuItem.checkIcon")
|
||||
|
||||
override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) {
|
||||
if (selected) {
|
||||
checkIcon.paintIcon(c, g, x, y)
|
||||
}
|
||||
colorIcon.paintIcon(c, g, x + checkIcon.iconWidth + 6, y)
|
||||
}
|
||||
|
||||
override fun getIconWidth(): Int {
|
||||
return colorIcon.iconWidth + checkIcon.iconWidth + 6
|
||||
}
|
||||
|
||||
override fun getIconHeight(): Int {
|
||||
return colorIcon.iconHeight
|
||||
}
|
||||
|
||||
}
|
||||
19
src/main/kotlin/app/termora/ColorHash.kt
Normal file
19
src/main/kotlin/app/termora/ColorHash.kt
Normal file
@@ -0,0 +1,19 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.codec.digest.MurmurHash3
|
||||
import java.awt.Color
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
object ColorHash {
|
||||
fun hash(text: String): Color {
|
||||
val hash = MurmurHash3.hash32x86(text.toByteArray())
|
||||
|
||||
val r = (hash shr 16) and 0xFF
|
||||
val g = (hash shr 8) and 0xFF
|
||||
val b = hash and 0xFF
|
||||
|
||||
val color = Color(r, g, b)
|
||||
|
||||
return color.darker()
|
||||
}
|
||||
}
|
||||
36
src/main/kotlin/app/termora/ColorIcon.kt
Normal file
36
src/main/kotlin/app/termora/ColorIcon.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package app.termora
|
||||
|
||||
import java.awt.Color
|
||||
import java.awt.Component
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import javax.swing.Icon
|
||||
|
||||
class ColorIcon(
|
||||
private val width: Int = 16,
|
||||
private val height: Int = 16,
|
||||
private val color: Color,
|
||||
private val circle: Boolean = true
|
||||
) : Icon {
|
||||
override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) {
|
||||
if (g is Graphics2D) {
|
||||
g.save()
|
||||
setupAntialiasing(g)
|
||||
g.color = color
|
||||
if (circle) {
|
||||
g.fillRoundRect(x, y, width, width, width, width)
|
||||
} else {
|
||||
g.fillRect(x, y, iconWidth, iconHeight)
|
||||
}
|
||||
g.restore()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getIconWidth(): Int {
|
||||
return width
|
||||
}
|
||||
|
||||
override fun getIconHeight(): Int {
|
||||
return height
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,54 @@
|
||||
package app.termora
|
||||
|
||||
import com.fasterxml.uuid.Generators
|
||||
import org.apache.commons.codec.binary.Base64
|
||||
import org.apache.commons.lang3.RandomUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.io.InputStream
|
||||
import java.security.*
|
||||
import java.security.spec.MGF1ParameterSpec
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.time.measureTime
|
||||
import javax.crypto.spec.*
|
||||
|
||||
private val jug = Generators.timeBasedEpochRandomGenerator(SecureRandom.getInstanceStrong())
|
||||
|
||||
|
||||
fun randomUUID(): String {
|
||||
return jug.generate().toString().replace("-", StringUtils.EMPTY)
|
||||
}
|
||||
|
||||
object AES {
|
||||
private const val ALGORITHM = "AES"
|
||||
|
||||
|
||||
object GCM {
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val GCM_TAG_LENGTH = 128
|
||||
|
||||
fun encrypt(key: ByteArray, iv: ByteArray, data: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(
|
||||
Cipher.ENCRYPT_MODE,
|
||||
SecretKeySpec(key, ALGORITHM),
|
||||
GCMParameterSpec(GCM_TAG_LENGTH, iv)
|
||||
)
|
||||
return cipher.doFinal(data)
|
||||
}
|
||||
|
||||
fun decrypt(key: ByteArray, iv: ByteArray, data: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
SecretKeySpec(key, ALGORITHM),
|
||||
GCMParameterSpec(GCM_TAG_LENGTH, iv)
|
||||
)
|
||||
return cipher.doFinal(data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ECB 没有 IV
|
||||
*/
|
||||
@@ -86,24 +119,11 @@ object AES {
|
||||
object PBKDF2 {
|
||||
|
||||
private const val ALGORITHM = "PBKDF2WithHmacSHA512"
|
||||
private val log = LoggerFactory.getLogger(PBKDF2::class.java)
|
||||
|
||||
fun generateSecret(
|
||||
password: CharArray,
|
||||
salt: ByteArray,
|
||||
iterationCount: Int = 150000,
|
||||
keyLength: Int = 256
|
||||
): ByteArray {
|
||||
val bytes: ByteArray
|
||||
val time = measureTime {
|
||||
bytes = SecretKeyFactory.getInstance(ALGORITHM)
|
||||
.generateSecret(PBEKeySpec(password, salt, iterationCount, keyLength))
|
||||
.encoded
|
||||
}
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Secret generated $time")
|
||||
}
|
||||
return bytes
|
||||
fun hash(slat: ByteArray, password: CharArray, iterationCount: Int, keyLength: Int): ByteArray {
|
||||
val spec = PBEKeySpec(password, slat, iterationCount, keyLength)
|
||||
val secretKeyFactory = SecretKeyFactory.getInstance(ALGORITHM)
|
||||
return secretKeyFactory.generateSecret(spec).encoded
|
||||
}
|
||||
|
||||
}
|
||||
@@ -111,45 +131,113 @@ object PBKDF2 {
|
||||
|
||||
object RSA {
|
||||
|
||||
private const val TRANSFORMATION = "RSA"
|
||||
|
||||
private const val TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"
|
||||
|
||||
fun encrypt(publicKey: PublicKey, data: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey, getOAEPParameterSpec())
|
||||
return cipher.doFinal(data)
|
||||
}
|
||||
|
||||
fun decrypt(privateKey: PrivateKey, data: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey)
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey, getOAEPParameterSpec())
|
||||
return cipher.doFinal(data)
|
||||
}
|
||||
|
||||
fun encrypt(privateKey: PrivateKey, data: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, privateKey)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, privateKey, getOAEPParameterSpec())
|
||||
return cipher.doFinal(data)
|
||||
}
|
||||
|
||||
fun decrypt(publicKey: PublicKey, data: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, publicKey)
|
||||
cipher.init(Cipher.DECRYPT_MODE, publicKey, getOAEPParameterSpec())
|
||||
return cipher.doFinal(data)
|
||||
}
|
||||
|
||||
private fun getOAEPParameterSpec(): OAEPParameterSpec {
|
||||
return OAEPParameterSpec(
|
||||
"SHA-256",
|
||||
"MGF1",
|
||||
MGF1ParameterSpec.SHA256,
|
||||
PSource.PSpecified.DEFAULT
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
fun generatePublic(publicKey: ByteArray): PublicKey {
|
||||
return KeyFactory.getInstance(TRANSFORMATION)
|
||||
return KeyFactory.getInstance("RSA")
|
||||
.generatePublic(X509EncodedKeySpec(publicKey))
|
||||
}
|
||||
|
||||
fun generatePrivate(privateKey: ByteArray): PrivateKey {
|
||||
return KeyFactory.getInstance(TRANSFORMATION)
|
||||
return KeyFactory.getInstance("RSA")
|
||||
.generatePrivate(PKCS8EncodedKeySpec(privateKey))
|
||||
}
|
||||
|
||||
fun generateKeyPair(keySize: Int = 2048): KeyPair {
|
||||
val generator = KeyPairGenerator.getInstance(TRANSFORMATION)
|
||||
fun generateKeyPair(keySize: Int): KeyPair {
|
||||
val generator = KeyPairGenerator.getInstance("RSA")
|
||||
generator.initialize(keySize)
|
||||
return generator.generateKeyPair()
|
||||
}
|
||||
|
||||
fun sign(privateKey: PrivateKey, data: ByteArray): ByteArray {
|
||||
val rsa = Signature.getInstance("SHA256withRSA")
|
||||
rsa.initSign(privateKey)
|
||||
rsa.update(data)
|
||||
return rsa.sign()
|
||||
}
|
||||
|
||||
fun verify(publicKey: PublicKey, data: ByteArray, signature: ByteArray): Boolean {
|
||||
val rsa = Signature.getInstance("SHA256withRSA")
|
||||
rsa.initVerify(publicKey)
|
||||
rsa.update(data)
|
||||
return rsa.verify(signature)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Ed25519 {
|
||||
fun sign(privateKey: PrivateKey, data: ByteArray): ByteArray {
|
||||
val signer = Signature.getInstance("Ed25519")
|
||||
signer.initSign(privateKey)
|
||||
signer.update(data)
|
||||
return signer.sign()
|
||||
}
|
||||
|
||||
fun verify(publicKey: PublicKey, data: ByteArray, signature: ByteArray): Boolean {
|
||||
return verify(publicKey, data.inputStream(), signature)
|
||||
}
|
||||
|
||||
fun verify(publicKey: PublicKey, input: InputStream, signature: ByteArray): Boolean {
|
||||
return runCatching {
|
||||
val verifier = Signature.getInstance("Ed25519")
|
||||
verifier.initVerify(publicKey)
|
||||
val buffer = ByteArray(1024)
|
||||
var len = 0
|
||||
while ((input.read(buffer).also { len = it }) != -1) {
|
||||
verifier.update(buffer, 0, len)
|
||||
}
|
||||
verifier.verify(signature)
|
||||
}.getOrNull() ?: false
|
||||
}
|
||||
|
||||
|
||||
fun generatePublic(publicKey: ByteArray): PublicKey {
|
||||
return KeyFactory.getInstance("Ed25519")
|
||||
.generatePublic(X509EncodedKeySpec(publicKey))
|
||||
}
|
||||
|
||||
fun generatePrivate(privateKey: ByteArray): PrivateKey {
|
||||
return KeyFactory.getInstance("Ed25519")
|
||||
.generatePrivate(PKCS8EncodedKeySpec(privateKey))
|
||||
}
|
||||
|
||||
fun generateKeyPair(): KeyPair {
|
||||
val generator = KeyPairGenerator.getInstance("Ed25519")
|
||||
return generator.generateKeyPair()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app.termora
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.actions.MultipleAction
|
||||
import app.termora.database.DatabaseManager
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
@@ -364,7 +365,7 @@ class CustomizeToolBarDialog(
|
||||
actions.add(ToolBarAction(leftList.model.getElementAt(i).id, false))
|
||||
}
|
||||
|
||||
Database.getDatabase()
|
||||
DatabaseManager.getInstance()
|
||||
.properties.putString("Termora.ToolBar.Actions", ohMyJson.encodeToString(actions))
|
||||
|
||||
super.doOKAction()
|
||||
|
||||
@@ -1,759 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.highlight.KeywordHighlight
|
||||
import app.termora.keymap.Keymap
|
||||
import app.termora.keymgr.OhKeyPair
|
||||
import app.termora.macro.Macro
|
||||
import app.termora.snippet.Snippet
|
||||
import app.termora.sync.SyncManager
|
||||
import app.termora.sync.SyncType
|
||||
import app.termora.terminal.CursorStyle
|
||||
import jetbrains.exodus.bindings.StringBinding
|
||||
import jetbrains.exodus.env.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class Database private constructor(private val env: Environment) : Disposable {
|
||||
companion object {
|
||||
private const val KEYMAP_STORE = "Keymap"
|
||||
private const val HOST_STORE = "Host"
|
||||
private const val SNIPPET_STORE = "Snippet"
|
||||
private const val KEYWORD_HIGHLIGHT_STORE = "KeywordHighlight"
|
||||
private const val MACRO_STORE = "Macro"
|
||||
private const val KEY_PAIR_STORE = "KeyPair"
|
||||
private const val DELETED_DATA_STORE = "DeletedData"
|
||||
private val log = LoggerFactory.getLogger(Database::class.java)
|
||||
|
||||
|
||||
private fun open(dir: File): Database {
|
||||
val config = EnvironmentConfig()
|
||||
// 32MB
|
||||
config.setLogFileSize(1024 * 32)
|
||||
config.setGcEnabled(true)
|
||||
// 5m
|
||||
config.setGcStartIn(5.minutes.inWholeMilliseconds.toInt())
|
||||
val environment = Environments.newInstance(dir, config)
|
||||
return Database(environment)
|
||||
}
|
||||
|
||||
fun getDatabase(): Database {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(Database::class) { open(Application.getDatabaseFile()) }
|
||||
}
|
||||
}
|
||||
|
||||
val properties by lazy { Properties() }
|
||||
val safetyProperties by lazy { SafetyProperties("Setting.SafetyProperties") }
|
||||
val terminal by lazy { Terminal() }
|
||||
val appearance by lazy { Appearance() }
|
||||
val sftp by lazy { SFTP() }
|
||||
val sync by lazy { Sync() }
|
||||
|
||||
private val doorman get() = Doorman.getInstance()
|
||||
|
||||
|
||||
fun getKeymaps(): Collection<Keymap> {
|
||||
val array = env.computeInTransaction { tx ->
|
||||
openCursor<String>(tx, KEYMAP_STORE) { _, value ->
|
||||
value
|
||||
}.values
|
||||
}
|
||||
|
||||
val keymaps = mutableListOf<Keymap>()
|
||||
for (text in array.iterator()) {
|
||||
keymaps.add(Keymap.fromJSON(text) ?: continue)
|
||||
}
|
||||
|
||||
return keymaps
|
||||
}
|
||||
|
||||
fun addKeymap(keymap: Keymap) {
|
||||
env.executeInTransaction {
|
||||
put(it, KEYMAP_STORE, keymap.name, keymap.toJSON())
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Added Keymap: ${keymap.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeKeymap(name: String) {
|
||||
env.executeInTransaction {
|
||||
delete(it, KEYMAP_STORE, name)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Removed Keymap: $name")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getHosts(): Collection<Host> {
|
||||
val isWorking = doorman.isWorking()
|
||||
return env.computeInTransaction { tx ->
|
||||
openCursor<Host>(tx, HOST_STORE) { _, value ->
|
||||
if (isWorking)
|
||||
ohMyJson.decodeFromString(doorman.decrypt(value))
|
||||
else
|
||||
ohMyJson.decodeFromString(value)
|
||||
}.values
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAllKeyPair() {
|
||||
env.executeInTransaction { tx ->
|
||||
val store = env.openStore(KEY_PAIR_STORE, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
|
||||
store.openCursor(tx).use {
|
||||
while (it.next) {
|
||||
it.deleteCurrent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getKeyPairs(): Collection<OhKeyPair> {
|
||||
val isWorking = doorman.isWorking()
|
||||
return env.computeInTransaction { tx ->
|
||||
openCursor<OhKeyPair>(tx, KEY_PAIR_STORE) { _, value ->
|
||||
if (isWorking)
|
||||
ohMyJson.decodeFromString(doorman.decrypt(value))
|
||||
else
|
||||
ohMyJson.decodeFromString(value)
|
||||
}.values
|
||||
}
|
||||
}
|
||||
|
||||
fun addHost(host: Host) {
|
||||
var text = ohMyJson.encodeToString(host)
|
||||
if (doorman.isWorking()) {
|
||||
text = doorman.encrypt(text)
|
||||
}
|
||||
env.executeInTransaction {
|
||||
put(it, HOST_STORE, host.id, text)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Added Host: ${host.id} , ${host.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeHost(id: String) {
|
||||
env.executeInTransaction {
|
||||
delete(it, HOST_STORE, id)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Removed host: $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addDeletedData(deletedData: DeletedData) {
|
||||
val text = ohMyJson.encodeToString(deletedData)
|
||||
env.executeInTransaction {
|
||||
put(it, DELETED_DATA_STORE, deletedData.id, text)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Added DeletedData: ${deletedData.id} , $text")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getDeletedData(): Collection<DeletedData> {
|
||||
return env.computeInTransaction { tx ->
|
||||
openCursor<DeletedData?>(tx, DELETED_DATA_STORE) { _, value ->
|
||||
try {
|
||||
ohMyJson.decodeFromString(value)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}.values.filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun addSnippet(snippet: Snippet) {
|
||||
var text = ohMyJson.encodeToString(snippet)
|
||||
if (doorman.isWorking()) {
|
||||
text = doorman.encrypt(text)
|
||||
}
|
||||
env.executeInTransaction {
|
||||
put(it, SNIPPET_STORE, snippet.id, text)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Added Snippet: ${snippet.id} , ${snippet.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSnippet(id: String) {
|
||||
env.executeInTransaction {
|
||||
delete(it, SNIPPET_STORE, id)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Removed snippet: $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSnippets(): Collection<Snippet> {
|
||||
val isWorking = doorman.isWorking()
|
||||
return env.computeInTransaction { tx ->
|
||||
openCursor<Snippet>(tx, SNIPPET_STORE) { _, value ->
|
||||
if (isWorking)
|
||||
ohMyJson.decodeFromString(doorman.decrypt(value))
|
||||
else
|
||||
ohMyJson.decodeFromString(value)
|
||||
}.values
|
||||
}
|
||||
}
|
||||
|
||||
fun getKeywordHighlights(): Collection<KeywordHighlight> {
|
||||
return env.computeInTransaction { tx ->
|
||||
openCursor<KeywordHighlight>(tx, KEYWORD_HIGHLIGHT_STORE) { _, value ->
|
||||
ohMyJson.decodeFromString(value)
|
||||
}.values
|
||||
}
|
||||
}
|
||||
|
||||
fun addKeywordHighlight(keywordHighlight: KeywordHighlight) {
|
||||
val text = ohMyJson.encodeToString(keywordHighlight)
|
||||
env.executeInTransaction {
|
||||
put(it, KEYWORD_HIGHLIGHT_STORE, keywordHighlight.id, text)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Added keyword highlight: ${keywordHighlight.id} , ${keywordHighlight.keyword}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeKeywordHighlight(id: String) {
|
||||
env.executeInTransaction {
|
||||
delete(it, KEYWORD_HIGHLIGHT_STORE, id)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Removed keyword highlight: $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getMacros(): Collection<Macro> {
|
||||
return env.computeInTransaction { tx ->
|
||||
openCursor<Macro>(tx, MACRO_STORE) { _, value ->
|
||||
ohMyJson.decodeFromString(value)
|
||||
}.values
|
||||
}
|
||||
}
|
||||
|
||||
fun addMacro(macro: Macro) {
|
||||
val text = ohMyJson.encodeToString(macro)
|
||||
env.executeInTransaction {
|
||||
put(it, MACRO_STORE, macro.id, text)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Added macro: ${macro.id}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMacro(id: String) {
|
||||
env.executeInTransaction {
|
||||
delete(it, MACRO_STORE, id)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Removed macro: $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addKeyPair(key: OhKeyPair) {
|
||||
var text = ohMyJson.encodeToString(key)
|
||||
if (doorman.isWorking()) {
|
||||
text = doorman.encrypt(text)
|
||||
}
|
||||
env.executeInTransaction {
|
||||
put(it, KEY_PAIR_STORE, key.id, text)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Added Key Pair: ${key.id} , ${key.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeKeyPair(id: String) {
|
||||
env.executeInTransaction {
|
||||
delete(it, KEY_PAIR_STORE, id)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Removed Key Pair: $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun put(tx: Transaction, name: String, key: String, value: String) {
|
||||
val store = env.openStore(name, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
|
||||
val k = StringBinding.stringToEntry(key)
|
||||
val v = StringBinding.stringToEntry(value)
|
||||
store.put(tx, k, v)
|
||||
|
||||
// 数据变动时触发一次同步
|
||||
if (name == HOST_STORE ||
|
||||
name == KEYMAP_STORE ||
|
||||
name == SNIPPET_STORE ||
|
||||
name == KEYWORD_HIGHLIGHT_STORE ||
|
||||
name == MACRO_STORE ||
|
||||
name == KEY_PAIR_STORE ||
|
||||
name == DELETED_DATA_STORE
|
||||
) {
|
||||
SyncManager.getInstance().triggerOnChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete(tx: Transaction, name: String, key: String) {
|
||||
val store = env.openStore(name, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
|
||||
val k = StringBinding.stringToEntry(key)
|
||||
store.delete(tx, k)
|
||||
}
|
||||
|
||||
fun getSafetyProperties(): List<SafetyProperties> {
|
||||
return listOf(sync, safetyProperties)
|
||||
}
|
||||
|
||||
private inline fun <reified T> openCursor(
|
||||
tx: Transaction,
|
||||
name: String,
|
||||
callback: (key: String, value: String) -> T
|
||||
): Map<String, T> {
|
||||
val store = env.openStore(name, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
|
||||
val map = mutableMapOf<String, T>()
|
||||
store.openCursor(tx).use {
|
||||
while (it.next) {
|
||||
try {
|
||||
val key = StringBinding.entryToString(it.key)
|
||||
map[key] = callback.invoke(
|
||||
key,
|
||||
StringBinding.entryToString(it.value)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Decode data failed. data: {}", it.value, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private fun putString(name: String, map: Map<String, String>) {
|
||||
return env.computeInTransaction {
|
||||
val store = env.openStore(name, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, it)
|
||||
for ((key, value) in map.entries) {
|
||||
store.put(it, StringBinding.stringToEntry(key), StringBinding.stringToEntry(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getString(name: String, key: String): String? {
|
||||
return env.computeInTransaction {
|
||||
val store = env.openStore(name, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, it)
|
||||
val value = store.get(it, StringBinding.stringToEntry(key))
|
||||
if (value == null) null else StringBinding.entryToString(value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
abstract inner class Property(private val name: String) {
|
||||
private val properties = Collections.synchronizedMap(mutableMapOf<String, String>())
|
||||
|
||||
init {
|
||||
swingCoroutineScope.launch(Dispatchers.IO) { properties.putAll(getProperties()) }
|
||||
}
|
||||
|
||||
protected open fun getString(key: String): String? {
|
||||
if (properties.containsKey(key)) {
|
||||
return properties[key]
|
||||
}
|
||||
return getString(name, key)
|
||||
}
|
||||
|
||||
open fun getProperties(): Map<String, String> {
|
||||
return env.computeInTransaction { tx ->
|
||||
openCursor<String>(
|
||||
tx,
|
||||
name
|
||||
) { _, value -> value }
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun putString(key: String, value: String) {
|
||||
properties[key] = value
|
||||
putString(name, mapOf(key to value))
|
||||
}
|
||||
|
||||
|
||||
protected abstract inner class PropertyLazyDelegate<T>(protected val initializer: () -> T) :
|
||||
ReadWriteProperty<Any?, T> {
|
||||
private var value: T? = null
|
||||
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
|
||||
if (value == null) {
|
||||
val v = getString(property.name)
|
||||
value = if (v == null) {
|
||||
initializer.invoke()
|
||||
} else {
|
||||
convertValue(v)
|
||||
}
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
value = initializer.invoke()
|
||||
}
|
||||
return value!!
|
||||
}
|
||||
|
||||
abstract fun convertValue(value: String): T
|
||||
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
|
||||
this.value = value
|
||||
putString(property.name, value.toString())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected abstract inner class PropertyDelegate<T>(private val defaultValue: T) :
|
||||
PropertyLazyDelegate<T>({ defaultValue })
|
||||
|
||||
|
||||
protected inner class StringPropertyDelegate(defaultValue: String) :
|
||||
PropertyDelegate<String>(defaultValue) {
|
||||
override fun convertValue(value: String): String {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class IntPropertyDelegate(defaultValue: Int) :
|
||||
PropertyDelegate<Int>(defaultValue) {
|
||||
override fun convertValue(value: String): Int {
|
||||
return value.toIntOrNull() ?: initializer.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class DoublePropertyDelegate(defaultValue: Double) :
|
||||
PropertyDelegate<Double>(defaultValue) {
|
||||
override fun convertValue(value: String): Double {
|
||||
return value.toDoubleOrNull() ?: initializer.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected inner class LongPropertyDelegate(defaultValue: Long) :
|
||||
PropertyDelegate<Long>(defaultValue) {
|
||||
override fun convertValue(value: String): Long {
|
||||
return value.toLongOrNull() ?: initializer.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class BooleanPropertyDelegate(defaultValue: Boolean) :
|
||||
PropertyDelegate<Boolean>(defaultValue) {
|
||||
override fun convertValue(value: String): Boolean {
|
||||
return value.toBooleanStrictOrNull() ?: initializer.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
protected open inner class StringPropertyLazyDelegate(initializer: () -> String) :
|
||||
PropertyLazyDelegate<String>(initializer) {
|
||||
override fun convertValue(value: String): String {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected inner class CursorStylePropertyDelegate(defaultValue: CursorStyle) :
|
||||
PropertyDelegate<CursorStyle>(defaultValue) {
|
||||
override fun convertValue(value: String): CursorStyle {
|
||||
return try {
|
||||
CursorStyle.valueOf(value)
|
||||
} catch (_: Exception) {
|
||||
initializer.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected inner class SyncTypePropertyDelegate(defaultValue: SyncType) :
|
||||
PropertyDelegate<SyncType>(defaultValue) {
|
||||
override fun convertValue(value: String): SyncType {
|
||||
try {
|
||||
return SyncType.valueOf(value)
|
||||
} catch (e: Exception) {
|
||||
return initializer.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 终端设置
|
||||
*/
|
||||
inner class Terminal : Property("Setting.Terminal") {
|
||||
|
||||
/**
|
||||
* 字体
|
||||
*/
|
||||
var font by StringPropertyDelegate("JetBrains Mono")
|
||||
|
||||
/**
|
||||
* 默认终端
|
||||
*/
|
||||
var localShell by StringPropertyLazyDelegate { Application.getDefaultShell() }
|
||||
|
||||
/**
|
||||
* 字体大小
|
||||
*/
|
||||
var fontSize by IntPropertyDelegate(14)
|
||||
|
||||
/**
|
||||
* 最大行数
|
||||
*/
|
||||
var maxRows by IntPropertyDelegate(5000)
|
||||
|
||||
/**
|
||||
* 调试模式
|
||||
*/
|
||||
var debug by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 蜂鸣声
|
||||
*/
|
||||
var beep by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* 超链接
|
||||
*/
|
||||
var hyperlink by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* 光标闪烁
|
||||
*/
|
||||
var cursorBlink by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 选中复制
|
||||
*/
|
||||
var selectCopy by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 光标样式
|
||||
*/
|
||||
var cursor by CursorStylePropertyDelegate(CursorStyle.Block)
|
||||
|
||||
/**
|
||||
* 终端断开连接时自动关闭Tab
|
||||
*/
|
||||
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 是否显示悬浮工具栏
|
||||
*/
|
||||
var floatingToolbar by BooleanPropertyDelegate(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用属性
|
||||
*/
|
||||
inner class Properties : Property("Setting.Properties") {
|
||||
public override fun getString(key: String): String? {
|
||||
return super.getString(key)
|
||||
}
|
||||
|
||||
|
||||
fun getString(key: String, defaultValue: String): String {
|
||||
return getString(key) ?: defaultValue
|
||||
}
|
||||
|
||||
public override fun putString(key: String, value: String) {
|
||||
super.putString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 安全的通用属性
|
||||
*/
|
||||
open inner class SafetyProperties(name: String) : Property(name) {
|
||||
private val doorman get() = Doorman.getInstance()
|
||||
|
||||
public override fun getString(key: String): String? {
|
||||
var value = super.getString(key)
|
||||
if (value != null && doorman.isWorking()) {
|
||||
try {
|
||||
value = doorman.decrypt(value)
|
||||
} catch (e: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("decryption key: [{}], value: [{}] failed: {}", key, value, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
|
||||
override fun getProperties(): Map<String, String> {
|
||||
val properties = super.getProperties()
|
||||
val map = mutableMapOf<String, String>()
|
||||
if (doorman.isWorking()) {
|
||||
for ((k, v) in properties) {
|
||||
try {
|
||||
map[k] = doorman.decrypt(v)
|
||||
} catch (e: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("decryption key: [{}], value: [{}] failed: {}", k, v, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
map.putAll(properties)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
fun getString(key: String, defaultValue: String): String {
|
||||
return getString(key) ?: defaultValue
|
||||
}
|
||||
|
||||
public override fun putString(key: String, value: String) {
|
||||
val v = if (doorman.isWorking()) doorman.encrypt(value) else value
|
||||
super.putString(key, v)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 外观
|
||||
*/
|
||||
inner class Appearance : Property("Setting.Appearance") {
|
||||
|
||||
|
||||
/**
|
||||
* 外观
|
||||
*/
|
||||
var theme by StringPropertyDelegate("Light")
|
||||
|
||||
/**
|
||||
* 跟随系统
|
||||
*/
|
||||
var followSystem by BooleanPropertyDelegate(true)
|
||||
var darkTheme by StringPropertyDelegate("Dark")
|
||||
var lightTheme by StringPropertyDelegate("Light")
|
||||
|
||||
/**
|
||||
* 允许后台运行,也就是托盘
|
||||
*/
|
||||
var backgroundRunning by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 标签关闭前确认
|
||||
*/
|
||||
var confirmTabClose by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 背景图片的地址
|
||||
*/
|
||||
var backgroundImage by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* 语言
|
||||
*/
|
||||
var language by StringPropertyLazyDelegate {
|
||||
I18n.containsLanguage(Locale.getDefault()) ?: Locale.US.toString()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 透明度
|
||||
*/
|
||||
var opacity by DoublePropertyDelegate(1.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* SFTP
|
||||
*/
|
||||
inner class SFTP : Property("Setting.SFTP") {
|
||||
|
||||
|
||||
/**
|
||||
* 编辑命令
|
||||
*/
|
||||
var editCommand by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
|
||||
/**
|
||||
* sftp command
|
||||
*/
|
||||
var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* defaultDirectory
|
||||
*/
|
||||
var defaultDirectory by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
|
||||
/**
|
||||
* 是否固定在标签栏
|
||||
*/
|
||||
var pinTab by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 是否保留原始文件时间
|
||||
*/
|
||||
var preserveModificationTime by BooleanPropertyDelegate(false)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步配置
|
||||
*/
|
||||
inner class Sync : SafetyProperties("Setting.Sync") {
|
||||
/**
|
||||
* 同步类型
|
||||
*/
|
||||
var type by SyncTypePropertyDelegate(SyncType.GitHub)
|
||||
|
||||
/**
|
||||
* 范围
|
||||
*/
|
||||
var rangeHosts by BooleanPropertyDelegate(true)
|
||||
var rangeKeyPairs by BooleanPropertyDelegate(true)
|
||||
var rangeSnippets by BooleanPropertyDelegate(true)
|
||||
var rangeKeywordHighlights by BooleanPropertyDelegate(true)
|
||||
var rangeMacros by BooleanPropertyDelegate(true)
|
||||
var rangeKeymap by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* Token
|
||||
*/
|
||||
var token by StringPropertyDelegate(String())
|
||||
|
||||
/**
|
||||
* Gist ID
|
||||
*/
|
||||
var gist by StringPropertyDelegate(String())
|
||||
|
||||
/**
|
||||
* Domain
|
||||
*/
|
||||
var domain by StringPropertyDelegate(String())
|
||||
|
||||
/**
|
||||
* 最后同步时间
|
||||
*/
|
||||
var lastSyncTime by LongPropertyDelegate(0L)
|
||||
|
||||
/**
|
||||
* 同步策略,为空就是默认手动
|
||||
*/
|
||||
var policy by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
IOUtils.closeQuietly(env)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.database.DatabaseManager
|
||||
|
||||
/**
|
||||
* 仅标记
|
||||
*/
|
||||
@@ -11,7 +13,7 @@ class DeleteDataManager private constructor() {
|
||||
}
|
||||
|
||||
private val data = mutableMapOf<String, DeletedData>()
|
||||
private val database get() = Database.getDatabase()
|
||||
private val database get() = DatabaseManager.getInstance()
|
||||
|
||||
fun removeHost(id: String, deleteDate: Long = System.currentTimeMillis()) {
|
||||
addDeletedData(DeletedData(id, "Host", deleteDate))
|
||||
@@ -40,12 +42,12 @@ class DeleteDataManager private constructor() {
|
||||
private fun addDeletedData(deletedData: DeletedData) {
|
||||
if (data.containsKey(deletedData.id)) return
|
||||
data[deletedData.id] = deletedData
|
||||
database.addDeletedData(deletedData)
|
||||
// TODO database.addDeletedData(deletedData)
|
||||
}
|
||||
|
||||
fun getDeletedData(): List<DeletedData> {
|
||||
if (data.isEmpty()) {
|
||||
data.putAll(database.getDeletedData().associateBy { it.id })
|
||||
// TODO data.putAll(database.getDeletedData().associateBy { it.id })
|
||||
}
|
||||
return data.values.sortedBy { it.deleteDate }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package app.termora
|
||||
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.native.osx.NativeMacLibrary
|
||||
import app.termora.nv.osx.NativeMacLibrary
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jetbrains.JBR
|
||||
@@ -147,13 +147,17 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
}
|
||||
|
||||
protected open fun createActions(): List<AbstractAction> {
|
||||
return listOf(createOkAction(), CancelAction())
|
||||
return listOf(createOkAction(), createCancelAction())
|
||||
}
|
||||
|
||||
protected open fun createOkAction(): AbstractAction {
|
||||
return OkAction()
|
||||
}
|
||||
|
||||
protected open fun createCancelAction(): AbstractAction {
|
||||
return CancelAction()
|
||||
}
|
||||
|
||||
protected open fun createJButtonForAction(action: Action): JButton {
|
||||
val button = JButton(action)
|
||||
val value = action.getValue(DEFAULT_ACTION)
|
||||
@@ -196,30 +200,32 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
rootPane.actionMap.put("close", object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val c = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
|
||||
val popups: List<JPopupMenu> = SwingUtils.getDescendantsOfType(
|
||||
JPopupMenu::class.java,
|
||||
c as Container, true
|
||||
)
|
||||
if (c != null) {
|
||||
val popups: List<JPopupMenu> = SwingUtils.getDescendantsOfType(
|
||||
JPopupMenu::class.java,
|
||||
c as Container, true
|
||||
)
|
||||
|
||||
var openPopup = false
|
||||
for (p in popups) {
|
||||
p.isVisible = false
|
||||
openPopup = true
|
||||
}
|
||||
var openPopup = false
|
||||
for (p in popups) {
|
||||
p.isVisible = false
|
||||
openPopup = true
|
||||
}
|
||||
|
||||
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()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openPopup) {
|
||||
return
|
||||
if (openPopup) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater { doCancelAction() }
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.AES.decodeBase64
|
||||
import app.termora.AES.encodeBase64String
|
||||
|
||||
class PasswordWrongException : RuntimeException()
|
||||
|
||||
class Doorman private constructor() : Disposable {
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private var key = byteArrayOf()
|
||||
|
||||
companion object {
|
||||
fun getInstance(): Doorman {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(Doorman::class) { Doorman() }
|
||||
}
|
||||
}
|
||||
|
||||
fun isWorking(): Boolean {
|
||||
return properties.getString("doorman", "false").toBoolean()
|
||||
}
|
||||
|
||||
fun encrypt(text: String): String {
|
||||
checkIsWorking()
|
||||
return AES.ECB.encrypt(key, text.toByteArray()).encodeBase64String()
|
||||
}
|
||||
|
||||
|
||||
fun decrypt(text: String): String {
|
||||
checkIsWorking()
|
||||
return AES.ECB.decrypt(key, text.decodeBase64()).decodeToString()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 返回钥匙
|
||||
*/
|
||||
fun work(password: CharArray): ByteArray {
|
||||
if (key.isNotEmpty()) {
|
||||
throw IllegalStateException("Working")
|
||||
}
|
||||
return work(convertKey(password))
|
||||
}
|
||||
|
||||
fun work(key: ByteArray): ByteArray {
|
||||
val verify = properties.getString("doorman-verify")
|
||||
if (verify == null) {
|
||||
properties.putString(
|
||||
"doorman-verify",
|
||||
AES.ECB.encrypt(key, factor()).encodeBase64String()
|
||||
)
|
||||
} else {
|
||||
try {
|
||||
if (!AES.ECB.decrypt(key, verify.decodeBase64()).contentEquals(factor())) {
|
||||
throw PasswordWrongException()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw PasswordWrongException()
|
||||
}
|
||||
}
|
||||
|
||||
this.key = key
|
||||
properties.putString("doorman", "true")
|
||||
|
||||
return this.key
|
||||
}
|
||||
|
||||
|
||||
private fun convertKey(password: CharArray): ByteArray {
|
||||
return PBKDF2.generateSecret(password, factor())
|
||||
}
|
||||
|
||||
|
||||
private fun checkIsWorking() {
|
||||
if (key.isEmpty() || !isWorking()) {
|
||||
throw UnsupportedOperationException("Doorman is not working")
|
||||
}
|
||||
}
|
||||
|
||||
private fun factor(): ByteArray {
|
||||
return Application.getName().toByteArray()
|
||||
}
|
||||
|
||||
fun test(password: CharArray): Boolean {
|
||||
checkIsWorking()
|
||||
return key.contentEquals(convertKey(password))
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
key = byteArrayOf()
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.AES.decodeBase64
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.terminal.ControlCharacters
|
||||
import cash.z.ecc.android.bip39.Mnemonics
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon
|
||||
import com.formdev.flatlaf.extras.components.FlatButton
|
||||
import com.formdev.flatlaf.extras.components.FlatLabel
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.JXHyperlink
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.event.KeyAdapter
|
||||
import java.awt.event.KeyEvent
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.*
|
||||
import kotlin.math.max
|
||||
|
||||
class DoormanDialog(owner: Window?) : DialogWrapper(owner) {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(DoormanDialog::class.java)
|
||||
}
|
||||
|
||||
private val formMargin = "7dlu"
|
||||
private val label = FlatLabel()
|
||||
private val icon = JLabel()
|
||||
private val passwordTextField = OutlinePasswordField()
|
||||
private val tip = FlatLabel()
|
||||
private val safeBtn = FlatButton()
|
||||
|
||||
var isOpened = false
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
|
||||
isModal = true
|
||||
isResizable = false
|
||||
controlsVisible = false
|
||||
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
title = I18n.getString("termora.doorman.safe")
|
||||
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE, false)
|
||||
}
|
||||
|
||||
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
val sizes = listOf(16, 20, 24, 28, 32, 48, 64)
|
||||
val loader = TermoraFrame::class.java.classLoader
|
||||
val images = sizes.mapNotNull { e ->
|
||||
loader.getResourceAsStream("icons/termora_${e}x${e}.png")?.use { ImageIO.read(it) }
|
||||
}
|
||||
iconImages = images
|
||||
}
|
||||
|
||||
setLocationRelativeTo(null)
|
||||
init()
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
label.text = I18n.getString("termora.doorman.safe")
|
||||
tip.text = I18n.getString("termora.doorman.unlock-data")
|
||||
icon.icon = FlatSVGIcon(Icons.role.name, 80, 80)
|
||||
safeBtn.icon = Icons.unlocked
|
||||
|
||||
|
||||
label.labelType = FlatLabel.LabelType.h2
|
||||
label.horizontalAlignment = SwingConstants.CENTER
|
||||
safeBtn.isFocusable = false
|
||||
tip.foreground = UIManager.getColor("TextField.placeholderForeground")
|
||||
icon.horizontalAlignment = SwingConstants.CENTER
|
||||
|
||||
|
||||
safeBtn.addActionListener { doOKAction() }
|
||||
passwordTextField.addActionListener { doOKAction() }
|
||||
|
||||
var rows = 2
|
||||
val step = 2
|
||||
return FormBuilder.create().debug(false)
|
||||
.layout(
|
||||
FormLayout(
|
||||
"$formMargin, default:grow, 4dlu, pref, $formMargin",
|
||||
"${if (SystemInfo.isWindows) "20dlu" else "0dlu"}, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin"
|
||||
)
|
||||
)
|
||||
.add(icon).xyw(2, rows, 4).apply { rows += step }
|
||||
.add(label).xyw(2, rows, 4).apply { rows += step }
|
||||
.add(passwordTextField).xy(2, rows)
|
||||
.add(safeBtn).xy(4, rows).apply { rows += step }
|
||||
.add(tip).xyw(2, rows, 4, "center, fill").apply { rows += step }
|
||||
.add(JXHyperlink(object : AnAction(I18n.getString("termora.doorman.forget-password")) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val option = OptionPane.showConfirmDialog(
|
||||
this@DoormanDialog, I18n.getString("termora.doorman.forget-password-message"),
|
||||
options = arrayOf(
|
||||
I18n.getString("termora.doorman.have-a-mnemonic"),
|
||||
I18n.getString("termora.doorman.dont-have-a-mnemonic"),
|
||||
),
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
messageType = JOptionPane.INFORMATION_MESSAGE,
|
||||
initialValue = I18n.getString("termora.doorman.have-a-mnemonic")
|
||||
)
|
||||
if (option == JOptionPane.YES_OPTION) {
|
||||
showMnemonicsDialog()
|
||||
} else if (option == JOptionPane.NO_OPTION) {
|
||||
OptionPane.showMessageDialog(
|
||||
this@DoormanDialog,
|
||||
I18n.getString("termora.doorman.delete-data"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
)
|
||||
Application.browse(Application.getDatabaseFile().toURI())
|
||||
}
|
||||
}
|
||||
}).apply { isFocusable = false }).xyw(2, rows, 4, "center, fill")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun showMnemonicsDialog() {
|
||||
val dialog = MnemonicsDialog(this@DoormanDialog)
|
||||
dialog.isVisible = true
|
||||
val entropy = dialog.entropy
|
||||
if (entropy.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val keyBackup = Database.getDatabase()
|
||||
.properties.getString("doorman-key-backup")
|
||||
?: throw IllegalStateException("doorman-key-backup is null")
|
||||
val key = AES.ECB.decrypt(entropy, keyBackup.decodeBase64())
|
||||
Doorman.getInstance().work(key)
|
||||
} catch (e: Exception) {
|
||||
OptionPane.showMessageDialog(
|
||||
this, I18n.getString("termora.doorman.mnemonic-data-corrupted"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
passwordTextField.outline = "error"
|
||||
passwordTextField.requestFocus()
|
||||
return
|
||||
}
|
||||
|
||||
isOpened = true
|
||||
super.doOKAction()
|
||||
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
if (passwordTextField.password.isEmpty()) {
|
||||
passwordTextField.outline = "error"
|
||||
passwordTextField.requestFocus()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Doorman.getInstance().work(passwordTextField.password)
|
||||
} catch (e: Exception) {
|
||||
if (e is PasswordWrongException) {
|
||||
OptionPane.showMessageDialog(
|
||||
this, I18n.getString("termora.doorman.password-wrong"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
passwordTextField.outline = "error"
|
||||
passwordTextField.requestFocus()
|
||||
return
|
||||
}
|
||||
|
||||
isOpened = true
|
||||
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
fun open(): Boolean {
|
||||
isModal = true
|
||||
isVisible = true
|
||||
return isOpened
|
||||
}
|
||||
|
||||
|
||||
private class MnemonicsDialog(owner: Window) : DialogWrapper(owner) {
|
||||
|
||||
private val textFields = (1..12).map { PasteTextField(it) }
|
||||
var entropy = byteArrayOf()
|
||||
private set
|
||||
|
||||
init {
|
||||
isModal = true
|
||||
isResizable = true
|
||||
controlsVisible = false
|
||||
title = I18n.getString("termora.doorman.mnemonic.title")
|
||||
init()
|
||||
pack()
|
||||
size = Dimension(max(size.width, UIManager.getInt("Dialog.width") - 250), size.height)
|
||||
setLocationRelativeTo(null)
|
||||
}
|
||||
|
||||
fun getWords(): List<String> {
|
||||
val words = mutableListOf<String>()
|
||||
for (e in textFields) {
|
||||
if (e.text.isBlank()) {
|
||||
return emptyList()
|
||||
}
|
||||
words.add(e.text)
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
val formMargin = "4dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow, $formMargin, default:grow, $formMargin, default:grow, $formMargin, default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin")
|
||||
.layout(layout).debug(true)
|
||||
val iterator = textFields.iterator()
|
||||
for (i in 1..5 step 2) {
|
||||
for (j in 1..7 step 2) {
|
||||
builder.add(iterator.next()).xy(j, i)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
for (textField in textFields) {
|
||||
if (textField.text.isBlank()) {
|
||||
textField.outline = "error"
|
||||
textField.requestFocusInWindow()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Mnemonics.MnemonicCode(getWords().joinToString(StringUtils.SPACE)).use {
|
||||
it.validate()
|
||||
entropy = it.toEntropy()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
OptionPane.showMessageDialog(
|
||||
this,
|
||||
I18n.getString("termora.doorman.mnemonic.incorrect"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
override fun doCancelAction() {
|
||||
entropy = byteArrayOf()
|
||||
super.doCancelAction()
|
||||
}
|
||||
|
||||
private inner class PasteTextField(private val index: Int) : OutlineTextField() {
|
||||
init {
|
||||
addKeyListener(object : KeyAdapter() {
|
||||
override fun keyPressed(e: KeyEvent) {
|
||||
if (e.keyCode == KeyEvent.VK_BACK_SPACE) {
|
||||
if (text.isEmpty() && index != 1) {
|
||||
textFields[index - 2].requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun paste() {
|
||||
if (!toolkit.systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
|
||||
return
|
||||
}
|
||||
|
||||
val text = toolkit.systemClipboard.getData(DataFlavor.stringFlavor)?.toString() ?: return
|
||||
if (text.isBlank()) {
|
||||
return
|
||||
}
|
||||
val words = mutableListOf<String>()
|
||||
if (text.count { it == ControlCharacters.SP } > text.count { it == ControlCharacters.LF }) {
|
||||
words.addAll(text.split(StringUtils.SPACE))
|
||||
} else {
|
||||
words.addAll(text.split(ControlCharacters.LF))
|
||||
}
|
||||
val iterator = words.iterator()
|
||||
for (i in index..textFields.size) {
|
||||
if (iterator.hasNext()) {
|
||||
textFields[i - 1].text = iterator.next()
|
||||
textFields[i - 1].requestFocusInWindow()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun createSouthPanel(): JComponent? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@ open class DynamicColor : Color {
|
||||
val r = regular
|
||||
val d = dark
|
||||
if (r == null || d == null) {
|
||||
return UIManager.getColor(colorKey)
|
||||
try {
|
||||
return UIManager.getColor(colorKey)
|
||||
} catch (e: Exception) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
return if (FlatLaf.isLafDark()) d else r
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
|
||||
@Suppress("CascadeIf")
|
||||
class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
|
||||
init {
|
||||
generalOption.portTextField.value = host.port
|
||||
generalOption.nameTextField.text = host.name
|
||||
generalOption.protocolTypeComboBox.selectedItem = host.protocol
|
||||
generalOption.usernameTextField.text = host.username
|
||||
generalOption.hostTextField.text = host.host
|
||||
generalOption.remarkTextArea.text = host.remark
|
||||
generalOption.authenticationTypeComboBox.selectedItem = host.authentication.type
|
||||
if (host.authentication.type == AuthenticationType.Password) {
|
||||
generalOption.passwordTextField.text = host.authentication.password
|
||||
} else if (host.authentication.type == AuthenticationType.PublicKey) {
|
||||
generalOption.publicKeyComboBox.selectedItem = host.authentication.password
|
||||
} else if (host.authentication.type == AuthenticationType.SSHAgent) {
|
||||
generalOption.sshAgentComboBox.selectedItem = host.authentication.password
|
||||
}
|
||||
|
||||
proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type
|
||||
proxyOption.proxyHostTextField.text = host.proxy.host
|
||||
proxyOption.proxyPasswordTextField.text = host.proxy.password
|
||||
proxyOption.proxyUsernameTextField.text = host.proxy.username
|
||||
proxyOption.proxyPortTextField.value = host.proxy.port
|
||||
proxyOption.proxyAuthenticationTypeComboBox.selectedItem = host.proxy.authenticationType
|
||||
|
||||
terminalOption.charsetComboBox.selectedItem = host.options.encoding
|
||||
terminalOption.environmentTextArea.text = host.options.env
|
||||
terminalOption.startupCommandTextField.text = host.options.startupCommand
|
||||
terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval
|
||||
|
||||
tunnelingOption.tunnelings.addAll(host.tunnelings)
|
||||
tunnelingOption.x11ForwardingCheckBox.isSelected = host.options.enableX11Forwarding
|
||||
tunnelingOption.x11ServerTextField.text = StringUtils.defaultIfBlank(host.options.x11Forwarding, "localhost:0")
|
||||
|
||||
if (host.options.jumpHosts.isNotEmpty()) {
|
||||
val hosts = HostManager.getInstance().hosts().associateBy { it.id }
|
||||
for (id in host.options.jumpHosts) {
|
||||
jumpHostsOption.jumpHosts.add(hosts[id] ?: continue)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory
|
||||
}
|
||||
|
||||
override fun getHost(): Host {
|
||||
val newHost = super.getHost()
|
||||
return host.copy(
|
||||
name = newHost.name,
|
||||
protocol = newHost.protocol,
|
||||
host = newHost.host,
|
||||
port = newHost.port,
|
||||
username = newHost.username,
|
||||
authentication = newHost.authentication,
|
||||
proxy = newHost.proxy,
|
||||
remark = newHost.remark,
|
||||
updateDate = System.currentTimeMillis(),
|
||||
options = newHost.options,
|
||||
tunnelings = newHost.tunnelings,
|
||||
)
|
||||
}
|
||||
}
|
||||
70
src/main/kotlin/app/termora/EnableManager.kt
Normal file
70
src/main/kotlin/app/termora/EnableManager.kt
Normal file
@@ -0,0 +1,70 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.tree.NewHostTree
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class EnableManager private constructor() {
|
||||
|
||||
companion object {
|
||||
fun getInstance(): EnableManager {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(EnableManager::class) { EnableManager() }
|
||||
}
|
||||
}
|
||||
|
||||
private val properties get() = DatabaseManager.getInstance().properties
|
||||
|
||||
/**
|
||||
* [NewHostTree] 是否显示标签
|
||||
*/
|
||||
fun isShowTags() = getFlag("HostTree.showTags", true)
|
||||
fun setShowTags(value: Boolean) {
|
||||
setFlag("HostTree.showTags", value)
|
||||
updateComponentTreeUI()
|
||||
}
|
||||
|
||||
/**
|
||||
* [NewHostTree] 是否显示更多信息
|
||||
*/
|
||||
fun isShowMoreInfo() = getFlag("HostTree.showMoreInfo", false)
|
||||
fun setShowMoreInfo(value: Boolean) {
|
||||
setFlag("HostTree.showMoreInfo", value)
|
||||
updateComponentTreeUI()
|
||||
}
|
||||
|
||||
fun setFlag(key: String, value: Boolean) {
|
||||
setFlag(key, value.toString())
|
||||
}
|
||||
|
||||
fun getFlag(key: String, defaultValue: Boolean): Boolean {
|
||||
return getFlag(key, defaultValue.toString()).toBooleanStrictOrNull() ?: defaultValue
|
||||
}
|
||||
|
||||
|
||||
fun setFlag(key: String, value: Int) {
|
||||
setFlag(key, value.toString())
|
||||
}
|
||||
|
||||
fun getFlag(key: String, defaultValue: Int): Int {
|
||||
return getFlag(key, defaultValue.toString()).toIntOrNull() ?: defaultValue
|
||||
}
|
||||
|
||||
fun setFlag(key: String, value: String) {
|
||||
properties.putString(key, value)
|
||||
}
|
||||
|
||||
|
||||
fun getFlag(key: String, defaultValue: String): String {
|
||||
return properties.getString(key, defaultValue)
|
||||
}
|
||||
|
||||
private fun updateComponentTreeUI() {
|
||||
// reload all tree
|
||||
for (frame in TermoraFrameManager.getInstance().getWindows()) {
|
||||
for (tree in SwingUtils.getDescendantsOfClass(NewHostTree::class.java, frame)) {
|
||||
SwingUtilities.updateComponentTreeUI(tree)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/main/kotlin/app/termora/FrameExtension.kt
Normal file
10
src/main/kotlin/app/termora/FrameExtension.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.plugin.Extension
|
||||
|
||||
interface FrameExtension : Extension {
|
||||
/**
|
||||
* 自定义
|
||||
*/
|
||||
fun customize(frame: TermoraFrame)
|
||||
}
|
||||
9
src/main/kotlin/app/termora/GlassPaneAwareExtension.kt
Normal file
9
src/main/kotlin/app/termora/GlassPaneAwareExtension.kt
Normal file
@@ -0,0 +1,9 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.plugin.Extension
|
||||
import java.awt.Window
|
||||
import javax.swing.JComponent
|
||||
|
||||
interface GlassPaneAwareExtension : Extension {
|
||||
fun setGlassPane(window: Window, glassPane: JComponent)
|
||||
}
|
||||
19
src/main/kotlin/app/termora/GlassPaneExtension.kt
Normal file
19
src/main/kotlin/app/termora/GlassPaneExtension.kt
Normal file
@@ -0,0 +1,19 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.plugin.Extension
|
||||
import java.awt.Graphics2D
|
||||
import javax.swing.JComponent
|
||||
|
||||
/**
|
||||
* 玻璃面板扩展
|
||||
*/
|
||||
interface GlassPaneExtension : Extension {
|
||||
|
||||
/**
|
||||
* 渲染背景,如果返回 true 会立即退出。(当有多个扩展的时候,只会执行一个)
|
||||
*
|
||||
* @return true:渲染了背景,false:没有渲染背景
|
||||
*/
|
||||
fun paint(c: JComponent, g2d: Graphics2D): Boolean
|
||||
|
||||
}
|
||||
47
src/main/kotlin/app/termora/Graphics2D.kt
Normal file
47
src/main/kotlin/app/termora/Graphics2D.kt
Normal file
@@ -0,0 +1,47 @@
|
||||
package app.termora
|
||||
|
||||
import java.awt.*
|
||||
import java.awt.geom.AffineTransform
|
||||
|
||||
|
||||
private val states = ArrayDeque<State>()
|
||||
|
||||
private data class State(
|
||||
val stroke: Stroke,
|
||||
val composite: Composite,
|
||||
val color: Color,
|
||||
val transform: AffineTransform,
|
||||
val clip: Shape,
|
||||
val font: Font,
|
||||
val renderingHints: RenderingHints
|
||||
)
|
||||
|
||||
fun Graphics2D.save() {
|
||||
states.addFirst(
|
||||
State(
|
||||
stroke = this.stroke,
|
||||
composite = this.composite,
|
||||
color = this.color,
|
||||
transform = this.transform,
|
||||
clip = this.clip,
|
||||
font = this.font,
|
||||
renderingHints = this.renderingHints
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun Graphics2D.restore() {
|
||||
val state = states.removeFirst()
|
||||
this.stroke = state.stroke
|
||||
this.composite = state.composite
|
||||
this.color = state.color
|
||||
this.transform = state.transform
|
||||
this.clip = state.clip
|
||||
this.font = state.font
|
||||
this.setRenderingHints(state.renderingHints)
|
||||
}
|
||||
|
||||
fun setupAntialiasing(graphics: Graphics2D) {
|
||||
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package app.termora
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.util.*
|
||||
|
||||
|
||||
fun Map<*, *>.toPropertiesString(): String {
|
||||
@@ -16,24 +15,6 @@ fun Map<*, *>.toPropertiesString(): String {
|
||||
return env.toString()
|
||||
}
|
||||
|
||||
fun UUID.toSimpleString(): String {
|
||||
return toString().replace("-", StringUtils.EMPTY)
|
||||
}
|
||||
|
||||
enum class Protocol {
|
||||
Folder,
|
||||
SSH,
|
||||
Local,
|
||||
Serial,
|
||||
RDP,
|
||||
|
||||
/**
|
||||
* 交互式的 SFTP,此协议只在系统内部交互不应该暴露给用户也不应该持久化
|
||||
*/
|
||||
@Transient
|
||||
SFTPPty
|
||||
}
|
||||
|
||||
|
||||
enum class AuthenticationType {
|
||||
No,
|
||||
@@ -106,6 +87,9 @@ data class SerialComm(
|
||||
val flowControl: SerialCommFlowControl = SerialCommFlowControl.None,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HostTag(val text: String)
|
||||
|
||||
|
||||
@Serializable
|
||||
data class Options(
|
||||
@@ -149,6 +133,16 @@ data class Options(
|
||||
* X11 Server,Format: host.port. default: localhost:0
|
||||
*/
|
||||
val x11Forwarding: String = StringUtils.EMPTY,
|
||||
|
||||
/**
|
||||
* 标签 [app.termora.tag.Tag.id]
|
||||
*/
|
||||
val tags: List<String> = emptyList(),
|
||||
|
||||
/**
|
||||
* 扩展,如果要使用此
|
||||
*/
|
||||
val extras: Map<String, String> = emptyMap(),
|
||||
) {
|
||||
companion object {
|
||||
val Default = Options()
|
||||
@@ -253,7 +247,7 @@ data class Host(
|
||||
/**
|
||||
* 唯一ID
|
||||
*/
|
||||
val id: String = UUID.randomUUID().toSimpleString(),
|
||||
val id: String = randomUUID(),
|
||||
/**
|
||||
* 名称
|
||||
*/
|
||||
@@ -261,7 +255,7 @@ data class Host(
|
||||
/**
|
||||
* 协议
|
||||
*/
|
||||
val protocol: Protocol,
|
||||
val protocol: String,
|
||||
/**
|
||||
* 主机
|
||||
*/
|
||||
@@ -309,25 +303,23 @@ data class Host(
|
||||
* 所属者
|
||||
*/
|
||||
val ownerId: String = "0",
|
||||
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
val creatorId: String = "0",
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
val createDate: Long = System.currentTimeMillis(),
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
val updateDate: Long = System.currentTimeMillis(),
|
||||
|
||||
/**
|
||||
* 是否已经删除
|
||||
* 所属者类型,默认是:用户
|
||||
*/
|
||||
val deleted: Boolean = false
|
||||
val ownerType: String = StringUtils.EMPTY,
|
||||
val deleted: Boolean = false,
|
||||
var createDate: Long = 0L,
|
||||
var updateDate: Long = 0L,
|
||||
) {
|
||||
|
||||
val isFolder get() = StringUtils.equalsIgnoreCase(protocol, "Folder")
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
|
||||
class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
|
||||
private val pane = if (host != null) EditHostOptionsPane(host) else HostOptionsPane()
|
||||
var host: Host? = host
|
||||
private set
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
|
||||
isModal = true
|
||||
title = I18n.getString("termora.new-host.title")
|
||||
setLocationRelativeTo(null)
|
||||
pane.setSelectedIndex(0)
|
||||
|
||||
init()
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
pane.background = UIManager.getColor("window")
|
||||
|
||||
val panel = JPanel(BorderLayout())
|
||||
panel.add(pane, BorderLayout.CENTER)
|
||||
panel.background = UIManager.getColor("window")
|
||||
panel.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
|
||||
|
||||
return panel
|
||||
}
|
||||
|
||||
override fun createActions(): List<AbstractAction> {
|
||||
return listOf(createOkAction(), createTestConnectionAction(), CancelAction())
|
||||
}
|
||||
|
||||
private fun createTestConnectionAction(): AbstractAction {
|
||||
return object : AnAction(I18n.getString("termora.new-host.test-connection")) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
if (!pane.validateFields()) {
|
||||
return
|
||||
}
|
||||
|
||||
putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...")
|
||||
isEnabled = false
|
||||
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
// 因为测试连接的时候从数据库读取会导致失效,所以这里生成随机ID
|
||||
testConnection(pane.getHost().copy(id = UUID.randomUUID().toSimpleString()))
|
||||
withContext(Dispatchers.Swing) {
|
||||
putValue(NAME, I18n.getString("termora.new-host.test-connection"))
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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, this)
|
||||
session = SshClients.openSession(host, client)
|
||||
} finally {
|
||||
session?.close()
|
||||
client?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun testSerial(host: Host) {
|
||||
Serials.openPort(host).closePort()
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
if (!pane.validateFields()) {
|
||||
return
|
||||
}
|
||||
host = pane.getHost()
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,52 +1,61 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.database.Data
|
||||
import app.termora.database.DataType
|
||||
import app.termora.database.DatabaseChangedExtension
|
||||
import app.termora.database.DatabaseManager
|
||||
|
||||
class HostManager private constructor() {
|
||||
|
||||
class HostManager private constructor() : Disposable {
|
||||
companion object {
|
||||
fun getInstance(): HostManager {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(HostManager::class) { HostManager() }
|
||||
}
|
||||
}
|
||||
|
||||
private val database get() = Database.getDatabase()
|
||||
private var hosts = mutableMapOf<String, Host>()
|
||||
private val databaseManager get() = DatabaseManager.getInstance()
|
||||
|
||||
/**
|
||||
* 修改缓存并存入数据库
|
||||
*/
|
||||
fun addHost(host: Host) {
|
||||
fun addHost(host: Host, source: DatabaseChangedExtension.Source = DatabaseChangedExtension.Source.User) {
|
||||
assertEventDispatchThread()
|
||||
if (host.deleted) {
|
||||
removeHost(host.id)
|
||||
} else {
|
||||
database.addHost(host)
|
||||
hosts[host.id] = host
|
||||
if (host.ownerType.isBlank()) {
|
||||
throw IllegalArgumentException("Owner type cannot be null")
|
||||
}
|
||||
databaseManager.saveAndIncrementVersion(
|
||||
Data(
|
||||
id = host.id,
|
||||
ownerId = host.ownerId,
|
||||
ownerType = host.ownerType,
|
||||
type = DataType.Host.name,
|
||||
data = ohMyJson.encodeToString(host),
|
||||
),
|
||||
source
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
fun removeHost(id: String) {
|
||||
hosts.entries.removeIf { it.value.id == id || it.value.parentId == id }
|
||||
database.removeHost(id)
|
||||
DeleteDataManager.getInstance().removeHost(id)
|
||||
databaseManager.delete(id, DataType.Host.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 第一次调用从数据库中获取,后续从缓存中获取
|
||||
*/
|
||||
fun hosts(): List<Host> {
|
||||
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 })
|
||||
return databaseManager.data<Host>(DataType.Host)
|
||||
.sortedWith(compareBy<Host> { if (it.isFolder) 0 else 1 }.thenBy { it.sort })
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存中获取
|
||||
*/
|
||||
fun getHost(id: String): Host? {
|
||||
return hosts[id]
|
||||
val data = databaseManager.data(id) ?: return null
|
||||
if (data.type != DataType.Host.name) return null
|
||||
return ohMyJson.decodeFromString(data.data)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -55,9 +55,6 @@ abstract class HostTerminalTab(
|
||||
}
|
||||
|
||||
override fun getIcon(): Icon {
|
||||
if (host.protocol == Protocol.Local || host.protocol == Protocol.SSH) {
|
||||
return if (unread) Icons.terminalUnread else Icons.terminal
|
||||
}
|
||||
return Icons.terminal
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.LocaleUtils
|
||||
import org.apache.commons.text.StringSubstitutor
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.text.MessageFormat
|
||||
import java.util.*
|
||||
|
||||
object I18n {
|
||||
object I18n : AbstractI18n() {
|
||||
private val log = LoggerFactory.getLogger(I18n::class.java)
|
||||
private val bundle by lazy {
|
||||
private val myBundle by lazy {
|
||||
val bundle = ResourceBundle.getBundle("i18n/messages", Locale.getDefault())
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("I18n: {}", bundle.baseBundleName ?: "null")
|
||||
@@ -16,7 +15,6 @@ object I18n {
|
||||
return@lazy bundle
|
||||
}
|
||||
|
||||
private val substitutor by lazy { StringSubstitutor { key -> getString(key) } }
|
||||
private val supportedLanguages = sortedMapOf(
|
||||
"en_US" to "English",
|
||||
"zh_CN" to "简体中文",
|
||||
@@ -39,24 +37,13 @@ object I18n {
|
||||
return supportedLanguages
|
||||
}
|
||||
|
||||
fun getString(key: String, vararg args: Any): String {
|
||||
val text = getString(key)
|
||||
if (args.isNotEmpty()) {
|
||||
return MessageFormat.format(text, *args)
|
||||
}
|
||||
return text
|
||||
|
||||
override fun getBundle(): ResourceBundle {
|
||||
return myBundle
|
||||
}
|
||||
|
||||
|
||||
fun getString(key: String): String {
|
||||
try {
|
||||
return substitutor.replace(bundle.getString(key))
|
||||
} catch (e: MissingResourceException) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn(e.message, e)
|
||||
}
|
||||
return key
|
||||
}
|
||||
override fun getLogger(): Logger {
|
||||
return log
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package app.termora
|
||||
|
||||
object Icons {
|
||||
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
|
||||
val dbms by lazy { DynamicIcon("icons/dbms.svg", "icons/dbms_dark.svg") }
|
||||
val newUI by lazy { DynamicIcon("icons/newUI.svg", "icons/newUI.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") }
|
||||
@@ -34,6 +36,8 @@ object Icons {
|
||||
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 error by lazy { DynamicIcon("icons/error.svg", "icons/error_dark.svg") }
|
||||
val cwmUsers by lazy { DynamicIcon("icons/cwmUsers.svg", "icons/cwmUsers_dark.svg") }
|
||||
val warningIntroduction by lazy { DynamicIcon("icons/warningIntroduction.svg", "icons/warningIntroduction_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") }
|
||||
@@ -52,6 +56,8 @@ object Icons {
|
||||
val keyboard by lazy { DynamicIcon("icons/keyboard.svg", "icons/keyboard_dark.svg") }
|
||||
val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") }
|
||||
val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") }
|
||||
val image by lazy { DynamicIcon("icons/image.svg", "icons/image_dark.svg") }
|
||||
val imageGray by lazy { DynamicIcon("icons/imageGray.svg", "icons/imageGray_dark.svg") }
|
||||
val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") }
|
||||
val chevronRight by lazy { DynamicIcon("icons/chevronRight.svg", "icons/chevronRight_dark.svg") }
|
||||
val homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") }
|
||||
@@ -59,10 +65,16 @@ 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 ssh by lazy { DynamicIcon("icons/ssh.svg", "icons/ssh_dark.svg") }
|
||||
val ftp by lazy { DynamicIcon("icons/ftp.svg", "icons/ftp_dark.svg") }
|
||||
val minio by lazy { DynamicIcon("icons/minio.svg", "icons/minio_dark.svg") }
|
||||
val powershell by lazy { DynamicIcon("icons/powershell.svg", "icons/powershell_dark.svg") }
|
||||
val serial by lazy { DynamicIcon("icons/serial.svg", "icons/serial_dark.svg") }
|
||||
val fileFormat by lazy { DynamicIcon("icons/fileFormat.svg", "icons/fileFormat_dark.svg") }
|
||||
val 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") }
|
||||
val editFolder by lazy { DynamicIcon("icons/editFolder.svg", "icons/editFolder_dark.svg") }
|
||||
val microsoft by lazy { DynamicIcon("icons/microsoft.svg", "icons/microsoft_dark.svg") }
|
||||
val microsoftWindows by lazy { DynamicIcon("icons/microsoftWindows.svg", "icons/microsoftWindows_dark.svg") }
|
||||
val tencent by lazy { DynamicIcon("icons/tencent.svg") }
|
||||
@@ -78,6 +90,7 @@ object Icons {
|
||||
val dbPrimitive by lazy { DynamicIcon("icons/dbPrimitive.svg", "icons/dbPrimitive_dark.svg") }
|
||||
val linux by lazy { DynamicIcon("icons/linux.svg", "icons/linux_dark.svg") }
|
||||
val success by lazy { DynamicIcon("icons/success.svg", "icons/success_dark.svg") }
|
||||
val errorDialog by lazy { DynamicIcon("icons/errorDialog.svg", "icons/errorDialog_dark.svg") }
|
||||
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") }
|
||||
|
||||
29
src/main/kotlin/app/termora/LocalSecret.kt
Normal file
29
src/main/kotlin/app/termora/LocalSecret.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.codec.digest.DigestUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
|
||||
/**
|
||||
* 用户需要保证自己的电脑是可信环境
|
||||
*/
|
||||
internal class LocalSecret private constructor() {
|
||||
|
||||
companion object {
|
||||
fun getInstance(): LocalSecret {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(LocalSecret::class) { LocalSecret() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个 16 长度的密码
|
||||
*/
|
||||
val password: String = StringUtils.substring(DigestUtils.sha256Hex(SystemUtils.USER_NAME), 0, 16)
|
||||
|
||||
/**
|
||||
* 一个 12 长度的盐
|
||||
*/
|
||||
val salt: String = StringUtils.substring(password, 0, 16)
|
||||
|
||||
}
|
||||
@@ -8,7 +8,10 @@ import java.awt.*
|
||||
import java.awt.event.*
|
||||
import java.awt.image.BufferedImage
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
import javax.swing.ImageIcon
|
||||
import javax.swing.JDialog
|
||||
import javax.swing.JLabel
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.math.abs
|
||||
|
||||
class MyTabbedPane : FlatTabbedPane() {
|
||||
@@ -23,15 +26,13 @@ class MyTabbedPane : FlatTabbedPane() {
|
||||
|
||||
init {
|
||||
isFocusable = false
|
||||
initEvents()
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
styleMap = mapOf(
|
||||
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
|
||||
"hoverColor" to UIManager.getColor("TabbedPane.background"),
|
||||
"focusColor" to DynamicColor("TabbedPane.background"),
|
||||
"hoverColor" to DynamicColor("TabbedPane.background"),
|
||||
)
|
||||
super.updateUI()
|
||||
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
23
src/main/kotlin/app/termora/NamedI18n.kt
Normal file
23
src/main/kotlin/app/termora/NamedI18n.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package app.termora
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.*
|
||||
|
||||
abstract class NamedI18n(private val baseName: String) : AbstractI18n() {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(NamedI18n::class.java)
|
||||
}
|
||||
|
||||
private val myBundle by lazy {
|
||||
val bundle =
|
||||
ResourceBundle.getBundle(baseName, Locale.getDefault(), javaClass.classLoader)
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("I18n: {}", bundle.baseBundleName ?: "null")
|
||||
}
|
||||
return@lazy bundle
|
||||
}
|
||||
|
||||
override fun getBundle(): ResourceBundle {
|
||||
return myBundle
|
||||
}
|
||||
}
|
||||
208
src/main/kotlin/app/termora/NewHostDialogV2.kt
Normal file
208
src/main/kotlin/app/termora/NewHostDialogV2.kt
Normal file
@@ -0,0 +1,208 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.protocol.*
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon
|
||||
import com.formdev.flatlaf.extras.components.FlatToolBar
|
||||
import com.formdev.flatlaf.ui.FlatButtonBorder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.CardLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import javax.swing.*
|
||||
|
||||
class NewHostDialogV2(owner: Window, private val editHost: Host? = null) : DialogWrapper(owner) {
|
||||
|
||||
private val cardLayout = CardLayout()
|
||||
private val cardPanel = JPanel(cardLayout)
|
||||
private val buttonGroup = mutableListOf<JToggleButton>()
|
||||
private var currentCard: ProtocolHostPanel? = null
|
||||
var host: Host? = null
|
||||
private set
|
||||
|
||||
init {
|
||||
|
||||
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
|
||||
isModal = true
|
||||
title = I18n.getString("termora.new-host.title")
|
||||
|
||||
setLocationRelativeTo(owner)
|
||||
|
||||
init()
|
||||
}
|
||||
|
||||
|
||||
override fun addNotify() {
|
||||
super.addNotify()
|
||||
|
||||
controlsVisible = false
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
val toolbar = FlatToolBar()
|
||||
val panel = JPanel(BorderLayout())
|
||||
|
||||
toolbar.border = BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(1, 0, 1, 0, DynamicColor.BorderColor),
|
||||
BorderFactory.createEmptyBorder(4, 0, 4, 0)
|
||||
)
|
||||
panel.add(toolbar, BorderLayout.NORTH)
|
||||
panel.add(cardPanel, BorderLayout.CENTER)
|
||||
|
||||
toolbar.add(Box.createHorizontalGlue())
|
||||
|
||||
val extensions = ProtocolHostPanelExtension.extensions
|
||||
for ((index, extension) in extensions.withIndex()) {
|
||||
val protocol = extension.getProtocolProvider().getProtocol()
|
||||
val icon = FlatSVGIcon(
|
||||
extension.getProtocolProvider().getIcon().name,
|
||||
22, 22, extension.javaClass.classLoader
|
||||
)
|
||||
val hostPanel = extension.createProtocolHostPanel()
|
||||
val button = JToggleButton(protocol, icon).apply { buttonGroup.add(this) }
|
||||
button.setVerticalTextPosition(SwingConstants.BOTTOM)
|
||||
button.setHorizontalTextPosition(SwingConstants.CENTER)
|
||||
button.border = BorderFactory.createCompoundBorder(
|
||||
FlatButtonBorder(),
|
||||
BorderFactory.createEmptyBorder(0, 4, 0, 4)
|
||||
)
|
||||
button.addActionListener { show(protocol, hostPanel, button) }
|
||||
|
||||
Disposer.register(disposable, hostPanel)
|
||||
|
||||
cardPanel.add(hostPanel, protocol)
|
||||
|
||||
toolbar.add(button)
|
||||
|
||||
if (extension != extensions.last()) {
|
||||
toolbar.add(Box.createHorizontalStrut(6))
|
||||
}
|
||||
|
||||
if (editHost == null) {
|
||||
if (index == 0) {
|
||||
show(protocol, hostPanel, button)
|
||||
}
|
||||
} else {
|
||||
if (StringUtils.equalsIgnoreCase(editHost.protocol, protocol)) {
|
||||
show(protocol, hostPanel, button)
|
||||
currentCard?.setHost(editHost)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (editHost != null && currentCard == null) {
|
||||
SwingUtilities.invokeLater {
|
||||
OptionPane.showMessageDialog(
|
||||
this,
|
||||
"Protocol ${editHost.protocol} not supported",
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
doCancelAction()
|
||||
}
|
||||
}
|
||||
|
||||
toolbar.add(Box.createHorizontalGlue())
|
||||
|
||||
return panel
|
||||
}
|
||||
|
||||
private fun show(name: String, card: ProtocolHostPanel, button: JToggleButton) {
|
||||
currentCard?.onBeforeHidden()
|
||||
card.onBeforeShown()
|
||||
cardLayout.show(cardPanel, name)
|
||||
currentCard?.onHidden()
|
||||
card.onShown()
|
||||
|
||||
currentCard = card
|
||||
|
||||
buttonGroup.forEach { it.isSelected = false }
|
||||
button.isSelected = true
|
||||
}
|
||||
|
||||
override fun createActions(): List<AbstractAction> {
|
||||
return listOf(createOkAction(), createTestConnectionAction(), CancelAction())
|
||||
}
|
||||
|
||||
private fun createTestConnectionAction(): AbstractAction {
|
||||
return object : AnAction(I18n.getString("termora.new-host.test-connection")) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
|
||||
val panel = currentCard ?: return
|
||||
if (panel.validateFields().not()) return
|
||||
val host = panel.getHost()
|
||||
val provider = ProtocolProvider.valueOf(host.protocol) ?: return
|
||||
if (provider !is ProtocolTester) return
|
||||
|
||||
putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...")
|
||||
isEnabled = false
|
||||
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
// 因为测试连接的时候从数据库读取会导致失效,所以这里生成随机ID
|
||||
testConnection(provider, host)
|
||||
withContext(Dispatchers.Swing) {
|
||||
putValue(NAME, I18n.getString("termora.new-host.test-connection"))
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun testConnection(tester: ProtocolTester, host: Host) {
|
||||
try {
|
||||
val request = ProtocolTestRequest(host = host, owner = this)
|
||||
if (tester.canTestConnection(request))
|
||||
tester.testConnection(request)
|
||||
} 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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
val panel = currentCard ?: return
|
||||
if (panel.validateFields().not()) return
|
||||
var host = panel.getHost()
|
||||
|
||||
if (editHost != null) {
|
||||
host = editHost.copy(
|
||||
name = host.name,
|
||||
protocol = host.protocol,
|
||||
host = host.host,
|
||||
port = host.port,
|
||||
username = host.username,
|
||||
authentication = host.authentication,
|
||||
proxy = host.proxy,
|
||||
remark = host.remark,
|
||||
options = host.options,
|
||||
tunnelings = host.tunnelings,
|
||||
)
|
||||
}
|
||||
|
||||
this.host = host
|
||||
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import javax.swing.tree.MutableTreeNode
|
||||
import javax.swing.tree.TreeNode
|
||||
|
||||
|
||||
class NewHostTreeModel : SimpleTreeModel<Host>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
52
src/main/kotlin/app/termora/OpenURIHandlers.kt
Normal file
52
src/main/kotlin/app/termora/OpenURIHandlers.kt
Normal file
@@ -0,0 +1,52 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Desktop
|
||||
import java.awt.desktop.OpenURIEvent
|
||||
import java.awt.desktop.OpenURIHandler
|
||||
|
||||
class OpenURIHandlers private constructor() {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(OpenURIHandlers::class.java)
|
||||
fun getInstance(): OpenURIHandlers {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(OpenURIHandlers::class) { OpenURIHandlers() }
|
||||
}
|
||||
}
|
||||
|
||||
private var handlers = emptyArray<OpenURIHandler>()
|
||||
|
||||
init {
|
||||
// 监听回调
|
||||
if (isSupported()) {
|
||||
Desktop.getDesktop().setOpenURIHandler { e -> trigger(e) }
|
||||
}
|
||||
}
|
||||
|
||||
fun isSupported(): Boolean {
|
||||
return Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)
|
||||
}
|
||||
|
||||
internal fun trigger(e: OpenURIEvent) {
|
||||
for (handler in handlers) {
|
||||
try {
|
||||
handler.openURI(e)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(handler: OpenURIHandler) {
|
||||
handlers += handler
|
||||
}
|
||||
|
||||
fun unregister(handler: OpenURIHandler) {
|
||||
handlers = ArrayUtils.removeElement(handlers, handler)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.native.osx.NativeMacLibrary
|
||||
import app.termora.nv.osx.NativeMacLibrary
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatTextPane
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.plugin.internal.extension.DynamicExtensionHandler
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import java.awt.*
|
||||
import javax.swing.*
|
||||
import javax.swing.border.Border
|
||||
|
||||
|
||||
open class OptionsPane : JPanel(BorderLayout()) {
|
||||
protected val formMargin = "7dlu"
|
||||
abstract class OptionsPane : JPanel(BorderLayout()), Disposable {
|
||||
companion object {
|
||||
const val FORM_MARGIN = "7dlu"
|
||||
}
|
||||
|
||||
private val options = mutableListOf<Option>()
|
||||
protected val tabListModel = DefaultListModel<Option>()
|
||||
protected val tabList = object : JList<Option>(tabListModel) {
|
||||
override fun getBackground(): Color {
|
||||
@@ -18,6 +22,8 @@ open class OptionsPane : JPanel(BorderLayout()) {
|
||||
private val cardLayout = CardLayout()
|
||||
private val contentPanel = JPanel(cardLayout)
|
||||
private val loadedComponents = mutableMapOf<String, JComponent>()
|
||||
private var contentPanelBorder = BorderFactory.createEmptyBorder(6, 8, 6, 8)
|
||||
private var themeChanged = false
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -98,25 +104,33 @@ open class OptionsPane : JPanel(BorderLayout()) {
|
||||
}
|
||||
|
||||
|
||||
fun addOption(option: Option) {
|
||||
open fun addOption(option: Option) {
|
||||
for (element in tabListModel.elements()) {
|
||||
if (element.getTitle() == option.getTitle()) {
|
||||
throw UnsupportedOperationException("Title already exists")
|
||||
}
|
||||
}
|
||||
tabListModel.addElement(option)
|
||||
}
|
||||
|
||||
fun removeOption(option: Option) {
|
||||
val title = option.getTitle()
|
||||
loadedComponents[title]?.let {
|
||||
contentPanel.remove(it)
|
||||
loadedComponents.remove(title)
|
||||
options.add(option)
|
||||
contentPanel.add(option.getJComponent(), option.getTitle())
|
||||
|
||||
tabListModel.clear()
|
||||
for (e in OptionSorter.sortOptions(options)) {
|
||||
tabListModel.addElement(e)
|
||||
}
|
||||
tabListModel.removeElement(option)
|
||||
|
||||
if (tabList.selectedIndex < 0) {
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
|
||||
if (option is Disposable) {
|
||||
Disposer.register(this, option)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun setContentBorder(border: Border) {
|
||||
contentPanelBorder = border
|
||||
contentPanel.border = border
|
||||
}
|
||||
|
||||
@@ -130,17 +144,122 @@ open class OptionsPane : JPanel(BorderLayout()) {
|
||||
val component = option.getJComponent()
|
||||
loadedComponents[title] = component
|
||||
contentPanel.add(component, title)
|
||||
SwingUtilities.updateComponentTreeUI(component)
|
||||
if (themeChanged) SwingUtilities.updateComponentTreeUI(component)
|
||||
}
|
||||
|
||||
val contentPanelBorder = option.getJComponent().getClientProperty("ContentPanelBorder")
|
||||
if (contentPanelBorder is Border) {
|
||||
contentPanel.border = contentPanelBorder
|
||||
} else {
|
||||
contentPanel.border = this.contentPanelBorder
|
||||
}
|
||||
|
||||
cardLayout.show(contentPanel, title)
|
||||
}
|
||||
}
|
||||
|
||||
tabList.addListSelectionListener {
|
||||
val index = tabList.selectedIndex
|
||||
if (index >= 0) {
|
||||
// 选中事件
|
||||
tabListModel.getElementAt(index).onSelected()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听主题变化
|
||||
DynamicExtensionHandler.getInstance().register(ThemeChangeExtension::class.java, object : ThemeChangeExtension {
|
||||
override fun onChanged() {
|
||||
themeChanged = true
|
||||
}
|
||||
}).let { Disposer.register(this, it) }
|
||||
}
|
||||
|
||||
|
||||
interface Option {
|
||||
fun getIcon(isSelected: Boolean): Icon
|
||||
fun getTitle(): String
|
||||
fun getJComponent(): JComponent
|
||||
fun getIdentifier(): String = javaClass.name
|
||||
fun getAnchor(): Anchor = Anchor.Null
|
||||
fun onSelected() {}
|
||||
}
|
||||
|
||||
interface PluginOption : Option {
|
||||
override fun getAnchor(): Anchor = Anchor.After("Plugin")
|
||||
}
|
||||
|
||||
|
||||
sealed class Anchor {
|
||||
object Null : Anchor()
|
||||
object First : Anchor()
|
||||
object Last : Anchor()
|
||||
data class Before(val target: String) : Anchor()
|
||||
data class After(val target: String) : Anchor()
|
||||
}
|
||||
|
||||
private object OptionSorter {
|
||||
|
||||
|
||||
fun sortOptions(options: List<Option>): List<Option> {
|
||||
val firsts = options.filter { it.getAnchor() is Anchor.First }
|
||||
val lasts = options.filter { it.getAnchor() is Anchor.Last }
|
||||
val nulls = options.filter { it.getAnchor() is Anchor.Null }
|
||||
val pendingOptions = mutableListOf<Option>()
|
||||
|
||||
val result = mutableListOf<Option>()
|
||||
result.addAll(firsts)
|
||||
result.addAll(nulls)
|
||||
result.addAll(lasts)
|
||||
|
||||
// 首次排序
|
||||
sort(options, pendingOptions, result)
|
||||
|
||||
// 对于没有找到对应依赖关系,则在最近的一个 Last 前面
|
||||
if (pendingOptions.isNotEmpty()) {
|
||||
for (i in 0 until result.size) {
|
||||
if (result[i].getAnchor() is Anchor.Last || i == result.size - 1) {
|
||||
for (n in 0 until pendingOptions.size) {
|
||||
result.add(i + n, pendingOptions[n])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun sort(
|
||||
options: List<Option>,
|
||||
pendingOptions: MutableList<Option>,
|
||||
result: MutableList<Option>
|
||||
) {
|
||||
for (option in options.filter { it.getAnchor() is Anchor.Before || it.getAnchor() is Anchor.After }) {
|
||||
val anchor = option.getAnchor()
|
||||
if (anchor is Anchor.Before) {
|
||||
val index = findIndex(anchor.target, result)
|
||||
if (index == -1) {
|
||||
pendingOptions.add(option)
|
||||
continue
|
||||
} else {
|
||||
result.add(index, option)
|
||||
}
|
||||
} else if (anchor is Anchor.After) {
|
||||
val index = findIndex(anchor.target, result)
|
||||
if (index == -1) {
|
||||
pendingOptions.add(option)
|
||||
continue
|
||||
} else {
|
||||
result.add(index + 1, option)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findIndex(identifier: String, list: List<Option>): Int {
|
||||
return list.indexOfFirst { it.getIdentifier() == identifier }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.macro.MacroPtyConnector
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.PtyConnectorDelegate
|
||||
@@ -14,7 +15,7 @@ import java.util.*
|
||||
|
||||
class PtyConnectorFactory : Disposable {
|
||||
private val ptyConnectors = Collections.synchronizedList(mutableListOf<PtyConnector>())
|
||||
private val database get() = Database.getDatabase()
|
||||
private val database get() = DatabaseManager.getInstance()
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(PtyConnectorFactory::class.java)
|
||||
|
||||
@@ -44,13 +44,14 @@ abstract class PtyHostTerminalTab(
|
||||
startPtyConnectorReader()
|
||||
|
||||
// 启动命令
|
||||
if (host.options.startupCommand.isNotBlank() && host.protocol != Protocol.SFTPPty) {
|
||||
if (host.options.startupCommand.isNotBlank()) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
delay(250.milliseconds)
|
||||
withContext(Dispatchers.Swing) {
|
||||
val charset = ptyConnector.getCharset()
|
||||
ptyConnector.write(host.options.startupCommand.toByteArray(charset))
|
||||
ptyConnector.write(
|
||||
sendStartupCommand(ptyConnector, host.options.startupCommand.toByteArray(charset))
|
||||
sendStartupCommand(
|
||||
ptyConnector,
|
||||
terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
|
||||
.toByteArray(charset)
|
||||
)
|
||||
@@ -80,6 +81,10 @@ abstract class PtyHostTerminalTab(
|
||||
}
|
||||
}
|
||||
|
||||
open fun sendStartupCommand(ptyConnector: PtyConnector, bytes: ByteArray) {
|
||||
ptyConnector.write(bytes)
|
||||
}
|
||||
|
||||
override fun canReconnect(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.database.DatabaseManager
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
@@ -10,7 +11,7 @@ import javax.swing.UIManager
|
||||
|
||||
class SettingsDialog(owner: Window) : DialogWrapper(owner) {
|
||||
private val optionsPane = SettingsOptionsPane()
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private val properties get() = DatabaseManager.getInstance().properties
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
|
||||
@@ -21,6 +22,8 @@ class SettingsDialog(owner: Window) : DialogWrapper(owner) {
|
||||
val index = properties.getString("Settings-SelectedOption")?.toIntOrNull() ?: 0
|
||||
optionsPane.setSelectedIndex(index)
|
||||
|
||||
Disposer.register(disposable, optionsPane)
|
||||
|
||||
init()
|
||||
initEvents()
|
||||
}
|
||||
@@ -31,6 +34,9 @@ class SettingsDialog(owner: Window) : DialogWrapper(owner) {
|
||||
properties.putString("Settings-SelectedOption", optionsPane.getSelectedIndex().toString())
|
||||
}
|
||||
})
|
||||
|
||||
Disposer.register(disposable, optionsPane)
|
||||
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
|
||||
13
src/main/kotlin/app/termora/SettingsOptionExtension.kt
Normal file
13
src/main/kotlin/app/termora/SettingsOptionExtension.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.plugin.Extension
|
||||
|
||||
/**
|
||||
* 设置选项扩展
|
||||
*/
|
||||
interface SettingsOptionExtension : Extension {
|
||||
/**
|
||||
* 创建选项
|
||||
*/
|
||||
fun createSettingsOption(): OptionsPane.Option
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -198,7 +198,7 @@ object SshClients {
|
||||
}
|
||||
// 映射完毕之后修改Host和端口
|
||||
jumpHosts[i + 1] =
|
||||
nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
|
||||
nextHost.copy(host = address.hostName, port = address.port)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,11 +440,10 @@ object SshClients {
|
||||
hostManager.addHost(
|
||||
h.copy(
|
||||
authentication = authentication,
|
||||
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
|
||||
username = dialog.getUsername(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return ref.get()
|
||||
@@ -683,7 +682,7 @@ object SshClients {
|
||||
// 获取代理连接器
|
||||
val clientProxyConnector = getClientProxyConnector(host, address) ?: return targetAddress
|
||||
|
||||
val id = UUID.randomUUID().toSimpleString()
|
||||
val id = randomUUID()
|
||||
entry.setProperty(CLIENT_PROXY_CONNECTOR, id)
|
||||
sshClient.clientProxyConnectors[id] = clientProxyConnector
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.terminal.*
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
import app.termora.tlog.TerminalLoggerDataListener
|
||||
@@ -45,7 +46,7 @@ class TerminalFactory private constructor() : Disposable {
|
||||
|
||||
open class MyTerminalModel(terminal: Terminal) : TerminalModelImpl(terminal) {
|
||||
private val colorPalette by lazy { MyColorPalette(terminal) }
|
||||
private val config get() = Database.getDatabase().terminal
|
||||
private val config get() = DatabaseManager.getInstance().terminal
|
||||
|
||||
init {
|
||||
this.setData(DataKey.CursorStyle, config.cursor)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.Database.Appearance
|
||||
import app.termora.actions.DataProvider
|
||||
import java.beans.PropertyChangeListener
|
||||
import javax.swing.Icon
|
||||
@@ -45,7 +44,7 @@ interface TerminalTab : Disposable, DataProvider {
|
||||
fun canClose(): Boolean = true
|
||||
|
||||
/**
|
||||
* 返回 true 表示可以关闭,只有当 [Appearance.confirmTabClose] 为 false 时才会调用
|
||||
* 返回 true 表示可以关闭,只有当 [app.termora.database.DatabaseManager.Appearance.confirmTabClose] 为 false 时才会调用
|
||||
*/
|
||||
fun willBeClose(): Boolean = true
|
||||
|
||||
|
||||
@@ -2,9 +2,14 @@ package app.termora
|
||||
|
||||
|
||||
import app.termora.actions.*
|
||||
import app.termora.database.DatabaseChangedExtension
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
|
||||
import app.termora.findeverywhere.FindEverywhereProvider
|
||||
import app.termora.findeverywhere.FindEverywhereResult
|
||||
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
|
||||
import app.termora.plugin.internal.sftppty.SFTPPtyTerminalTab
|
||||
import app.termora.plugin.internal.ssh.SSHProtocolProvider
|
||||
import app.termora.terminal.DataKey
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
@@ -31,8 +36,8 @@ class TerminalTabbed(
|
||||
private val toolbar = termoraToolBar.getJToolBar()
|
||||
private val actionManager = ActionManager.getInstance()
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val titleProperty = UUID.randomUUID().toSimpleString()
|
||||
private val appearance get() = Database.getDatabase().appearance
|
||||
private val appearance get() = DatabaseManager.getInstance().appearance
|
||||
private val titleProperty = randomUUID()
|
||||
private val iconListener = PropertyChangeListener { e ->
|
||||
val source = e.source
|
||||
if (e.propertyName == "icon" && source is TerminalTab) {
|
||||
@@ -242,10 +247,12 @@ class TerminalTabbed(
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
if (tab is HostTerminalTab) {
|
||||
val host = hostManager.getHost(tab.host.id) ?: return
|
||||
val dialog = HostDialog(evt.window, host)
|
||||
val dialog = NewHostDialogV2(evt.window, host)
|
||||
dialog.setLocationRelativeTo(evt.window)
|
||||
dialog.isVisible = true
|
||||
hostManager.addHost(dialog.host ?: return)
|
||||
|
||||
// Sync 模式,触发 reload
|
||||
hostManager.addHost(dialog.host ?: return, DatabaseChangedExtension.Source.Sync)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -274,7 +281,7 @@ 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) {
|
||||
if (tab.host.protocol == SSHProtocolProvider.PROTOCOL || tab.host.protocol == SFTPPtyProtocolProvider.PROTOCOL) {
|
||||
popupMenu.addSeparator()
|
||||
val sftpCommand = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
|
||||
sftpCommand.addActionListener { openSFTPPtyTab(tab, openHostAction, it) }
|
||||
@@ -382,7 +389,7 @@ class TerminalTabbed(
|
||||
|
||||
var host = tab.host
|
||||
|
||||
if (host.protocol == Protocol.SSH) {
|
||||
if (host.protocol == SSHProtocolProvider.PROTOCOL) {
|
||||
val envs = tab.host.options.envs().toMutableMap()
|
||||
val currentDir = tab.getData(DataProviders.Terminal)?.getTerminalModel()
|
||||
?.getData(DataKey.CurrentDir, StringUtils.EMPTY) ?: StringUtils.EMPTY
|
||||
@@ -392,7 +399,7 @@ class TerminalTabbed(
|
||||
}
|
||||
|
||||
host = host.copy(
|
||||
protocol = Protocol.SFTPPty, updateDate = System.currentTimeMillis(),
|
||||
protocol = SFTPPtyProtocolProvider.PROTOCOL,
|
||||
options = host.options.copy(env = envs.toPropertiesString())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ package app.termora
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviderSupport
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import app.termora.sftp.SFTPTab
|
||||
import app.termora.terminal.DataKey
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.ui.FlatRootPaneUI
|
||||
import com.formdev.flatlaf.ui.FlatTitlePane
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
@@ -42,7 +43,7 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane)
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val welcomePanel = WelcomePanel(windowScope)
|
||||
private val sftp get() = Database.getDatabase().sftp
|
||||
private val sftp get() = DatabaseManager.getInstance().sftp
|
||||
private var notifyListeners = emptyArray<NotifyListener>()
|
||||
|
||||
|
||||
@@ -216,6 +217,10 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
glassPane.isOpaque = false
|
||||
glassPane.isVisible = true
|
||||
|
||||
for (extension in ExtensionManager.getInstance().getExtensions(GlassPaneAwareExtension::class.java)) {
|
||||
extension.setGlassPane(this, glassPane)
|
||||
}
|
||||
|
||||
|
||||
Disposer.register(windowScope, terminalTabbed)
|
||||
add(terminalTabbed, BorderLayout.CENTER)
|
||||
@@ -260,19 +265,22 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
|
||||
|
||||
private class GlassPane : JComponent() {
|
||||
|
||||
init {
|
||||
isFocusable = false
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val img = BackgroundManager.getInstance().getBackgroundImage() ?: return
|
||||
val g2d = g as Graphics2D
|
||||
g2d.composite = AlphaComposite.getInstance(
|
||||
AlphaComposite.SRC_OVER,
|
||||
if (FlatLaf.isLafDark()) 0.2f else 0.1f
|
||||
)
|
||||
g2d.drawImage(img, 0, 0, width, height, null)
|
||||
g2d.composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER)
|
||||
override fun paintComponent(g2d: Graphics) {
|
||||
if (g2d !is Graphics2D) return
|
||||
val extensions = ExtensionManager.getInstance()
|
||||
.getExtensions(GlassPaneExtension::class.java)
|
||||
if (extensions.isNotEmpty()) {
|
||||
for (extension in extensions) {
|
||||
if (extension.paint(this, g2d)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun contains(x: Int, y: Int): Boolean {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.native.osx.NativeMacLibrary
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.nv.osx.NativeMacLibrary
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import com.formdev.flatlaf.ui.FlatNativeWindowsLibrary
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.sun.jna.Pointer
|
||||
@@ -37,9 +39,10 @@ class TermoraFrameManager : Disposable {
|
||||
}
|
||||
|
||||
private val frames = mutableListOf<TermoraFrame>()
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private val properties get() = DatabaseManager.getInstance().properties
|
||||
private val isDisposed = AtomicBoolean(false)
|
||||
private val isBackgroundRunning get() = Database.getDatabase().appearance.backgroundRunning
|
||||
private val isBackgroundRunning get() = DatabaseManager.getInstance().appearance.backgroundRunning
|
||||
private val frameExtensions get() = ExtensionManager.getInstance().getExtensions(FrameExtension::class.java)
|
||||
|
||||
fun createWindow(): TermoraFrame {
|
||||
val frame = TermoraFrame().apply { registerCloseCallback(this) }
|
||||
@@ -65,7 +68,7 @@ class TermoraFrameManager : Disposable {
|
||||
}
|
||||
|
||||
frame.addNotifyListener(object : NotifyListener {
|
||||
private val opacity get() = Database.getDatabase().appearance.opacity
|
||||
private val opacity get() = DatabaseManager.getInstance().appearance.opacity
|
||||
override fun addNotify() {
|
||||
val opacity = this.opacity
|
||||
if (opacity >= 1.0) return
|
||||
@@ -73,6 +76,10 @@ class TermoraFrameManager : Disposable {
|
||||
}
|
||||
})
|
||||
|
||||
for (extension in frameExtensions) {
|
||||
extension.customize(frame)
|
||||
}
|
||||
|
||||
return frame.apply { frames.add(this) }
|
||||
}
|
||||
|
||||
@@ -213,7 +220,7 @@ class TermoraFrameManager : Disposable {
|
||||
} else if (SystemInfo.isWindows) {
|
||||
val alpha = ((opacity * 255).toInt() and 0xFF).toByte()
|
||||
val hwnd = WinDef.HWND(Pointer.createConstant(FlatNativeWindowsLibrary.getHWND(window)))
|
||||
val exStyle = User32.INSTANCE.GetWindowLong(hwnd, User32.GWL_EXSTYLE)
|
||||
val exStyle = User32.INSTANCE.GetWindowLong(hwnd, GWL_EXSTYLE)
|
||||
if (exStyle and WS_EX_LAYERED == 0) {
|
||||
User32.INSTANCE.SetWindowLong(hwnd, GWL_EXSTYLE, exStyle or WS_EX_LAYERED)
|
||||
}
|
||||
|
||||
@@ -37,8 +37,9 @@ class TermoraRestarter {
|
||||
|
||||
}
|
||||
|
||||
val isSupported get() = !restarting.get() && checkIsSupported()
|
||||
|
||||
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 {
|
||||
@@ -66,22 +67,26 @@ class TermoraRestarter {
|
||||
/**
|
||||
* 计划重启,如果当前进程支持重启,那么会询问用户是否重启。如果不支持重启,那么弹窗提示需要手动重启。
|
||||
*/
|
||||
fun scheduleRestart(owner: Component?, commands: List<String> = emptyList()) {
|
||||
fun scheduleRestart(owner: Component?, ask: Boolean = true, 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(
|
||||
if (ask) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
I18n.getString("termora.settings.restart.message"),
|
||||
I18n.getString("termora.settings.restart.title"),
|
||||
I18n.getString("termora.cancel")
|
||||
),
|
||||
initialValue = I18n.getString("termora.settings.restart.title")
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
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 {
|
||||
restart(commands)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.termora
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.actions.*
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.findeverywhere.FindEverywhereAction
|
||||
import app.termora.snippet.SnippetAction
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
@@ -38,7 +39,7 @@ class TermoraToolBar(
|
||||
}
|
||||
}
|
||||
|
||||
private val properties by lazy { Database.getDatabase().properties }
|
||||
private val properties get() = DatabaseManager.getInstance().properties
|
||||
private val toolbar by lazy { MyToolBar().apply { rebuild() } }
|
||||
|
||||
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.plugin.Extension
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.extras.FlatAnimatedLafChange
|
||||
import com.jthemedetecor.OsThemeDetector
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.*
|
||||
import java.util.function.Consumer
|
||||
import javax.swing.PopupFactory
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.UIManager
|
||||
import javax.swing.event.EventListenerList
|
||||
|
||||
interface ThemeChangeListener : EventListener {
|
||||
interface ThemeChangeExtension : Extension {
|
||||
fun onChanged()
|
||||
}
|
||||
|
||||
class ThemeManager private constructor() {
|
||||
internal class ThemeManager private constructor() {
|
||||
|
||||
|
||||
companion object {
|
||||
@@ -27,7 +28,7 @@ class ThemeManager private constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
val appearance by lazy { Database.getDatabase().appearance }
|
||||
val appearance by lazy { DatabaseManager.getInstance().appearance }
|
||||
val themes = mapOf(
|
||||
"Light" to LightLaf::class.java.name,
|
||||
"Dark" to DarkLaf::class.java.name,
|
||||
@@ -57,7 +58,6 @@ class ThemeManager private constructor() {
|
||||
"Chalk" to ChalkLaf::class.java.name,
|
||||
)
|
||||
|
||||
private var listenerList = EventListenerList()
|
||||
|
||||
/**
|
||||
* 当前的主题
|
||||
@@ -112,7 +112,8 @@ class ThemeManager private constructor() {
|
||||
FlatAnimatedLafChange.hideSnapshotWithAnimation()
|
||||
}
|
||||
|
||||
listenerList.getListeners(ThemeChangeListener::class.java).forEach { it.onChanged() }
|
||||
ExtensionManager.getInstance().getExtensions(ThemeChangeExtension::class.java)
|
||||
.forEach { it.onChanged() }
|
||||
}
|
||||
|
||||
private fun immediateChange(classname: String) {
|
||||
@@ -127,12 +128,5 @@ class ThemeManager private constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
fun addThemeChangeListener(listener: ThemeChangeListener) {
|
||||
listenerList.add(ThemeChangeListener::class.java, listener)
|
||||
}
|
||||
|
||||
fun removeThemeChangeListener(listener: ThemeChangeListener) {
|
||||
listenerList.remove(ThemeChangeListener::class.java, listener)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package app.termora
|
||||
import app.termora.Application.ohMyJson
|
||||
import kotlinx.serialization.json.*
|
||||
import okhttp3.Request
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import org.commonmark.node.BulletList
|
||||
@@ -11,6 +12,7 @@ import org.commonmark.node.Paragraph
|
||||
import org.commonmark.parser.Parser
|
||||
import org.commonmark.renderer.html.AttributeProvider
|
||||
import org.commonmark.renderer.html.HtmlRenderer
|
||||
import org.semver4j.Semver
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
@@ -64,14 +66,23 @@ class UpdaterManager private constructor() {
|
||||
|
||||
fun fetchLatestVersion(): LatestVersion {
|
||||
try {
|
||||
val isBetaVersion = Application.isBetaVersion()
|
||||
val url = StringBuilder("https://api.github.com/repos/TermoraDev/termora/releases")
|
||||
if (isBetaVersion) {
|
||||
url.append("?per_page=10")
|
||||
} else {
|
||||
url.append("/latest")
|
||||
}
|
||||
|
||||
val request = Request.Builder().get()
|
||||
.url("https://api.github.com/repos/TermoraDev/termora/releases/latest")
|
||||
.url(url.toString())
|
||||
.build()
|
||||
val response = Application.httpClient.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
if (response.isSuccessful.not()) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error("Failed to fetch latest version, response was ${response.code}")
|
||||
}
|
||||
IOUtils.closeQuietly(response)
|
||||
return LatestVersion.self
|
||||
}
|
||||
|
||||
@@ -80,7 +91,9 @@ class UpdaterManager private constructor() {
|
||||
return LatestVersion.self
|
||||
}
|
||||
|
||||
val json = ohMyJson.parseToJsonElement(text).jsonObject
|
||||
val json = if (isBetaVersion) getLatestBetaRelease(text) else ohMyJson.parseToJsonElement(text).jsonObject
|
||||
if (json == null) return LatestVersion.self
|
||||
|
||||
val version = json.getValue("tag_name").jsonPrimitive.content
|
||||
val prerelease = json.getValue("prerelease").jsonPrimitive.boolean
|
||||
val draft = json.getValue("draft").jsonPrimitive.boolean
|
||||
@@ -145,4 +158,22 @@ class UpdaterManager private constructor() {
|
||||
return LatestVersion.self
|
||||
}
|
||||
|
||||
private fun getLatestBetaRelease(text: String): JsonObject? {
|
||||
val releases = parseReleases(text)
|
||||
if (releases.isEmpty()) return null
|
||||
return releases.maxByOrNull { it.first }?.second
|
||||
}
|
||||
|
||||
private fun parseReleases(text: String): List<Pair<Semver, JsonObject>> {
|
||||
val array = ohMyJson.parseToJsonElement(text).jsonArray
|
||||
val releases = mutableListOf<Pair<Semver, JsonObject>>()
|
||||
for (e in array) {
|
||||
val version = e.jsonObject.getValue("tag_name").jsonPrimitive.content
|
||||
val prerelease = e.jsonObject.getValue("prerelease").jsonPrimitive.boolean
|
||||
if (prerelease.not()) continue
|
||||
val semver = Semver.parse(version) ?: continue
|
||||
releases.add(semver to e.jsonObject)
|
||||
}
|
||||
return releases
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@ package app.termora
|
||||
|
||||
|
||||
import app.termora.actions.*
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.findeverywhere.FindEverywhereProvider
|
||||
import app.termora.findeverywhere.FindEverywhereResult
|
||||
import app.termora.plugin.internal.ssh.SSHProtocolProvider
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.tree.NewHostTree
|
||||
import app.termora.tree.NewHostTreeModel
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon
|
||||
@@ -18,12 +22,11 @@ import java.awt.Dimension
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.awt.event.*
|
||||
import javax.swing.*
|
||||
import javax.swing.event.DocumentEvent
|
||||
import kotlin.math.max
|
||||
|
||||
class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()), Disposable, TerminalTab,
|
||||
DataProvider {
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private val properties get() = DatabaseManager.getInstance().properties
|
||||
private val rootPanel = JPanel(BorderLayout())
|
||||
private val searchTextField = FlatTextField()
|
||||
private val hostTree = NewHostTree()
|
||||
@@ -33,9 +36,9 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val hostTreeModel = hostTree.model as NewHostTreeModel
|
||||
private var lastFocused: Component? = null
|
||||
private val filterableHostTreeModel = FilterableHostTreeModel(hostTree) {
|
||||
searchTextField.text.isBlank()
|
||||
}
|
||||
// private val filterableHostTreeModel = FilterableHostTreeModel(hostTree) {
|
||||
// searchTextField.text.isBlank()
|
||||
// }
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -141,11 +144,9 @@ 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)
|
||||
)
|
||||
// hostTree.model = filterableHostTreeModel
|
||||
hostTree.name = "WelcomeHostTree"
|
||||
hostTree.restoreExpansions()
|
||||
|
||||
return panel
|
||||
}
|
||||
@@ -153,6 +154,8 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
Disposer.register(this, hostTree)
|
||||
|
||||
addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentShown(e: ComponentEvent) {
|
||||
if (!searchTextField.hasFocus()) {
|
||||
@@ -175,11 +178,11 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
override fun find(pattern: String): List<FindEverywhereResult> {
|
||||
var filter = hostTreeModel.root.getAllChildren()
|
||||
.map { it.host }
|
||||
.filter { it.protocol != Protocol.Folder }
|
||||
.filter { it.isFolder.not() }
|
||||
|
||||
if (pattern.isNotBlank()) {
|
||||
filter = filter.filter {
|
||||
if (it.protocol == Protocol.SSH) {
|
||||
if (it.protocol == SSHProtocolProvider.PROTOCOL) {
|
||||
it.name.contains(pattern, true) || it.host.contains(pattern, true)
|
||||
} else {
|
||||
it.name.contains(pattern, true)
|
||||
@@ -200,7 +203,8 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
})
|
||||
|
||||
|
||||
filterableHostTreeModel.addFilter {
|
||||
/*filterableHostTreeModel.addFilter {
|
||||
if (it !is HostTreeNode) return@addFilter false
|
||||
val text = searchTextField.text
|
||||
val host = it.host
|
||||
text.isBlank() || host.name.contains(text, true)
|
||||
@@ -216,7 +220,7 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
hostTree.expandAll()
|
||||
}
|
||||
}
|
||||
})
|
||||
})*/
|
||||
|
||||
searchTextField.addKeyListener(object : KeyAdapter() {
|
||||
private val event = ActionEvent(hostTree, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY)
|
||||
@@ -290,11 +294,10 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
|
||||
override fun dispose() {
|
||||
properties.putString("WelcomeFullContent", fullContent.toString())
|
||||
properties.putString("Welcome.HostTree.state", TreeUtils.saveExpansionState(hostTree))
|
||||
}
|
||||
|
||||
private inner class HostFindEverywhereResult(val host: Host) : FindEverywhereResult {
|
||||
private val showMoreInfo get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
|
||||
private val showMoreInfo get() = EnableManager.getInstance().isShowMoreInfo()
|
||||
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
ActionManager.getInstance()
|
||||
@@ -315,8 +318,8 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
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
|
||||
SSHProtocolProvider.PROTOCOL -> "${host.username}@${host.host}"
|
||||
"Serial" -> host.options.serialComm.port
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
if (moreInfo.isNotBlank()) {
|
||||
|
||||
81
src/main/kotlin/app/termora/account/Account.kt
Normal file
81
src/main/kotlin/app/termora/account/Account.kt
Normal file
@@ -0,0 +1,81 @@
|
||||
package app.termora.account
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.security.PrivateKey
|
||||
import java.security.PublicKey
|
||||
|
||||
data class Account(
|
||||
/**
|
||||
* 账户唯一 ID
|
||||
*/
|
||||
val id: String,
|
||||
|
||||
/**
|
||||
* 后台服务
|
||||
*/
|
||||
val server: String,
|
||||
|
||||
/**
|
||||
* 账号
|
||||
*/
|
||||
val email: String,
|
||||
|
||||
/**
|
||||
* 加入的团队
|
||||
*/
|
||||
val teams: List<Team>,
|
||||
|
||||
/**
|
||||
* 订阅
|
||||
*/
|
||||
val subscriptions: List<Subscription>,
|
||||
|
||||
|
||||
/**
|
||||
* 访问 Token
|
||||
*/
|
||||
val accessToken: String,
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
*/
|
||||
val refreshToken: String,
|
||||
|
||||
/**
|
||||
* 用户的密钥
|
||||
*/
|
||||
val secretKey: ByteArray,
|
||||
|
||||
/**
|
||||
* 用户公钥
|
||||
*/
|
||||
val publicKey: PublicKey,
|
||||
|
||||
/**
|
||||
* 用户私钥
|
||||
*/
|
||||
val privateKey: PrivateKey,
|
||||
) {
|
||||
|
||||
val isLocally
|
||||
get() = (AccountManager.isLocally(id) || StringUtils.equalsIgnoreCase(email, "locally") ||
|
||||
StringUtils.equalsIgnoreCase(server, "locally"))
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Account
|
||||
|
||||
if (id != other.id) return false
|
||||
if (server != other.server) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + server.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
10
src/main/kotlin/app/termora/account/AccountExtension.kt
Normal file
10
src/main/kotlin/app/termora/account/AccountExtension.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.plugin.Extension
|
||||
|
||||
interface AccountExtension : Extension {
|
||||
/**
|
||||
* 账户发生变更
|
||||
*/
|
||||
fun onAccountChanged(oldAccount: Account, newAccount: Account)
|
||||
}
|
||||
218
src/main/kotlin/app/termora/account/AccountHttp.kt
Normal file
218
src/main/kotlin/app/termora/account/AccountHttp.kt
Normal file
@@ -0,0 +1,218 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.AntPathMatcher
|
||||
import app.termora.Application
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.Ed25519
|
||||
import app.termora.ResponseException
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.withLock
|
||||
import org.apache.commons.codec.binary.Base64
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.time.DateUtils
|
||||
import org.apache.commons.net.util.SubnetUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
object AccountHttp {
|
||||
private val log = LoggerFactory.getLogger(AccountHttp::class.java)
|
||||
val client = Application.httpClient.newBuilder()
|
||||
.addInterceptor(UserAgentInterceptor())
|
||||
.addInterceptor(SignatureInterceptor())
|
||||
.addInterceptor(AccessTokenInterceptor())
|
||||
.build()
|
||||
|
||||
|
||||
fun execute(client: OkHttpClient = AccountHttp.client, request: Request): String {
|
||||
val response = client.newCall(request).execute()
|
||||
if (response.isSuccessful.not()) {
|
||||
IOUtils.closeQuietly(response)
|
||||
throw ResponseException(response.code, response)
|
||||
}
|
||||
|
||||
val text = response.use { response.body.use { it?.string() } }
|
||||
if (text.isNullOrBlank()) {
|
||||
throw ResponseException(response.code, "response body is empty", response)
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("url: ${request.url} response: $text")
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
private class SignatureInterceptor : Interceptor {
|
||||
private val accountProperties get() = AccountProperties.getInstance()
|
||||
private val matcher = AntPathMatcher(".")
|
||||
|
||||
// @formatter:off
|
||||
private val publicKey by lazy { Ed25519.generatePublic(Base64.decodeBase64("MCowBQYDK2VwAyEADhvgc8vWLXBFB36QtMlCujqdBNDMb2T5qE2V03hJKWA=")) }
|
||||
// @formatter:on
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
val signatureBase64 = response.headers("X-Signature").firstOrNull()
|
||||
val dataBase64 = response.headers("X-Signature-Data").firstOrNull()
|
||||
var signed: Boolean? = null
|
||||
|
||||
if (signatureBase64.isNullOrBlank() || dataBase64.isNullOrBlank()) {
|
||||
signed = false
|
||||
} else {
|
||||
val signature = Base64.decodeBase64(signatureBase64)
|
||||
val data = Base64.decodeBase64(dataBase64)
|
||||
val json = ohMyJson.decodeFromString<JsonObject>(String(data))
|
||||
|
||||
// 校验许可证日期
|
||||
val subscriptionExpiry = json["SubscriptionExpiry"]?.jsonPrimitive?.content
|
||||
if (subscriptionExpiry == null) {
|
||||
signed = false
|
||||
} else {
|
||||
val date = runCatching { DateUtils.parseDate(subscriptionExpiry, "yyyy-MM-dd") }.getOrNull()
|
||||
if (date == null) {
|
||||
signed = false
|
||||
} else if (Application.getReleaseDate().after(date) || date.time < System.currentTimeMillis()) {
|
||||
signed = false
|
||||
}
|
||||
}
|
||||
|
||||
// 校验 host 是否与签名一致
|
||||
if (signed == null) {
|
||||
if (isInRange(chain.request(), json).not()) {
|
||||
signed = false
|
||||
}
|
||||
}
|
||||
|
||||
// 校验许可证签名
|
||||
if (signed == null) {
|
||||
signed = Ed25519.verify(publicKey, data, signature)
|
||||
}
|
||||
}
|
||||
|
||||
val oldSigned = accountProperties.signed
|
||||
|
||||
if (oldSigned != signed) {
|
||||
accountProperties.signed = signed
|
||||
SwingUtilities.invokeLater {
|
||||
for (extension in ExtensionManager.getInstance().getExtensions(ServerSignedExtension::class.java)) {
|
||||
extension.onSignedChanged(oldSigned, signed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun isInRange(request: Request, json: JsonObject): Boolean {
|
||||
val hostsArray = json["Hosts"]?.jsonArray
|
||||
if (hostsArray.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val host = request.url.host
|
||||
val hosts = ohMyJson.decodeFromJsonElement<List<String>>(hostsArray).toMutableList()
|
||||
hosts.addFirst("127.0.0.1")
|
||||
hosts.addFirst("localhost")
|
||||
for (cidr in hosts) {
|
||||
try {
|
||||
if (cidr == host) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (matcher.match(cidr, host)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val subnet = SubnetUtils(cidr)
|
||||
if (subnet.info.isInRange(host)) {
|
||||
return true
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private class UserAgentInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val builder = chain.request().newBuilder()
|
||||
if (chain.request().header("User-Agent") == null) {
|
||||
builder.header("User-Agent", Application.getUserAgent())
|
||||
}
|
||||
return chain.proceed(builder.build())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class AccessTokenInterceptor : Interceptor {
|
||||
private val lock = ReentrantLock()
|
||||
private val condition = lock.newCondition()
|
||||
private val isRefreshing = AtomicBoolean(false)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val builder = chain.request().newBuilder()
|
||||
val accountManager = AccountManager.getInstance()
|
||||
val accessToken = accountManager.getAccessToken()
|
||||
if (chain.request().header("Authorization") == null) {
|
||||
if (accessToken.isNotBlank()) {
|
||||
builder.header("Authorization", "Bearer $accessToken")
|
||||
}
|
||||
}
|
||||
|
||||
val response = chain.proceed(builder.build())
|
||||
if (response.code == 401 && accountManager.isLocally().not()) {
|
||||
IOUtils.closeQuietly(response)
|
||||
|
||||
if (isRefreshing.compareAndSet(false, true)) {
|
||||
try {
|
||||
// 刷新 token
|
||||
accountManager.refreshToken()
|
||||
} finally {
|
||||
lock.withLock {
|
||||
isRefreshing.set(false)
|
||||
condition.signalAll()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lock.lock()
|
||||
try {
|
||||
condition.await()
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// 拿到新 token 后重新发请求
|
||||
val newAccessToken = accountManager.getAccessToken()
|
||||
val newRequest = builder
|
||||
.removeHeader("Authorization")
|
||||
.header("Authorization", "Bearer $newAccessToken")
|
||||
.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
}
|
||||
265
src/main/kotlin/app/termora/account/AccountManager.kt
Normal file
265
src/main/kotlin/app/termora/account/AccountManager.kt
Normal file
@@ -0,0 +1,265 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.apache.commons.codec.binary.Base64
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.security.PrivateKey
|
||||
import java.security.PublicKey
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class AccountManager private constructor() : ApplicationRunnerExtension {
|
||||
companion object {
|
||||
fun getInstance(): AccountManager {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(AccountManager::class) { AccountManager() }
|
||||
}
|
||||
|
||||
fun isLocally(id: String): Boolean {
|
||||
return StringUtils.isBlank(id) || id == "0"
|
||||
}
|
||||
}
|
||||
|
||||
private var account = locally()
|
||||
private val accountProperties get() = AccountProperties.getInstance()
|
||||
|
||||
fun getAccount() = account
|
||||
fun getAccountId() = account.id
|
||||
fun getServer() = account.server
|
||||
fun getEmail() = account.email
|
||||
fun getSubscriptions() = account.subscriptions
|
||||
fun getTeams() = account.teams
|
||||
fun getSecretKey() = account.secretKey
|
||||
fun getPublicKey() = account.publicKey
|
||||
fun getPrivateKey() = account.privateKey
|
||||
fun isSigned() = isFreePlan().not() && accountProperties.signed
|
||||
fun isLocally() = account.isLocally
|
||||
fun getLastSynchronizationOn() = accountProperties.lastSynchronizationOn
|
||||
fun getAccessToken() = account.accessToken
|
||||
fun getRefreshToken() = account.refreshToken
|
||||
fun getOwnerIds() = account.teams.map { it.id }.toMutableList().apply { add(getAccountId()) }.toSet()
|
||||
|
||||
fun isFreePlan(): Boolean {
|
||||
return isLocally() || getSubscription().plan == SubscriptionPlan.Free
|
||||
}
|
||||
|
||||
fun getSubscription(): Subscription {
|
||||
|
||||
if (isLocally().not()) {
|
||||
val subscriptions = getSubscriptions()
|
||||
val enterprises = getSubscriptions().filter { it.plan == SubscriptionPlan.Enterprise }
|
||||
val teams = subscriptions.filter { it.plan == SubscriptionPlan.Team }
|
||||
val pros = subscriptions.filter { it.plan == SubscriptionPlan.Pro }
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
if (enterprises.any { it.endAt > now }) {
|
||||
return enterprises.first { it.endAt > now }
|
||||
} else if (teams.any { it.endAt > now }) {
|
||||
return teams.first { it.endAt > now }
|
||||
} else if (pros.any { it.endAt > now }) {
|
||||
return pros.first { it.endAt > now }
|
||||
}
|
||||
}
|
||||
|
||||
return Subscription(id = "0", plan = SubscriptionPlan.Free, startAt = 0, endAt = 0)
|
||||
}
|
||||
|
||||
fun hasTeamFeature(): Boolean {
|
||||
if (accountProperties.signed.not()) return false
|
||||
val plan = getSubscription().plan
|
||||
return SubscriptionPlan.Team == plan || SubscriptionPlan.Enterprise == plan
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
*/
|
||||
internal fun refreshToken() {
|
||||
val body = ohMyJson.encodeToString(mapOf("refreshToken" to getRefreshToken()))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder().url("${getServer()}/v1/token")
|
||||
.header("Authorization", "Bearer ${getRefreshToken()}")
|
||||
.post(body)
|
||||
.build()
|
||||
val response = Application.httpClient.newCall(request).execute()
|
||||
if (response.code == 401) {
|
||||
IOUtils.closeQuietly(response)
|
||||
logout()
|
||||
throw ResponseException(response.code, response)
|
||||
} else if (response.isSuccessful.not()) {
|
||||
IOUtils.closeQuietly(response)
|
||||
throw ResponseException(response.code, response)
|
||||
}
|
||||
|
||||
val text = response.use { response.body.use { it?.string() } }
|
||||
if (text == null) {
|
||||
throw ResponseException(response.code, "response body is empty", response)
|
||||
}
|
||||
|
||||
val json = ohMyJson.decodeFromString<JsonObject>(text)
|
||||
val accessToken = json["accessToken"]?.jsonPrimitive?.content
|
||||
val refreshToken = json["refreshToken"]?.jsonPrimitive?.content
|
||||
if (accessToken == null || refreshToken == null) {
|
||||
throw ResponseException(response.code, "token is empty", response)
|
||||
}
|
||||
|
||||
// 设置用户信息
|
||||
login(account.copy(accessToken = accessToken, refreshToken = refreshToken))
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置账户信息,可以多次调用,每次修改用户信息都要通过这个方法
|
||||
*/
|
||||
internal fun login(account: Account) {
|
||||
|
||||
val oldAccount = this.account
|
||||
|
||||
this.account = account
|
||||
|
||||
// 立即保存到数据库
|
||||
val accountProperties = AccountProperties.getInstance()
|
||||
accountProperties.id = account.id
|
||||
accountProperties.server = account.server
|
||||
accountProperties.email = account.email
|
||||
accountProperties.teams = ohMyJson.encodeToString(account.teams)
|
||||
accountProperties.subscriptions = ohMyJson.encodeToString(account.subscriptions)
|
||||
accountProperties.accessToken = account.accessToken
|
||||
accountProperties.refreshToken = account.refreshToken
|
||||
accountProperties.secretKey = ohMyJson.encodeToString(account.secretKey)
|
||||
|
||||
// 如果变更账户了,那么同步时间从0开始
|
||||
if (oldAccount.id != account.id) {
|
||||
accountProperties.nextSynchronizationSince = 0
|
||||
}
|
||||
|
||||
if (isLocally().not()) {
|
||||
accountProperties.publicKey = Base64.encodeBase64String(account.publicKey.encoded)
|
||||
accountProperties.privateKey = Base64.encodeBase64String(account.privateKey.encoded)
|
||||
} else {
|
||||
accountProperties.publicKey = StringUtils.EMPTY
|
||||
accountProperties.privateKey = StringUtils.EMPTY
|
||||
}
|
||||
|
||||
// 通知变化
|
||||
notifyAccountChanged(oldAccount, account)
|
||||
}
|
||||
|
||||
private fun notifyAccountChanged(oldAccount: Account, newAccount: Account) {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
for (extension in ExtensionManager.getInstance().getExtensions(AccountExtension::class.java)) {
|
||||
extension.onAccountChanged(oldAccount, newAccount)
|
||||
}
|
||||
} else {
|
||||
SwingUtilities.invokeLater { notifyAccountChanged(oldAccount, newAccount) }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun logout() {
|
||||
if (isLocally().not()) {
|
||||
// 登入本地用户
|
||||
login(locally())
|
||||
}
|
||||
}
|
||||
|
||||
private fun locally(): Account {
|
||||
return Account(
|
||||
id = "0",
|
||||
server = "locally",
|
||||
email = "locally",
|
||||
teams = emptyList(),
|
||||
subscriptions = listOf(),
|
||||
secretKey = byteArrayOf(),
|
||||
accessToken = StringUtils.EMPTY,
|
||||
refreshToken = StringUtils.EMPTY,
|
||||
publicKey = object : PublicKey {
|
||||
override fun getAlgorithm(): String? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getFormat(): String? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getEncoded(): ByteArray? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
},
|
||||
privateKey = object : PrivateKey {
|
||||
override fun getAlgorithm(): String? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getFormat(): String? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getEncoded(): ByteArray? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
override fun ready() {
|
||||
if (isLocally().not()) {
|
||||
swingCoroutineScope.launch(Dispatchers.IO) { refreshToken() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 刷新用户
|
||||
*/
|
||||
fun refresh(accessToken: String = getAccessToken()) {
|
||||
|
||||
}
|
||||
|
||||
class AccountApplicationRunnerExtension private constructor() : ApplicationRunnerExtension {
|
||||
companion object {
|
||||
val instance by lazy { AccountApplicationRunnerExtension() }
|
||||
}
|
||||
|
||||
override fun ready() {
|
||||
val accountManager = getInstance()
|
||||
val accountProperties = AccountProperties.getInstance()
|
||||
|
||||
// 如果都是本地用户,那么可以忽略
|
||||
val id = accountProperties.id
|
||||
if (id.isBlank() || id == accountManager.getAccountId()) return
|
||||
|
||||
// 初始化本地账户
|
||||
accountManager.account = Account(
|
||||
id = accountProperties.id,
|
||||
server = accountProperties.server,
|
||||
email = accountProperties.email,
|
||||
accessToken = accountProperties.accessToken,
|
||||
refreshToken = accountProperties.refreshToken,
|
||||
teams = ohMyJson.decodeFromString(accountProperties.teams),
|
||||
subscriptions = ohMyJson.decodeFromString(accountProperties.subscriptions),
|
||||
secretKey = ohMyJson.decodeFromString(accountProperties.secretKey),
|
||||
publicKey = RSA.generatePublic(Base64.decodeBase64(accountProperties.publicKey)),
|
||||
privateKey = RSA.generatePrivate(Base64.decodeBase64(accountProperties.privateKey))
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
override fun ordered(): Long {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
238
src/main/kotlin/app/termora/account/AccountOption.kt
Normal file
238
src/main/kotlin/app/termora/account/AccountOption.kt
Normal file
@@ -0,0 +1,238 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.OptionsPane.Companion.FORM_MARGIN
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.plugin.internal.extension.DynamicExtensionHandler
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import org.jdesktop.swingx.JXHyperlink
|
||||
import java.awt.BorderLayout
|
||||
import java.net.URI
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
class AccountOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable {
|
||||
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||
private val databaseManager get() = DatabaseManager.getInstance()
|
||||
private val accountManager get() = AccountManager.getInstance()
|
||||
private val accountProperties get() = AccountProperties.getInstance()
|
||||
private val userInfoPanel = JPanel(BorderLayout())
|
||||
private val lastSynchronizationOnLabel = JLabel()
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
|
||||
private fun initView() {
|
||||
refreshUserInfoPanel()
|
||||
add(userInfoPanel, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
|
||||
private fun initEvents() {
|
||||
// 服务器签名发生变更
|
||||
DynamicExtensionHandler.getInstance()
|
||||
.register(ServerSignedExtension::class.java, object : ServerSignedExtension {
|
||||
override fun onSignedChanged(oldSigned: Boolean, newSigned: Boolean) {
|
||||
refreshUserInfoPanel()
|
||||
}
|
||||
}).let { Disposer.register(this, it) }
|
||||
|
||||
// 账号发生变化
|
||||
DynamicExtensionHandler.getInstance()
|
||||
.register(AccountExtension::class.java, object : AccountExtension {
|
||||
override fun onAccountChanged(oldAccount: Account, newAccount: Account) {
|
||||
if (oldAccount.id != newAccount.id) {
|
||||
refreshUserInfoPanel()
|
||||
}
|
||||
}
|
||||
}).let { Disposer.register(this, it) }
|
||||
}
|
||||
|
||||
private fun getCenterComponent(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $FORM_MARGIN, default:grow",
|
||||
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
|
||||
)
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
|
||||
val subscription = accountManager.getSubscription()
|
||||
val isFreePlan = accountManager.isFreePlan()
|
||||
val isLocally = accountManager.isLocally()
|
||||
val validTo = if (isFreePlan) "-" else if (subscription.endAt >= Long.MAX_VALUE)
|
||||
I18n.getString("termora.settings.account.lifetime") else
|
||||
DateFormatUtils.format(Date(subscription.endAt), I18n.getString("termora.date-format"))
|
||||
val lastSynchronizationOn = if (isFreePlan) "-" else
|
||||
DateFormatUtils.format(
|
||||
Date(accountManager.getLastSynchronizationOn()),
|
||||
I18n.getString("termora.date-format")
|
||||
)
|
||||
|
||||
var server = accountManager.getServer()
|
||||
var email = accountManager.getEmail()
|
||||
if (isLocally) {
|
||||
server = I18n.getString("termora.settings.account.locally")
|
||||
email = I18n.getString("termora.settings.account.locally")
|
||||
}
|
||||
|
||||
val planBox = Box.createHorizontalBox()
|
||||
planBox.add(JLabel(if (isLocally) "-" else subscription.plan.name))
|
||||
if (isFreePlan && isLocally.not()) {
|
||||
planBox.add(Box.createHorizontalStrut(16))
|
||||
val upgrade = JXHyperlink(object : AnAction(I18n.getString("termora.settings.account.upgrade")) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
|
||||
}
|
||||
})
|
||||
upgrade.isFocusable = false
|
||||
planBox.add(upgrade)
|
||||
}
|
||||
|
||||
val serverBox = Box.createHorizontalBox()
|
||||
serverBox.add(JLabel(server))
|
||||
if (isLocally.not()) {
|
||||
serverBox.add(Box.createHorizontalStrut(8))
|
||||
if (accountManager.isSigned().not()) {
|
||||
val upgrade =
|
||||
JXHyperlink(object : AnAction(I18n.getString("termora.settings.account.verify"), Icons.error) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
Application.browse(URI.create("https://www.termora.app"))
|
||||
}
|
||||
})
|
||||
upgrade.isFocusable = false
|
||||
serverBox.add(upgrade)
|
||||
} else {
|
||||
serverBox.add(JLabel(Icons.success))
|
||||
}
|
||||
}
|
||||
|
||||
lastSynchronizationOnLabel.text = lastSynchronizationOn
|
||||
|
||||
return FormBuilder.create().layout(layout).debug(false)
|
||||
.add("${I18n.getString("termora.settings.account.server")}:").xy(1, rows)
|
||||
.add(serverBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.account")}:").xy(1, rows)
|
||||
.add(email).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.account.subscription")}:").xy(1, rows)
|
||||
.add(planBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.account.valid-to")}:").xy(1, rows)
|
||||
.add(validTo).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.account.synchronization-on")}:").xy(1, rows)
|
||||
.add(lastSynchronizationOnLabel).xy(3, rows).apply { rows += step }
|
||||
.add(createActionPanel(isFreePlan)).xyw(1, rows, 3).apply { rows += step }
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createActionPanel(isFreePlan: Boolean): JComponent {
|
||||
val actionBox = Box.createHorizontalBox()
|
||||
actionBox.add(Box.createHorizontalGlue())
|
||||
val actions = mutableSetOf<JComponent>()
|
||||
|
||||
if (accountManager.isLocally()) {
|
||||
actions.add(JXHyperlink(object : AnAction("${I18n.getString("termora.settings.account.login")}...") {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
onLogin()
|
||||
}
|
||||
}).apply { isFocusable = false })
|
||||
} else {
|
||||
if (isFreePlan.not()) {
|
||||
actions.add(JXHyperlink(object : AnAction(I18n.getString("termora.settings.account.sync-now")) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
PullService.getInstance().trigger()
|
||||
PushService.getInstance().trigger()
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
isEnabled = false
|
||||
lastSynchronizationOnLabel.text = DateFormatUtils.format(
|
||||
Date(System.currentTimeMillis()),
|
||||
I18n.getString("termora.date-format")
|
||||
)
|
||||
}
|
||||
delay(1500.milliseconds)
|
||||
withContext(Dispatchers.Swing) { isEnabled = true }
|
||||
}
|
||||
}
|
||||
}).apply { isFocusable = false })
|
||||
}
|
||||
|
||||
actions.add(JXHyperlink(object : AnAction(I18n.getString("termora.settings.account.logout")) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val hasUnsyncedData = databaseManager.unsyncedData().isNotEmpty()
|
||||
val message = if (hasUnsyncedData) "termora.settings.account.unsynced-logout-confirm"
|
||||
else "termora.settings.account.logout-confirm"
|
||||
val option = OptionPane.showConfirmDialog(
|
||||
owner, I18n.getString(message),
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION,
|
||||
messageType = JOptionPane.QUESTION_MESSAGE,
|
||||
)
|
||||
if (option != JOptionPane.OK_OPTION) {
|
||||
return
|
||||
}
|
||||
AccountManager.getInstance().logout()
|
||||
}
|
||||
}).apply { isFocusable = false })
|
||||
}
|
||||
|
||||
for (component in actions) {
|
||||
actionBox.add(component)
|
||||
if (actions.last() != component) {
|
||||
actionBox.add(Box.createHorizontalStrut(8))
|
||||
}
|
||||
}
|
||||
|
||||
actionBox.add(Box.createHorizontalGlue())
|
||||
|
||||
|
||||
|
||||
return actionBox
|
||||
}
|
||||
|
||||
private fun onLogin() {
|
||||
val dialog = LoginServerDialog(owner)
|
||||
dialog.isVisible = true
|
||||
}
|
||||
|
||||
private fun refreshUserInfoPanel() {
|
||||
userInfoPanel.removeAll()
|
||||
userInfoPanel.add(getCenterComponent(), BorderLayout.CENTER)
|
||||
userInfoPanel.revalidate()
|
||||
userInfoPanel.repaint()
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
return Icons.user
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
return I18n.getString("termora.settings.account")
|
||||
}
|
||||
|
||||
override fun getJComponent(): JComponent {
|
||||
return this
|
||||
}
|
||||
|
||||
override fun getAnchor(): OptionsPane.Anchor {
|
||||
return OptionsPane.Anchor.First
|
||||
}
|
||||
|
||||
override fun getIdentifier(): String {
|
||||
return "Account"
|
||||
}
|
||||
|
||||
}
|
||||
6
src/main/kotlin/app/termora/account/AccountOwner.kt
Normal file
6
src/main/kotlin/app/termora/account/AccountOwner.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.database.OwnerType
|
||||
|
||||
data class AccountOwner(val id: String, val name: String, val type: OwnerType) {
|
||||
}
|
||||
27
src/main/kotlin/app/termora/account/AccountPlugin.kt
Normal file
27
src/main/kotlin/app/termora/account/AccountPlugin.kt
Normal file
@@ -0,0 +1,27 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.ApplicationRunnerExtension
|
||||
import app.termora.SettingsOptionExtension
|
||||
import app.termora.database.DatabaseChangedExtension
|
||||
import app.termora.plugin.Extension
|
||||
import app.termora.plugin.InternalPlugin
|
||||
|
||||
internal class AccountPlugin : InternalPlugin() {
|
||||
init {
|
||||
support.addExtension(SettingsOptionExtension::class.java) { AccountSettingsOptionExtension.instance }
|
||||
support.addExtension(ApplicationRunnerExtension::class.java) { AccountManager.AccountApplicationRunnerExtension.instance }
|
||||
support.addExtension(ApplicationRunnerExtension::class.java) { AccountManager.getInstance() }
|
||||
support.addExtension(ApplicationRunnerExtension::class.java) { PushService.getInstance() }
|
||||
support.addExtension(ApplicationRunnerExtension::class.java) { PullService.getInstance() }
|
||||
support.addExtension(DatabaseChangedExtension::class.java) { PushService.getInstance() }
|
||||
}
|
||||
|
||||
override fun getName(): String {
|
||||
return "Account"
|
||||
}
|
||||
|
||||
|
||||
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
|
||||
return support.getExtensions(clazz)
|
||||
}
|
||||
}
|
||||
79
src/main/kotlin/app/termora/account/AccountProperties.kt
Normal file
79
src/main/kotlin/app/termora/account/AccountProperties.kt
Normal file
@@ -0,0 +1,79 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.ApplicationScope
|
||||
import app.termora.database.DatabaseManager
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
|
||||
/**
|
||||
* 账号配置
|
||||
*/
|
||||
class AccountProperties private constructor(databaseManager: DatabaseManager) :
|
||||
DatabaseManager.IProperties(databaseManager, "Setting.Account") {
|
||||
|
||||
companion object {
|
||||
fun getInstance(): AccountProperties {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(AccountProperties::class) { AccountProperties(DatabaseManager.getInstance()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* id
|
||||
*/
|
||||
var id by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* server
|
||||
*/
|
||||
var server by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* email
|
||||
*/
|
||||
var email by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* team
|
||||
*/
|
||||
var teams by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* team
|
||||
*/
|
||||
var subscriptions by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* 最后同步时间(本地时间)
|
||||
*/
|
||||
var lastSynchronizationOn by LongPropertyDelegate(0)
|
||||
|
||||
/**
|
||||
* 下次同步时间的开始时间
|
||||
*/
|
||||
var nextSynchronizationSince by LongPropertyDelegate(0)
|
||||
|
||||
/**
|
||||
* 用户密钥,array string
|
||||
*/
|
||||
var secretKey by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* 用户公钥匙,base64
|
||||
*/
|
||||
var publicKey by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* 用户私钥,base64
|
||||
*/
|
||||
var privateKey by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
var accessToken by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
var refreshToken by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* 服务器 是否经过验证
|
||||
*/
|
||||
var signed by BooleanPropertyDelegate(false)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.OptionsPane
|
||||
import app.termora.SettingsOptionExtension
|
||||
|
||||
class AccountSettingsOptionExtension private constructor() : SettingsOptionExtension {
|
||||
companion object {
|
||||
val instance by lazy { AccountSettingsOptionExtension() }
|
||||
}
|
||||
|
||||
override fun createSettingsOption(): OptionsPane.Option {
|
||||
return AccountOption()
|
||||
}
|
||||
}
|
||||
331
src/main/kotlin/app/termora/account/LoginServerDialog.kt
Normal file
331
src/main/kotlin/app/termora/account/LoginServerDialog.kt
Normal file
@@ -0,0 +1,331 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.OptionsPane.Companion.FORM_MARGIN
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.database.DatabaseManager
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.JXHyperlink
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.awt.event.ItemEvent
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import java.net.URI
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.*
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class LoginServerDialog(owner: Window) : DialogWrapper(owner) {
|
||||
private val serverComboBox = OutlineComboBox<Server>()
|
||||
private val usernameTextField = OutlineTextField()
|
||||
private val passwordField = OutlinePasswordField()
|
||||
private val okAction = OkAction(I18n.getString("termora.settings.account.login"))
|
||||
private val cancelAction = super.createCancelAction()
|
||||
private val cancelButton = super.createJButtonForAction(cancelAction)
|
||||
private val isLoggingIn = AtomicBoolean(false)
|
||||
|
||||
init {
|
||||
isModal = true
|
||||
isResizable = false
|
||||
controlsVisible = false
|
||||
title = I18n.getString("termora.settings.account.login")
|
||||
init()
|
||||
pack()
|
||||
size = Dimension(max(preferredSize.width, UIManager.getInt("Dialog.width") - 250), preferredSize.height)
|
||||
setLocationRelativeTo(owner)
|
||||
|
||||
|
||||
addWindowListener(object : WindowAdapter() {
|
||||
override fun windowOpened(e: WindowEvent) {
|
||||
removeWindowListener(this)
|
||||
usernameTextField.requestFocus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, pref",
|
||||
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN"
|
||||
)
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
|
||||
|
||||
val singaporeServer =
|
||||
Server(I18n.getString("termora.settings.account.server-singapore"), "https://account.termora.app")
|
||||
val chinaServer = Server(I18n.getString("termora.settings.account.server-china"), "https://account.termora.cn")
|
||||
|
||||
if (Application.isUnknownVersion()) {
|
||||
serverComboBox.addItem(Server("Localhost", "http://127.0.0.1:8080"))
|
||||
}
|
||||
|
||||
serverComboBox.addItem(singaporeServer)
|
||||
serverComboBox.addItem(chinaServer)
|
||||
|
||||
val properties = DatabaseManager.getInstance().properties
|
||||
val servers = (runCatching {
|
||||
ohMyJson.decodeFromString<List<Server>>(properties.getString("login-servers", "[]"))
|
||||
}.getOrNull() ?: emptyList()).toMutableList()
|
||||
for (server in servers) {
|
||||
serverComboBox.addItem(Server(server.name, server.server))
|
||||
}
|
||||
|
||||
serverComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
if (value is Server) {
|
||||
if (isSelected) {
|
||||
return super.getListCellRendererComponent(
|
||||
list,
|
||||
"[${value.name}] ${value.server}",
|
||||
index,
|
||||
true,
|
||||
cellHasFocus
|
||||
)
|
||||
}
|
||||
val color = UIManager.getColor("textInactiveText")
|
||||
return super.getListCellRendererComponent(
|
||||
list,
|
||||
"<html><font color=rgb(${color.red},${color.green},${color.blue})>[${value.name}]</font> ${value.server}</html>",
|
||||
index,
|
||||
false,
|
||||
cellHasFocus
|
||||
)
|
||||
}
|
||||
return super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val dialog = this
|
||||
val newAction = object : AnAction(I18n.getString("termora.welcome.contextmenu.new")) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
if (serverComboBox.selectedItem == singaporeServer || serverComboBox.selectedItem == chinaServer) {
|
||||
val c = NewServerDialog(dialog)
|
||||
c.isVisible = true
|
||||
val server = c.server ?: return
|
||||
serverComboBox.addItem(server)
|
||||
serverComboBox.selectedItem = server
|
||||
servers.add(server)
|
||||
properties.putString("login-servers", ohMyJson.encodeToString(servers))
|
||||
} else {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
dialog,
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
I18n.getString("termora.remove"),
|
||||
JOptionPane.YES_NO_OPTION,
|
||||
JOptionPane.QUESTION_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
val item = serverComboBox.selectedItem
|
||||
serverComboBox.removeItem(item)
|
||||
servers.removeIf { it == item }
|
||||
properties.putString("login-servers", ohMyJson.encodeToString(servers))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val newServer = JXHyperlink(newAction)
|
||||
newServer.isFocusable = false
|
||||
serverComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
if (serverComboBox.selectedItem == singaporeServer || serverComboBox.selectedItem == chinaServer) {
|
||||
newAction.name = I18n.getString("termora.welcome.contextmenu.new")
|
||||
} else {
|
||||
newAction.name = I18n.getString("termora.remove")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return FormBuilder.create().layout(layout).debug(false).padding("0dlu, $FORM_MARGIN, 0dlu, $FORM_MARGIN")
|
||||
.add("${I18n.getString("termora.settings.account.server")}:").xy(1, rows)
|
||||
.add(serverComboBox).xy(3, rows)
|
||||
.add(newServer).xy(5, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.account")}:").xy(1, rows)
|
||||
.add(usernameTextField).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, rows)
|
||||
.add(passwordField).xy(3, rows).apply { rows += step }
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun createOkAction(): AbstractAction {
|
||||
return okAction
|
||||
}
|
||||
|
||||
override fun createCancelAction(): AbstractAction {
|
||||
return cancelAction
|
||||
}
|
||||
|
||||
override fun createJButtonForAction(action: Action): JButton {
|
||||
if (action == cancelAction) {
|
||||
return cancelButton
|
||||
}
|
||||
return super.createJButtonForAction(action)
|
||||
}
|
||||
|
||||
private class NewServerDialog(owner: Window) : DialogWrapper(owner) {
|
||||
private val nameTextField = OutlineTextField(128)
|
||||
private val serverTextField = OutlineTextField(256)
|
||||
var server: Server? = null
|
||||
|
||||
init {
|
||||
isModal = true
|
||||
isResizable = false
|
||||
controlsVisible = false
|
||||
title = I18n.getString("termora.settings.account.new-server")
|
||||
init()
|
||||
pack()
|
||||
size = Dimension(max(preferredSize.width, UIManager.getInt("Dialog.width") - 320), preferredSize.height)
|
||||
setLocationRelativeTo(owner)
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, pref",
|
||||
"pref, $FORM_MARGIN, pref, $FORM_MARGIN"
|
||||
)
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
|
||||
val deploy = JXHyperlink(object : AnAction(I18n.getString("termora.settings.account.deploy-server")) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
Application.browse(URI.create("https://github.com/TermoraDev/termora-backend"))
|
||||
}
|
||||
})
|
||||
deploy.isFocusable = false
|
||||
|
||||
|
||||
return FormBuilder.create().layout(layout).debug(false).padding("0dlu, $FORM_MARGIN, 0dlu, $FORM_MARGIN")
|
||||
.add("${I18n.getString("termora.new-host.general.name")}:").xy(1, rows)
|
||||
.add(nameTextField).xyw(3, rows, 3).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.account.server")}:").xy(1, rows)
|
||||
.add(serverTextField).xy(3, rows)
|
||||
.add(deploy).xy(5, rows).apply { rows += step }
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
if (nameTextField.text.isBlank()) {
|
||||
nameTextField.outline = FlatClientProperties.OUTLINE_ERROR
|
||||
nameTextField.requestFocusInWindow()
|
||||
return
|
||||
}
|
||||
val uri = runCatching { URI.create(serverTextField.text) }.getOrNull()
|
||||
val isHttp = uri != null && StringUtils.equalsAnyIgnoreCase(uri.scheme, "http", "https")
|
||||
if (serverTextField.text.isBlank() || isHttp.not()) {
|
||||
serverTextField.outline = FlatClientProperties.OUTLINE_ERROR
|
||||
serverTextField.requestFocusInWindow()
|
||||
return
|
||||
}
|
||||
|
||||
server = Server(nameTextField.text.trim(), serverTextField.text.trim())
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
override fun doCancelAction() {
|
||||
server = null
|
||||
super.doCancelAction()
|
||||
}
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
if (isLoggingIn.get()) return
|
||||
|
||||
val server = serverComboBox.selectedItem as? Server ?: return
|
||||
|
||||
if (usernameTextField.text.isBlank()) {
|
||||
usernameTextField.outline = FlatClientProperties.OUTLINE_ERROR
|
||||
usernameTextField.requestFocusInWindow()
|
||||
return
|
||||
} else if (passwordField.password.isEmpty()) {
|
||||
passwordField.outline = FlatClientProperties.OUTLINE_ERROR
|
||||
passwordField.requestFocusInWindow()
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoggingIn.compareAndSet(false, true)) {
|
||||
okAction.isEnabled = false
|
||||
usernameTextField.isEnabled = false
|
||||
passwordField.isEnabled = false
|
||||
serverComboBox.isEnabled = false
|
||||
cancelButton.isVisible = false
|
||||
onLogin(server)
|
||||
return
|
||||
}
|
||||
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
private fun onLogin(server: Server) {
|
||||
val job = swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
var c = 0
|
||||
while (isActive) {
|
||||
if (++c > 3) c = 0
|
||||
okAction.name = I18n.getString("termora.settings.account.login") + ".".repeat(c)
|
||||
delay(350.milliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
val loginJob = swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
ServerManager.getInstance().login(server, usernameTextField.text, String(passwordField.password))
|
||||
withContext(Dispatchers.Swing) {
|
||||
super.doOKAction()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
this@LoginServerDialog,
|
||||
StringUtils.defaultIfBlank(
|
||||
e.message ?: StringUtils.EMPTY,
|
||||
I18n.getString("termora.settings.account.login-failed")
|
||||
),
|
||||
messageType = JOptionPane.ERROR_MESSAGE,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
job.cancel()
|
||||
withContext(Dispatchers.Swing) {
|
||||
okAction.name = I18n.getString("termora.settings.account.login")
|
||||
okAction.isEnabled = true
|
||||
usernameTextField.isEnabled = true
|
||||
passwordField.isEnabled = true
|
||||
serverComboBox.isEnabled = true
|
||||
cancelButton.isVisible = true
|
||||
}
|
||||
isLoggingIn.compareAndSet(true, false)
|
||||
}
|
||||
}
|
||||
|
||||
Disposer.register(disposable, object : Disposable {
|
||||
override fun dispose() {
|
||||
if (loginJob.isActive)
|
||||
loginJob.cancel()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun doCancelAction() {
|
||||
if (isLoggingIn.get()) return
|
||||
super.doCancelAction()
|
||||
}
|
||||
}
|
||||
417
src/main/kotlin/app/termora/account/PullService.kt
Normal file
417
src/main/kotlin/app/termora/account/PullService.kt
Normal file
@@ -0,0 +1,417 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.database.Data
|
||||
import app.termora.database.DatabaseChangedExtension
|
||||
import app.termora.plugin.internal.extension.DynamicExtensionHandler
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import okhttp3.Request
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* 同步服务
|
||||
*/
|
||||
@Suppress("LoggingSimilarMessage", "DuplicatedCode")
|
||||
class PullService private constructor() : SyncService(), Disposable, ApplicationRunnerExtension {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(PullService::class.java)
|
||||
fun getInstance(): PullService {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(PullService::class) { PullService() }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
Disposer.register(this, DynamicExtensionHandler.getInstance().register(AccountExtension::class.java, object :
|
||||
AccountExtension {
|
||||
override fun onAccountChanged(oldAccount: Account, newAccount: Account) {
|
||||
// 账号变更后重制hash
|
||||
if (oldAccount.id != newAccount.id && newAccount.isLocally.not()) {
|
||||
lastChangeHash = StringUtils.EMPTY
|
||||
}
|
||||
|
||||
if (oldAccount.isLocally && newAccount.isLocally.not()) {
|
||||
trigger()
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
private val channel = Channel<Unit>(Channel.CONFLATED)
|
||||
private val accountProperties get() = AccountProperties.getInstance()
|
||||
private val pulling = AtomicBoolean(false)
|
||||
private var lastChangeHash = StringUtils.EMPTY
|
||||
|
||||
private fun pullChanges() {
|
||||
if (isFreePlan) return
|
||||
val hash: String
|
||||
|
||||
try {
|
||||
hash = getChangeHash()
|
||||
if (hash.isBlank()) return
|
||||
|
||||
if (hash == lastChangeHash) {
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("没有数据变动")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug(e.message, e)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pulling.compareAndSet(false, true).not()) {
|
||||
return
|
||||
}
|
||||
|
||||
var count = 0
|
||||
try {
|
||||
PullServiceExtension.firePullStarted()
|
||||
count = doPullChanges()
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
if (hash.isNotBlank()) {
|
||||
lastChangeHash = hash
|
||||
}
|
||||
|
||||
pulling.set(false)
|
||||
|
||||
PullServiceExtension.firePullFinished(count)
|
||||
}
|
||||
|
||||
private fun doPullChanges(): Int {
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("即将从云端拉取变更")
|
||||
}
|
||||
|
||||
val since = accountProperties.nextSynchronizationSince
|
||||
var after = StringUtils.EMPTY
|
||||
var nextSince = since
|
||||
val limit = 100
|
||||
var count = 0
|
||||
|
||||
while (true) {
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url("${accountManager.getServer()}/v1/data/changes?since=${since}&after=${after}&limit=${limit}")
|
||||
.build()
|
||||
val text = AccountHttp.execute(request = request)
|
||||
val response = ohMyJson.decodeFromString<DataChangesResponse>(text)
|
||||
if (response.changes.isEmpty()) break
|
||||
|
||||
for (e in response.changes) {
|
||||
if (tryPull(e)) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
after = response.after
|
||||
nextSince = response.since
|
||||
if (response.changes.size < limit) break
|
||||
}
|
||||
|
||||
accountProperties.nextSynchronizationSince = nextSince
|
||||
accountProperties.lastSynchronizationOn = System.currentTimeMillis()
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("从云端拉取变更结束,变更条数: {}", count)
|
||||
}
|
||||
|
||||
return count
|
||||
|
||||
}
|
||||
|
||||
private fun tryPull(e: DataChange): Boolean {
|
||||
val data = getData(e.objectId)
|
||||
// 如果本地不存在,并且云端已经删除,那么不需要处理
|
||||
if (data == null && e.deleted) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果云端与本地都已经删除,那么不需要处理
|
||||
if (data != null && e.deleted && data.deleted) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (data == null || data.version != e.version || e.deleted != data.deleted) {
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug(
|
||||
"数据: {}, 本地版本: {}, 云端版本: {} 触发同步",
|
||||
e.objectId,
|
||||
data?.version ?: "不存在",
|
||||
e.version
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
if (pull(e.objectId, e.ownerId, e.ownerType) == PullResult.Changed) {
|
||||
return true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
} else if (log.isDebugEnabled) {
|
||||
log.debug("数据: {} 本地版本与云端版本一致", e.objectId)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getChangeHash(): String {
|
||||
val request = Request.Builder()
|
||||
.head()
|
||||
.url("${accountManager.getServer()}/v1/data/changes")
|
||||
.build()
|
||||
val response = AccountHttp.client.newCall(request).execute().apply { close() }
|
||||
if (response.isSuccessful.not()) {
|
||||
IOUtils.closeQuietly(response)
|
||||
throw ResponseException(response.code, response)
|
||||
}
|
||||
return response.header("X-Hash") ?: StringUtils.EMPTY
|
||||
}
|
||||
|
||||
|
||||
private fun pull(id: String, ownerId: String, ownerType: String): PullResult {
|
||||
return syncLock.withLock { doPull(id, ownerId, ownerType) }
|
||||
}
|
||||
|
||||
private fun doPull(id: String, ownerId: String, ownerType: String): PullResult {
|
||||
val request = Request.Builder()
|
||||
.url("${accountManager.getServer()}/v1/data/${id}?ownerId=${ownerId}&ownerType=${ownerType}")
|
||||
.get()
|
||||
.build()
|
||||
val response = AccountHttp.client.newCall(request).execute()
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("拉取数据: {} 成功, 响应码: {}", id, response.code)
|
||||
}
|
||||
|
||||
if(response.isSuccessful.not()){
|
||||
IOUtils.closeQuietly(response)
|
||||
}
|
||||
|
||||
// 云端数据不存在直接返回
|
||||
if (response.code == 404) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("数据: {} 云端不存在,本地数据将会删除", id)
|
||||
}
|
||||
// 云端数据不存在,那么本地也要删除
|
||||
updateData(id, synced = true, deleted = true)
|
||||
return PullResult.Changed
|
||||
}
|
||||
|
||||
// 没有权限拉取
|
||||
if (response.code == 403) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("数据: {} 没有权限拉取,本地数据将会删除", id)
|
||||
}
|
||||
updateData(id, synced = true, deleted = true)
|
||||
return PullResult.Changed
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
if (response.isSuccessful.not()) {
|
||||
throw ResponseException(response.code, response)
|
||||
}
|
||||
|
||||
val text = response.use { response.body.use { it?.string() } }
|
||||
if (text.isNullOrBlank()) {
|
||||
throw ResponseException(response.code, "response body is empty", response)
|
||||
}
|
||||
|
||||
val json = ohMyJson.decodeFromString<JsonObject>(text)
|
||||
val id = json["id"]?.jsonPrimitive?.content
|
||||
val ownerId = json["ownerId"]?.jsonPrimitive?.content
|
||||
val ownerType = json["ownerType"]?.jsonPrimitive?.content
|
||||
val data = json["data"]?.jsonPrimitive?.content
|
||||
val type = json["type"]?.jsonPrimitive?.content
|
||||
val version = json["version"]?.jsonPrimitive?.long
|
||||
val deleted = json["deleted"]?.jsonPrimitive?.boolean
|
||||
if (id == null || ownerId == null || ownerType == null || data == null || version == null || deleted == null || type == null) {
|
||||
throw IllegalStateException("Data.id $id data error")
|
||||
}
|
||||
|
||||
val row = getData(id)
|
||||
|
||||
// 如果本地不存在,
|
||||
if (row == null) {
|
||||
// 云端已经删除,那么忽略
|
||||
if (deleted) {
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("数据: {}, 类型: {} 云端已经删除,本地也不存在", id, type)
|
||||
}
|
||||
return PullResult.Nothing
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("数据: {}, 类型: {} 从云端拉取成功,即将保存到本地", id, type)
|
||||
}
|
||||
|
||||
// 保存到本地
|
||||
databaseManager.save(
|
||||
Data(
|
||||
id = id,
|
||||
ownerId = ownerId,
|
||||
ownerType = ownerType,
|
||||
type = type,
|
||||
data = decryptData(id, data),
|
||||
version = version,
|
||||
// 因为已经是拉取最新版本了,所以这里无需再同步了
|
||||
synced = true,
|
||||
deleted = false
|
||||
),
|
||||
DatabaseChangedExtension.Source.Sync
|
||||
)
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("数据: {}, 类型: {} 已保存至本地", id, type)
|
||||
}
|
||||
|
||||
} else if (deleted && row.deleted.not()) { // 如果本地存在,云端已经删除,那么本地删除
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("数据: {}, 类型: {} 云端已经删除,本地即将删除", id, type)
|
||||
}
|
||||
databaseManager.delete(
|
||||
id, type,
|
||||
DatabaseChangedExtension.Source.Sync
|
||||
)
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("数据: {}, 类型: {} 已从本地删除", id, type)
|
||||
|
||||
}
|
||||
} else if (row.version > version) { // 如果本地版本大于云端版本,那么忽略,因为需要推送到云端
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn(
|
||||
"数据: {}, 类型: {}, 本地版本: {}, 云端版本: {}, 本地版本高于云端版本,即将将本地数据推送至云端",
|
||||
id,
|
||||
type,
|
||||
row.version,
|
||||
version
|
||||
)
|
||||
}
|
||||
// 如果没有同步标识,那么修改为代同步
|
||||
if (row.synced.not()) {
|
||||
updateData(id, false, row.version)
|
||||
}
|
||||
} else if (row.version < version) { // 本地版本小于云端版本,那么立即修改
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug(
|
||||
"数据: {}, 类型: {}, 本地版本: {}, 云端版本: {}, 本地版本小于云端版本,即将保存到本地",
|
||||
id,
|
||||
type,
|
||||
row.version,
|
||||
version
|
||||
)
|
||||
}
|
||||
|
||||
// 解密数据
|
||||
databaseManager.save(
|
||||
Data(
|
||||
id = id,
|
||||
ownerId = ownerId,
|
||||
ownerType = ownerType,
|
||||
type = type,
|
||||
data = decryptData(id, data),
|
||||
version = version,
|
||||
// 因为已经是拉取最新版本了,所以这里无需再同步了
|
||||
synced = true,
|
||||
deleted = false
|
||||
),
|
||||
DatabaseChangedExtension.Source.Sync
|
||||
)
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("数据: {}, 类型: {} 已更新至最新版", id, type)
|
||||
}
|
||||
|
||||
} else {
|
||||
return PullResult.Nothing
|
||||
}
|
||||
|
||||
return PullResult.Changed
|
||||
}
|
||||
|
||||
fun trigger() {
|
||||
channel.trySend(Unit).isSuccess
|
||||
}
|
||||
|
||||
|
||||
override fun ready() {
|
||||
// 定时同步
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
// 等一会儿再同步
|
||||
delay(Random.nextInt(500, 1500).milliseconds)
|
||||
|
||||
while (isActive) {
|
||||
|
||||
// 拉取变动的
|
||||
pullChanges()
|
||||
|
||||
// N 秒拉一次
|
||||
val result = withTimeoutOrNull(Random.nextInt(5, 15).seconds) {
|
||||
channel.receiveCatching()
|
||||
} ?: continue
|
||||
if (result.isFailure) break
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Disposer.register(this, object : Disposable {
|
||||
override fun dispose() {
|
||||
channel.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private enum class PullResult {
|
||||
Nothing,
|
||||
Changed
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class DataChangesResponse(
|
||||
val after: String,
|
||||
val since: Long,
|
||||
val changes: List<DataChange>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class DataChange(
|
||||
val objectId: String,
|
||||
val version: Long,
|
||||
val deleted: Boolean,
|
||||
val ownerId: String,
|
||||
val ownerType: String,
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
43
src/main/kotlin/app/termora/account/PullServiceExtension.kt
Normal file
43
src/main/kotlin/app/termora/account/PullServiceExtension.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.database.DatabaseManager.Companion.log
|
||||
import app.termora.plugin.Extension
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
interface PullServiceExtension : Extension {
|
||||
|
||||
companion object {
|
||||
fun firePullStarted() {
|
||||
fire { it.onPullStarted() }
|
||||
}
|
||||
|
||||
fun firePullFinished(count: Int) {
|
||||
fire { it.onPullFinished(count) }
|
||||
}
|
||||
|
||||
private fun fire(invoker: (PullServiceExtension) -> Unit) {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
for (extension in ExtensionManager.getInstance().getExtensions(PullServiceExtension::class.java)) {
|
||||
try {
|
||||
invoker.invoke(extension)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SwingUtilities.invokeLater { fire(invoker) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onPullStarted() {}
|
||||
|
||||
/**
|
||||
* 同步了多少条数据
|
||||
*/
|
||||
fun onPullFinished(count: Int) {}
|
||||
|
||||
}
|
||||
229
src/main/kotlin/app/termora/account/PushService.kt
Normal file
229
src/main/kotlin/app/termora/account/PushService.kt
Normal file
@@ -0,0 +1,229 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.database.Data
|
||||
import app.termora.database.DatabaseChangedExtension
|
||||
import app.termora.plugin.DispatchThread
|
||||
import app.termora.plugin.internal.extension.DynamicExtensionHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.slf4j.LoggerFactory
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* 同步服务
|
||||
*/
|
||||
@Suppress("LoggingSimilarMessage", "DuplicatedCode")
|
||||
class PushService private constructor() : SyncService(), Disposable, ApplicationRunnerExtension,
|
||||
DatabaseChangedExtension {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(PushService::class.java)
|
||||
|
||||
fun getInstance(): PushService {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(PushService::class) { PushService() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
Disposer.register(this, DynamicExtensionHandler.getInstance().register(AccountExtension::class.java, object :
|
||||
AccountExtension {
|
||||
override fun onAccountChanged(oldAccount: Account, newAccount: Account) {
|
||||
if (oldAccount.isLocally && newAccount.isLocally.not()) {
|
||||
trigger()
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 多次通知只会生效一次 也就是最后一次
|
||||
*/
|
||||
private val channel = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
|
||||
private suspend fun schedule() {
|
||||
try {
|
||||
if (channel.receiveCatching().isSuccess) {
|
||||
synchronize()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun synchronize() {
|
||||
// 免费方案没有同步
|
||||
if (isFreePlan) return
|
||||
|
||||
// 同步
|
||||
val list = getUnsyncedData()
|
||||
for (data in list) {
|
||||
try {
|
||||
synchronize(data)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun getUnsyncedData(): List<Data> {
|
||||
val ownerIds = accountManager.getOwnerIds()
|
||||
return databaseManager.unsyncedData().filter { ownerIds.contains(it.ownerId) }
|
||||
.filterNot { AccountManager.isLocally(it.ownerId) }
|
||||
}
|
||||
|
||||
|
||||
private fun synchronize(data: Data) {
|
||||
syncLock.withLock { if (data.deleted) delete(data) else push(data) }
|
||||
}
|
||||
|
||||
private fun delete(data: Data) {
|
||||
val request = Request.Builder()
|
||||
.url("${accountManager.getServer()}/v1/data/${data.id}?ownerId=${data.ownerId}&ownerType=${data.ownerType}")
|
||||
.delete()
|
||||
.build()
|
||||
|
||||
AccountHttp.execute(request = request)
|
||||
|
||||
// 修改为已经同步
|
||||
updateData(data.id, synced = true)
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("数据: {} 已从云端删除", data.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun push(data: Data) {
|
||||
val requestData = PushDataRequest(
|
||||
objectId = data.id,
|
||||
ownerId = data.ownerId,
|
||||
ownerType = data.ownerType,
|
||||
version = data.version,
|
||||
type = data.type,
|
||||
data = encryptData(data.id, data.data),
|
||||
)
|
||||
|
||||
val request = Request.Builder().url("${accountManager.getServer()}/v1/data/push")
|
||||
.post(ohMyJson.encodeToString(requestData).toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
val response = AccountHttp.client.newCall(request).execute()
|
||||
|
||||
val text = response.use { response.body.use { it?.string() } }
|
||||
if (text.isNullOrBlank()) {
|
||||
throw ResponseException(response.code, "response body is empty", response)
|
||||
}
|
||||
|
||||
// 如果是 403 ,说明没有权限
|
||||
if (response.code == 403) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("数据: {} 没有权限推送到云端,此数据将在下次修改时推送", data.id)
|
||||
}
|
||||
// 标记为已经同步
|
||||
updateData(data.id, synced = true, version = data.version)
|
||||
return
|
||||
} else if (response.code == 409) { // 版本冲突,一般来说是云端版本大于本地版本
|
||||
val json = ohMyJson.decodeFromString<JsonObject>(text)
|
||||
// 最新版
|
||||
val version = json["data"]?.jsonObject?.get("version")?.jsonPrimitive?.long
|
||||
if (version == null) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("数据: {} 推送时版本冲突,触发拉取", data.id)
|
||||
}
|
||||
}
|
||||
PullService.getInstance().trigger()
|
||||
return
|
||||
}
|
||||
|
||||
// 没有错误就是推送成功了
|
||||
if (response.isSuccessful.not()) {
|
||||
throw ResponseException(response.code, response)
|
||||
}
|
||||
|
||||
// 获取到响应体
|
||||
val json = ohMyJson.decodeFromString<JsonObject>(text)
|
||||
val version = json["version"]?.jsonPrimitive?.long ?: data.version
|
||||
|
||||
// 修改为已经同步,并且将版本改为云端版本
|
||||
updateData(data.id, synced = true, version = version)
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("数据: {} 已推送至云端", data.id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun ready() {
|
||||
|
||||
// 同步
|
||||
swingCoroutineScope.launch(Dispatchers.IO) { while (isActive) schedule() }
|
||||
|
||||
// 定时同步
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
delay(10.seconds)
|
||||
while (isActive) {
|
||||
// 发送同步
|
||||
channel.send(Unit)
|
||||
// 每 1 分钟尝试同步一次,除非收到数据变动通知
|
||||
delay(1.minutes)
|
||||
}
|
||||
}
|
||||
|
||||
Disposer.register(this, object : Disposable {
|
||||
override fun dispose() {
|
||||
channel.close()
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
override fun onDataChanged(
|
||||
id: String,
|
||||
type: String,
|
||||
action: DatabaseChangedExtension.Action,
|
||||
source: DatabaseChangedExtension.Source
|
||||
) {
|
||||
if (source == DatabaseChangedExtension.Source.User) trigger()
|
||||
}
|
||||
|
||||
override fun getDispatchThread(): DispatchThread {
|
||||
return DispatchThread.BGT
|
||||
}
|
||||
|
||||
fun trigger() {
|
||||
channel.trySend(Unit).isSuccess
|
||||
}
|
||||
|
||||
|
||||
@Serializable
|
||||
private data class PushDataRequest(
|
||||
val objectId: String,
|
||||
val ownerId: String,
|
||||
val ownerType: String,
|
||||
val version: Long,
|
||||
val type: String,
|
||||
val data: String
|
||||
)
|
||||
}
|
||||
6
src/main/kotlin/app/termora/account/Server.kt
Normal file
6
src/main/kotlin/app/termora/account/Server.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
package app.termora.account
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Server(val name: String, val server: String)
|
||||
165
src/main/kotlin/app/termora/account/ServerManager.kt
Normal file
165
src/main/kotlin/app/termora/account/ServerManager.kt
Normal file
@@ -0,0 +1,165 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.Application.ohMyJson
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.apache.commons.codec.binary.Base64
|
||||
import org.apache.commons.codec.digest.DigestUtils
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class ServerManager private constructor() {
|
||||
companion object {
|
||||
fun getInstance(): ServerManager {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(ServerManager::class) { ServerManager() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private val isLoggingIn = AtomicBoolean(false)
|
||||
private val accountManager get() = AccountManager.getInstance()
|
||||
|
||||
|
||||
/**
|
||||
* 登录,不报错就是登录成功
|
||||
*/
|
||||
fun login(server: Server, username: String, password: String) {
|
||||
|
||||
if (accountManager.isLocally().not()) {
|
||||
throw IllegalStateException("Already logged in")
|
||||
}
|
||||
|
||||
if (isLoggingIn.compareAndSet(false, true).not()) {
|
||||
throw IllegalStateException("Logging in")
|
||||
}
|
||||
|
||||
try {
|
||||
doLogin(server, username, password)
|
||||
} finally {
|
||||
isLoggingIn.compareAndSet(true, false)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun doLogin(server: Server, username: String, password: String) {
|
||||
// 服务器信息
|
||||
val serverInfo = getServerInfo(server)
|
||||
|
||||
// call login
|
||||
val loginResponse = callLogin(serverInfo, server, username, password)
|
||||
|
||||
// call me
|
||||
val meResponse = callMe(server, loginResponse.accessToken)
|
||||
|
||||
// 解密
|
||||
val salt = "${serverInfo.salt}:${username}".toByteArray()
|
||||
val privateKeySecureKey = PBKDF2.hash(salt, username.toCharArray(), 1024, 256)
|
||||
val privateKeySecureIv = PBKDF2.hash(salt, username.toCharArray(), 1024, 128)
|
||||
val privateKeyEncoded = AES.CBC.decrypt(
|
||||
privateKeySecureKey, privateKeySecureIv,
|
||||
Base64.decodeBase64(meResponse.privateKey)
|
||||
)
|
||||
val privateKey = RSA.generatePrivate(privateKeyEncoded)
|
||||
val publicKey = RSA.generatePublic(Base64.decodeBase64(meResponse.publicKey))
|
||||
val secretKey = RSA.decrypt(privateKey, Base64.decodeBase64(meResponse.secretKey))
|
||||
|
||||
val teams = mutableListOf<Team>()
|
||||
for (team in meResponse.teams) {
|
||||
teams.add(
|
||||
Team(
|
||||
id = team.id,
|
||||
name = team.name,
|
||||
secretKey = RSA.decrypt(privateKey, Base64.decodeBase64(team.secretKey)),
|
||||
role = team.role
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 登录成功
|
||||
accountManager.login(
|
||||
Account(
|
||||
id = meResponse.id,
|
||||
server = server.server,
|
||||
email = meResponse.email,
|
||||
teams = teams,
|
||||
subscriptions = meResponse.subscriptions,
|
||||
accessToken = loginResponse.accessToken,
|
||||
refreshToken = loginResponse.refreshToken,
|
||||
secretKey = secretKey,
|
||||
publicKey = publicKey,
|
||||
privateKey = privateKey,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getServerInfo(server: Server): ServerInfo {
|
||||
val request = Request.Builder()
|
||||
.url("${server.server}/v1/client/system")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return ohMyJson.decodeFromString<ServerInfo>(AccountHttp.execute(request = request))
|
||||
}
|
||||
|
||||
private fun callLogin(serverInfo: ServerInfo, server: Server, username: String, password: String): LoginResponse {
|
||||
|
||||
val passwordHex = DigestUtils.sha256Hex("${serverInfo.salt}:${username}:${password}")
|
||||
val requestBody = ohMyJson.encodeToString(mapOf("email" to username, "password" to passwordHex))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${server.server}/v1/login")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val response = AccountHttp.client.newCall(request).execute()
|
||||
val text = response.use { response.body.use { it?.string() } }
|
||||
|
||||
if (text == null) {
|
||||
throw ResponseException(response.code, response)
|
||||
}
|
||||
|
||||
if (response.isSuccessful.not()) {
|
||||
val message = ohMyJson.parseToJsonElement(text).jsonObject["message"]?.jsonPrimitive?.content
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
|
||||
return ohMyJson.decodeFromString<LoginResponse>(text)
|
||||
}
|
||||
|
||||
|
||||
private fun callMe(server: Server, accessToken: String): MeResponse {
|
||||
val request = Request.Builder()
|
||||
.url("${server.server}/v1/users/me")
|
||||
.header("Authorization", "Bearer $accessToken")
|
||||
.build()
|
||||
val text = AccountHttp.execute(request = request)
|
||||
return ohMyJson.decodeFromString<MeResponse>(text)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ServerInfo(val salt: String)
|
||||
|
||||
@Serializable
|
||||
private data class LoginResponse(val accessToken: String, val refreshToken: String)
|
||||
|
||||
@Serializable
|
||||
private data class MeResponse(
|
||||
val id: String,
|
||||
val email: String,
|
||||
val publicKey: String,
|
||||
val privateKey: String,
|
||||
val secretKey: String,
|
||||
val teams: List<MeTeam>,
|
||||
val subscriptions: List<Subscription>,
|
||||
)
|
||||
|
||||
|
||||
@Serializable
|
||||
private data class MeTeam(val id: String, val name: String, val role: TeamRole, val secretKey: String)
|
||||
}
|
||||
10
src/main/kotlin/app/termora/account/ServerSignedExtension.kt
Normal file
10
src/main/kotlin/app/termora/account/ServerSignedExtension.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.plugin.Extension
|
||||
|
||||
interface ServerSignedExtension : Extension {
|
||||
/**
|
||||
* 签名发生变化
|
||||
*/
|
||||
fun onSignedChanged(oldSigned: Boolean, newSigned: Boolean)
|
||||
}
|
||||
11
src/main/kotlin/app/termora/account/Subscription.kt
Normal file
11
src/main/kotlin/app/termora/account/Subscription.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package app.termora.account
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Subscription(
|
||||
val id: String,
|
||||
val plan: SubscriptionPlan,
|
||||
val startAt: Long,
|
||||
val endAt: Long,
|
||||
)
|
||||
8
src/main/kotlin/app/termora/account/SubscriptionPlan.kt
Normal file
8
src/main/kotlin/app/termora/account/SubscriptionPlan.kt
Normal file
@@ -0,0 +1,8 @@
|
||||
package app.termora.account
|
||||
|
||||
enum class SubscriptionPlan {
|
||||
Free,
|
||||
Pro,
|
||||
Team,
|
||||
Enterprise
|
||||
}
|
||||
97
src/main/kotlin/app/termora/account/SyncService.kt
Normal file
97
src/main/kotlin/app/termora/account/SyncService.kt
Normal file
@@ -0,0 +1,97 @@
|
||||
package app.termora.account
|
||||
|
||||
import app.termora.AES
|
||||
import app.termora.database.*
|
||||
import app.termora.database.Data.Companion.toData
|
||||
import org.apache.commons.codec.binary.Base64
|
||||
import org.apache.commons.codec.digest.DigestUtils
|
||||
import org.apache.commons.lang3.ObjectUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
abstract class SyncService {
|
||||
|
||||
companion object {
|
||||
private val syncLock = ReentrantLock()
|
||||
}
|
||||
|
||||
protected val databaseManager get() = DatabaseManager.getInstance()
|
||||
private val database get() = databaseManager.database
|
||||
private val databaseLock get() = databaseManager.lock
|
||||
protected val accountManager get() = AccountManager.getInstance()
|
||||
protected val isFreePlan get() = accountManager.isFreePlan()
|
||||
|
||||
/**
|
||||
* 同一时刻,要么拉取 要么推送
|
||||
*/
|
||||
protected val syncLock get() = Companion.syncLock
|
||||
|
||||
protected fun getData(id: String): Data? {
|
||||
val list = mutableListOf<Data>()
|
||||
databaseLock.withLock {
|
||||
transaction(database) {
|
||||
val rows = DataEntity.selectAll().where { (DataEntity.id.eq(id)) }.toList()
|
||||
for (row in rows) {
|
||||
list.add(row.toData())
|
||||
}
|
||||
}
|
||||
}
|
||||
return list.firstOrNull()
|
||||
}
|
||||
|
||||
|
||||
protected fun updateData(
|
||||
id: String,
|
||||
synced: Boolean? = null,
|
||||
version: Long? = null,
|
||||
deleted: Boolean? = null,
|
||||
) {
|
||||
if (ObjectUtils.allNull(version, deleted, synced)) return
|
||||
|
||||
databaseLock.withLock {
|
||||
transaction(database) {
|
||||
DataEntity.update({ DataEntity.id.eq(id) }) {
|
||||
if (version != null) it[DataEntity.version] = version
|
||||
if (synced != null) it[DataEntity.synced] = synced
|
||||
if (deleted != null) {
|
||||
it[DataEntity.deleted] = deleted
|
||||
it[DataEntity.data] = StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 触发更改
|
||||
if (this is PullService) {
|
||||
DatabaseChangedExtension.fireDataChanged(
|
||||
id, DataType.Host.name,
|
||||
DatabaseChangedExtension.Action.Changed,
|
||||
DatabaseChangedExtension.Source.Sync
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun encryptData(id: String, data: String): String {
|
||||
val iv = DigestUtils.sha256(id).copyOf(12)
|
||||
return Base64.encodeBase64String(
|
||||
AES.GCM.encrypt(
|
||||
accountManager.getSecretKey(), iv,
|
||||
data.toByteArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
protected fun decryptData(id: String, data: String): String {
|
||||
val iv = DigestUtils.sha256(id).copyOf(12)
|
||||
return String(
|
||||
AES.GCM.decrypt(
|
||||
accountManager.getSecretKey(), iv,
|
||||
Base64.decodeBase64(data)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
29
src/main/kotlin/app/termora/account/Team.kt
Normal file
29
src/main/kotlin/app/termora/account/Team.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
package app.termora.account
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 团队
|
||||
*/
|
||||
@Serializable
|
||||
class Team(
|
||||
/**
|
||||
* ID
|
||||
*/
|
||||
val id: String,
|
||||
|
||||
/**
|
||||
* 团队名称
|
||||
*/
|
||||
val name: String,
|
||||
|
||||
/**
|
||||
* 团队密钥,用于解密团队数据
|
||||
*/
|
||||
val secretKey: ByteArray,
|
||||
|
||||
/**
|
||||
* 所属角色
|
||||
*/
|
||||
val role: TeamRole,
|
||||
)
|
||||
6
src/main/kotlin/app/termora/account/TeamRole.kt
Normal file
6
src/main/kotlin/app/termora/account/TeamRole.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
package app.termora.account
|
||||
|
||||
enum class TeamRole {
|
||||
Member,
|
||||
Owner,
|
||||
}
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -15,6 +14,7 @@ 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.semver4j.Semver
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Dimension
|
||||
import java.awt.KeyboardFocusManager
|
||||
@@ -28,7 +28,6 @@ import javax.swing.JOptionPane
|
||||
import javax.swing.JScrollPane
|
||||
import javax.swing.UIManager
|
||||
import javax.swing.event.HyperlinkEvent
|
||||
import kotlin.concurrent.fixedRateTimer
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@@ -61,29 +60,46 @@ class AppUpdateAction private constructor() : AnAction(
|
||||
|
||||
|
||||
private fun scheduleUpdate() {
|
||||
fixedRateTimer(
|
||||
name = "check-update-timer",
|
||||
initialDelay = 3.minutes.inWholeMilliseconds,
|
||||
period = 5.hours.inWholeMilliseconds, daemon = true
|
||||
) {
|
||||
if (!isRemindMeNextTime) {
|
||||
coroutineScope.launch(Dispatchers.IO) { checkUpdate() }
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
// 启动 3 分钟后才是检查
|
||||
if (Application.isUnknownVersion().not()) {
|
||||
delay(3.minutes)
|
||||
}
|
||||
|
||||
while (coroutineScope.isActive) {
|
||||
// 下次提醒我
|
||||
if (isRemindMeNextTime) break
|
||||
|
||||
try {
|
||||
checkUpdate()
|
||||
} catch (e: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
// 之后每 3 小时检查一次
|
||||
delay(3.hours.inWholeMilliseconds)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkUpdate() {
|
||||
if (Application.isUnknownVersion()) {
|
||||
return
|
||||
}
|
||||
|
||||
val latestVersion = updaterManager.fetchLatestVersion()
|
||||
if (latestVersion.isSelf) {
|
||||
return
|
||||
}
|
||||
|
||||
val newVersion = Version(latestVersion.version)
|
||||
val version = Version(Application.getVersion())
|
||||
// 之所以放到后面检查是不是开发版本,是需要发起一次检测请求,以方便调试
|
||||
if (Application.isUnknownVersion()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val newVersion = Semver.parse(latestVersion.version) ?: return
|
||||
val version = Semver.parse(Application.getVersion()) ?: return
|
||||
if (newVersion <= version) {
|
||||
return
|
||||
}
|
||||
@@ -120,7 +136,7 @@ class AppUpdateAction private constructor() : AnAction(
|
||||
.build()
|
||||
.newCall(Request.Builder().url(asset.downloadUrl).build())
|
||||
.execute()
|
||||
if (!response.isSuccessful) {
|
||||
if (response.isSuccessful.not()) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.warn("Failed to download latest version ${latestVersion.version}, response code ${response.code}")
|
||||
}
|
||||
@@ -227,7 +243,7 @@ class AppUpdateAction private constructor() : AnAction(
|
||||
log.info("restart {}", commands.joinToString(StringUtils.SPACE))
|
||||
}
|
||||
|
||||
TermoraRestarter.getInstance().scheduleRestart(owner, commands)
|
||||
TermoraRestarter.getInstance().scheduleRestart(owner, true, commands)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.tree.NewHostTree
|
||||
|
||||
object DataProviders {
|
||||
val TerminalPanel = DataKey(app.termora.terminal.panel.TerminalPanel::class)
|
||||
@@ -17,6 +18,6 @@ object DataProviders {
|
||||
|
||||
|
||||
object Welcome {
|
||||
val HostTree = DataKey(app.termora.NewHostTree::class)
|
||||
val HostTree = DataKey(NewHostTree::class)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.NewHostDialogV2
|
||||
import app.termora.tree.FilterableHostTreeModel
|
||||
import app.termora.tree.HostTreeNode
|
||||
import app.termora.tree.NewHostTreeModel
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
class NewHostAction : AnAction() {
|
||||
@@ -13,24 +16,29 @@ class NewHostAction : AnAction() {
|
||||
|
||||
}
|
||||
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val tree = evt.getData(DataProviders.Welcome.HostTree) ?: return
|
||||
var lastNode = (tree.lastSelectedPathComponent ?: tree.model.root) as? HostTreeNode ?: return
|
||||
if (lastNode.host.protocol != Protocol.Folder) {
|
||||
if (lastNode.host.isFolder.not()) {
|
||||
lastNode = lastNode.parent ?: return
|
||||
}
|
||||
|
||||
// Root 不可以添加,如果是 Root 那么加到用户下
|
||||
if (lastNode == tree.model.root) {
|
||||
lastNode = lastNode.childrenNode().firstOrNull { it.id == "0" || it.id.isBlank() } ?: return
|
||||
}
|
||||
|
||||
val lastHost = lastNode.host
|
||||
val dialog = HostDialog(evt.window)
|
||||
val dialog = NewHostDialogV2(evt.window)
|
||||
dialog.setLocationRelativeTo(evt.window)
|
||||
dialog.isVisible = true
|
||||
val host = (dialog.host ?: return).copy(parentId = lastHost.id)
|
||||
val host = (dialog.host ?: return).copy(
|
||||
parentId = lastHost.id,
|
||||
ownerId = lastHost.ownerId,
|
||||
ownerType = lastHost.ownerType
|
||||
)
|
||||
|
||||
hostManager.addHost(host)
|
||||
val newNode = HostTreeNode(host)
|
||||
|
||||
val model = if (tree.model is FilterableHostTreeModel) (tree.model as FilterableHostTreeModel).getModel()
|
||||
else tree.model
|
||||
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.*
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.IOUtils
|
||||
import app.termora.protocol.GenericProtocolProvider
|
||||
import app.termora.protocol.ProtocolProvider
|
||||
import app.termora.sftp.SFTPActionEvent
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.net.URI
|
||||
import java.util.*
|
||||
import javax.swing.JOptionPane
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class OpenHostAction : AnAction() {
|
||||
companion object {
|
||||
@@ -23,86 +15,53 @@ class OpenHostAction : AnAction() {
|
||||
const val OPEN_HOST = "OpenHostAction"
|
||||
}
|
||||
|
||||
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
if (evt !is OpenHostActionEvent) return
|
||||
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
|
||||
val windowScope = evt.getData(DataProviders.WindowScope) ?: return
|
||||
val host = evt.host
|
||||
|
||||
// 如果不支持 SFTP 那么不处理这个响应
|
||||
if (evt.host.protocol == Protocol.SFTPPty) {
|
||||
if (!SFTPPtyTerminalTab.canSupports) {
|
||||
return
|
||||
}
|
||||
}
|
||||
var tab: TerminalTab? = null
|
||||
var providers = ProtocolProvider.providers
|
||||
|
||||
val tab = when (evt.host.protocol) {
|
||||
Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
|
||||
Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
|
||||
Protocol.SFTPPty -> SFTPPtyTerminalTab(windowScope, evt.host)
|
||||
Protocol.RDP -> openRDP(windowScope, evt.host)
|
||||
else -> LocalTerminalTab(windowScope, evt.host)
|
||||
}
|
||||
|
||||
if (tab is TerminalTab) {
|
||||
terminalTabbedManager.addTerminalTab(tab)
|
||||
if (tab is PtyHostTerminalTab) {
|
||||
tab.start()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun openRDP(windowScope: WindowScope, host: Host) {
|
||||
if (SystemInfo.isLinux) {
|
||||
if (providers.none { StringUtils.equalsIgnoreCase(it.getProtocol(), host.protocol) }) {
|
||||
OptionPane.showMessageDialog(
|
||||
windowScope.window,
|
||||
"Linux cannot connect to Windows Remote Server, Supported only for macOS and Windows",
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
"Protocol ${host.protocol} not supported",
|
||||
messageType = JOptionPane.ERROR_MESSAGE,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (SystemInfo.isMacOS) {
|
||||
if (!FileUtils.getFile("/Applications/Windows App.app").exists()) {
|
||||
val option = OptionPane.showConfirmDialog(
|
||||
windowScope.window,
|
||||
"If you want to connect to a Windows Remote Server, You have to install the Windows App",
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||
)
|
||||
if (option == JOptionPane.OK_OPTION) {
|
||||
Application.browse(URI.create("https://apps.apple.com/app/windows-app/id1295203466"))
|
||||
}
|
||||
return
|
||||
}
|
||||
// 如果是传输协议
|
||||
if (providers.first { StringUtils.equalsIgnoreCase(it.getProtocol(), host.protocol) }
|
||||
.isTransfer()) {
|
||||
ActionManager.getInstance().getAction(Actions.SFTP)
|
||||
.actionPerformed(SFTPActionEvent(evt.source, evt.host.id, evt.event))
|
||||
return
|
||||
}
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.append("full address:s:").append(host.host).append(':').append(host.port).appendLine()
|
||||
sb.append("username:s:").append(host.username).appendLine()
|
||||
// 只处理通用协议
|
||||
providers = providers.filterIsInstance<GenericProtocolProvider>()
|
||||
|
||||
val file = FileUtils.getFile(Application.getTemporaryDir(), UUID.randomUUID().toSimpleString() + ".rdp")
|
||||
file.outputStream().use { IOUtils.write(sb.toString(), it, Charsets.UTF_8) }
|
||||
|
||||
if (host.authentication.type == AuthenticationType.Password) {
|
||||
val systemClipboard = windowScope.window.toolkit.systemClipboard
|
||||
val password = host.authentication.password
|
||||
systemClipboard.setContents(StringSelection(password), null)
|
||||
// clear password
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
delay(30.seconds)
|
||||
if (systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
|
||||
if (systemClipboard.getData(DataFlavor.stringFlavor) == password) {
|
||||
systemClipboard.setContents(StringSelection(StringUtils.EMPTY), null)
|
||||
}
|
||||
for (provider in providers) {
|
||||
if (StringUtils.equalsIgnoreCase(provider.getProtocol(), host.protocol)) {
|
||||
if (provider.canCreateTerminalTab(evt, windowScope, host)) {
|
||||
tab = provider.createTerminalTab(evt, windowScope, host)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (SystemInfo.isMacOS) {
|
||||
ProcessBuilder("open", file.absolutePath).start()
|
||||
} else if (SystemInfo.isWindows) {
|
||||
ProcessBuilder("mstsc", file.absolutePath).start()
|
||||
if (tab == null) return
|
||||
|
||||
terminalTabbedManager.addTerminalTab(tab)
|
||||
if (tab is PtyHostTerminalTab) {
|
||||
tab.start()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.Host
|
||||
import app.termora.I18n
|
||||
import app.termora.Icons
|
||||
import app.termora.OpenHostActionEvent
|
||||
|
||||
class OpenLocalTerminalAction : AnAction(
|
||||
I18n.getString("termora.find-everywhere.quick-command.local-terminal"),
|
||||
@@ -24,7 +27,7 @@ class OpenLocalTerminalAction : AnAction(
|
||||
Host(
|
||||
id = "local",
|
||||
name = name,
|
||||
protocol = Protocol.Local
|
||||
protocol = "Local"
|
||||
),
|
||||
evt
|
||||
)
|
||||
|
||||
@@ -3,7 +3,8 @@ package app.termora.actions
|
||||
import app.termora.HostTerminalTab
|
||||
import app.termora.I18n
|
||||
import app.termora.OpenHostActionEvent
|
||||
import app.termora.Protocol
|
||||
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
|
||||
import app.termora.plugin.internal.ssh.SSHProtocolProvider
|
||||
|
||||
class SFTPCommandAction : AnAction() {
|
||||
companion object {
|
||||
@@ -23,8 +24,8 @@ class SFTPCommandAction : AnAction() {
|
||||
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))
|
||||
if (!(host.protocol == SSHProtocolProvider.PROTOCOL || host.protocol == SFTPPtyProtocolProvider.PROTOCOL)) return
|
||||
actionManager.actionPerformed(OpenHostActionEvent(evt.source, host.copy(protocol = SFTPPtyProtocolProvider.PROTOCOL), evt))
|
||||
evt.consume()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.Database
|
||||
import app.termora.TerminalPanelFactory
|
||||
import app.termora.database.DatabaseManager
|
||||
|
||||
abstract class TerminalZoomAction : AnAction() {
|
||||
protected val fontSize get() = Database.getDatabase().terminal.fontSize
|
||||
protected val fontSize get() = DatabaseManager.getInstance().terminal.fontSize
|
||||
|
||||
abstract fun zoom(): Boolean
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.Database
|
||||
import app.termora.I18n
|
||||
import app.termora.database.DatabaseManager
|
||||
|
||||
class TerminalZoomInAction : TerminalZoomAction() {
|
||||
companion object {
|
||||
@@ -14,7 +14,7 @@ class TerminalZoomInAction : TerminalZoomAction() {
|
||||
}
|
||||
|
||||
override fun zoom(): Boolean {
|
||||
Database.getDatabase().terminal.fontSize += 2
|
||||
DatabaseManager.getInstance().terminal.fontSize += 2
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.Database
|
||||
import app.termora.I18n
|
||||
import app.termora.database.DatabaseManager
|
||||
import kotlin.math.max
|
||||
|
||||
class TerminalZoomOutAction : TerminalZoomAction() {
|
||||
@@ -16,7 +16,7 @@ class TerminalZoomOutAction : TerminalZoomAction() {
|
||||
|
||||
override fun zoom(): Boolean {
|
||||
val oldFontSize = fontSize
|
||||
Database.getDatabase().terminal.fontSize = max(fontSize - 2, 9)
|
||||
DatabaseManager.getInstance().terminal.fontSize = max(fontSize - 2, 9)
|
||||
return oldFontSize != fontSize
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.Database
|
||||
import app.termora.I18n
|
||||
import app.termora.database.DatabaseManager
|
||||
|
||||
class TerminalZoomResetAction : TerminalZoomAction() {
|
||||
companion object {
|
||||
@@ -19,7 +19,8 @@ class TerminalZoomResetAction : TerminalZoomAction() {
|
||||
if (fontSize == defaultFontSize) {
|
||||
return false
|
||||
}
|
||||
Database.getDatabase().terminal.fontSize = defaultFontSize
|
||||
|
||||
DatabaseManager.getInstance().terminal.fontSize = defaultFontSize
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package app.termora.addons.zmodem
|
||||
|
||||
import app.termora.I18n
|
||||
import app.termora.native.FileChooser
|
||||
import app.termora.nv.FileChooser
|
||||
import app.termora.terminal.ControlCharacters
|
||||
import app.termora.terminal.PtyConnectorDelegate
|
||||
import app.termora.terminal.StreamPtyConnector
|
||||
|
||||
30
src/main/kotlin/app/termora/database/Data.kt
Normal file
30
src/main/kotlin/app/termora/database/Data.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package app.termora.database
|
||||
|
||||
import app.termora.randomUUID
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
|
||||
data class Data(
|
||||
val id: String = randomUUID(),
|
||||
val ownerId: String,
|
||||
val ownerType: String,
|
||||
val type: String,
|
||||
val data: String,
|
||||
val version: Long = 0,
|
||||
val synced: Boolean = false,
|
||||
val deleted: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
fun ResultRow.toData(): Data {
|
||||
return Data(
|
||||
id = this[DataEntity.id],
|
||||
ownerId = this[DataEntity.ownerId],
|
||||
ownerType = this[DataEntity.ownerType],
|
||||
type = this[DataEntity.type],
|
||||
data = this[DataEntity.data],
|
||||
version = this[DataEntity.version],
|
||||
synced = this[DataEntity.synced],
|
||||
deleted = this[DataEntity.deleted],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/main/kotlin/app/termora/database/DataEntity.kt
Normal file
65
src/main/kotlin/app/termora/database/DataEntity.kt
Normal file
@@ -0,0 +1,65 @@
|
||||
package app.termora.database
|
||||
|
||||
import app.termora.LocalSecret
|
||||
import app.termora.randomUUID
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jetbrains.exposed.v1.core.Column
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.crypt.Algorithms
|
||||
|
||||
object DataEntity : Table() {
|
||||
val id: Column<String> = char("id", length = 32).clientDefault { randomUUID() }
|
||||
|
||||
/**
|
||||
* 归属者
|
||||
*/
|
||||
val ownerId: Column<String> = char("ownerId", length = 32).index()
|
||||
|
||||
/**
|
||||
* [OwnerType]
|
||||
*/
|
||||
val ownerType: Column<String> = varchar("ownerType", 32)
|
||||
|
||||
/**
|
||||
* [DataType]
|
||||
*/
|
||||
val type: Column<String> = varchar("type", 32)
|
||||
|
||||
/**
|
||||
* 版本,当和云端不一致时会同步,以最大的为准
|
||||
*/
|
||||
val version = long("version").clientDefault { 0L }
|
||||
|
||||
/**
|
||||
* 是否已经同步标识,每次更新都要设置成 false 否则不会同步
|
||||
*/
|
||||
val synced: Column<Boolean> = bool("synced").clientDefault { false }
|
||||
|
||||
/**
|
||||
* 是否已经删除
|
||||
*/
|
||||
val deleted: Column<Boolean> = bool("deleted").clientDefault { false }
|
||||
|
||||
/**
|
||||
* 数据
|
||||
*/
|
||||
val data: Column<String> = encryptedText(
|
||||
"data", Algorithms.AES_256_PBE_GCM(
|
||||
LocalSecret.getInstance().password,
|
||||
LocalSecret.getInstance().salt
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 备用字段1-5
|
||||
*/
|
||||
val extra1: Column<String> = text("extra1").clientDefault { StringUtils.EMPTY }
|
||||
val extra2: Column<String> = text("extra2").clientDefault { StringUtils.EMPTY }
|
||||
val extra3: Column<String> = text("extra3").clientDefault { StringUtils.EMPTY }
|
||||
val extra4: Column<String> = text("extra4").clientDefault { StringUtils.EMPTY }
|
||||
val extra5: Column<String> = text("extra5").clientDefault { StringUtils.EMPTY }
|
||||
|
||||
override val primaryKey: PrimaryKey get() = PrimaryKey(id)
|
||||
override val tableName: String
|
||||
get() = "tb_data"
|
||||
}
|
||||
11
src/main/kotlin/app/termora/database/DataType.kt
Normal file
11
src/main/kotlin/app/termora/database/DataType.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package app.termora.database
|
||||
|
||||
enum class DataType {
|
||||
Host,
|
||||
Snippet,
|
||||
KeyPair,
|
||||
Tag,
|
||||
Macro,
|
||||
KeywordHighlight,
|
||||
Keymap,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package app.termora.database
|
||||
|
||||
import app.termora.database.DatabaseManager.Companion.log
|
||||
import app.termora.plugin.Extension
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
interface DatabaseChangedExtension : Extension {
|
||||
|
||||
companion object {
|
||||
fun fireDataChanged(id: String, type: String, action: Action, source: Source = Source.User) {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
for (extension in ExtensionManager.getInstance().getExtensions(DatabaseChangedExtension::class.java)) {
|
||||
try {
|
||||
extension.onDataChanged(id, type, action, source)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SwingUtilities.invokeLater { fireDataChanged(id, type, action, source) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Action {
|
||||
Changed,
|
||||
Added,
|
||||
Removed
|
||||
}
|
||||
|
||||
enum class Source {
|
||||
User,
|
||||
Sync,
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据变动 如果 [type] 和 [id] 同时为空,那么不知道删除了什么,所有类型都需要刷新
|
||||
*
|
||||
* @param type 为空时表示删除
|
||||
*/
|
||||
fun onDataChanged(id: String, type: String, action: Action, source: Source = Source.User) {}
|
||||
|
||||
}
|
||||
746
src/main/kotlin/app/termora/database/DatabaseManager.kt
Normal file
746
src/main/kotlin/app/termora/database/DatabaseManager.kt
Normal file
@@ -0,0 +1,746 @@
|
||||
package app.termora.database
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.account.Account
|
||||
import app.termora.account.AccountExtension
|
||||
import app.termora.account.AccountManager
|
||||
import app.termora.database.Data.Companion.toData
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import app.termora.plugin.internal.extension.DynamicExtensionHandler
|
||||
import app.termora.terminal.CursorStyle
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.inList
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.statements.StatementType
|
||||
import org.jetbrains.exposed.v1.jdbc.*
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class DatabaseManager private constructor() : Disposable {
|
||||
companion object {
|
||||
val log = LoggerFactory.getLogger(DatabaseManager::class.java)!!
|
||||
fun getInstance(): DatabaseManager {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(DatabaseManager::class) { DatabaseManager() }
|
||||
}
|
||||
}
|
||||
|
||||
val database: Database
|
||||
val lock = ReentrantLock()
|
||||
|
||||
val properties by lazy { Properties(this) }
|
||||
val terminal by lazy { Terminal(this) }
|
||||
val appearance by lazy { Appearance(this) }
|
||||
val sftp by lazy { SFTP(this) }
|
||||
|
||||
private val map = Collections.synchronizedMap<String, String?>(mutableMapOf())
|
||||
private val accountManager get() = AccountManager.getInstance()
|
||||
|
||||
init {
|
||||
|
||||
val databaseFile = FileUtils.getFile(
|
||||
Application.getBaseDataDir(),
|
||||
"config", "termora.db"
|
||||
)
|
||||
FileUtils.forceMkdirParent(databaseFile)
|
||||
val isExists = databaseFile.exists()
|
||||
|
||||
database = Database.connect(
|
||||
"jdbc:sqlite:${databaseFile.absolutePath}",
|
||||
driver = "org.sqlite.JDBC", user = "sa"
|
||||
)
|
||||
|
||||
// 设置数据库版本号,便于后续升级
|
||||
if (isExists.not()) {
|
||||
transaction(database) {
|
||||
// 创建数据库
|
||||
SchemaUtils.create(DataEntity, SettingEntity)
|
||||
@Suppress("SqlNoDataSourceInspection")
|
||||
exec("PRAGMA db_version = 1", explicitStatementType = StatementType.UPDATE)
|
||||
}
|
||||
}
|
||||
|
||||
// 异步初始化
|
||||
Thread.ofVirtual().start { map.putAll(getSettings()); }
|
||||
|
||||
// 注册动态扩展
|
||||
registerDynamicExtensions()
|
||||
|
||||
for (extension in ExtensionManager.getInstance().getExtensions(DatabaseReadyExtension::class.java)) {
|
||||
extension.ready(this)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private fun registerDynamicExtensions() {
|
||||
// 负责清理或转移数据,如果从本地用户切换到云端用户,那么把本地用户的数据复制到云端用户下,然后本地用户数据清除
|
||||
DynamicExtensionHandler.getInstance().register(AccountExtension::class.java, AccountDataTransferExtension())
|
||||
.let { Disposer.register(this, it) }
|
||||
|
||||
|
||||
// 用户团队变更
|
||||
DynamicExtensionHandler.getInstance().register(AccountExtension::class.java, AccountTeamChangedExtension())
|
||||
.let { Disposer.register(this, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回本地所有用户的数据,调用者需要过滤具体用户
|
||||
*/
|
||||
inline fun <reified T> data(type: DataType): List<T> {
|
||||
return data(type, StringUtils.EMPTY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回本地所有用户的数据,调用者需要过滤具体用户
|
||||
*/
|
||||
inline fun <reified T> data(type: DataType, ownerId: String): List<T> {
|
||||
val list = mutableListOf<T>()
|
||||
try {
|
||||
for (data in rawData(type, ownerId)) {
|
||||
list.add(ohMyJson.decodeFromString<T>(data.data))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn(e.message, e)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回本地所有用户的数据,调用者需要过滤具体用户
|
||||
*/
|
||||
fun data(id: String): Data? {
|
||||
return lock.withLock {
|
||||
transaction(database) {
|
||||
DataEntity.selectAll()
|
||||
.where { (DataEntity.id.eq(id)) }
|
||||
.firstOrNull()?.toData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun unsyncedData(): List<Data> {
|
||||
val list = mutableListOf<Data>()
|
||||
lock.withLock {
|
||||
transaction(database) {
|
||||
val rows = DataEntity.selectAll().where { (DataEntity.synced eq false) }.toList()
|
||||
for (row in rows) {
|
||||
list.add(row.toData())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (accountManager.isLocally().not()) {
|
||||
val ownerIds = accountManager.getOwnerIds()
|
||||
return list.filter { ownerIds.contains(it.ownerId) }
|
||||
.filterNot { AccountManager.isLocally(it.ownerId) }
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据版本
|
||||
*/
|
||||
fun version(id: String): Long? {
|
||||
return lock.withLock {
|
||||
transaction(database) {
|
||||
DataEntity.select(DataEntity.version)
|
||||
.where { (DataEntity.id.eq(id) and DataEntity.deleted.eq(false)) }
|
||||
.firstOrNull()?.get(DataEntity.version) ?: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 不会返回已删除的数据
|
||||
*/
|
||||
fun rawData(type: DataType): List<Data> {
|
||||
return rawData(type, StringUtils.EMPTY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 不会返回已删除的数据
|
||||
*/
|
||||
fun rawData(type: DataType, ownerId: String): List<Data> {
|
||||
val list = mutableListOf<Data>()
|
||||
lock.withLock {
|
||||
transaction(database) {
|
||||
val query = DataEntity.selectAll()
|
||||
.where { (DataEntity.type eq type.name) and (DataEntity.deleted.eq(false)) }
|
||||
|
||||
if (ownerId.isNotBlank()) {
|
||||
query.andWhere { DataEntity.ownerId eq ownerId }
|
||||
}
|
||||
|
||||
for (row in query) {
|
||||
try {
|
||||
list.add(row.toData())
|
||||
} catch (e: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/**
|
||||
* 存在则获取本地版本 +1 然后修改,synced 会改成 false ,不存在则新增
|
||||
*/
|
||||
fun saveAndIncrementVersion(
|
||||
data: Data,
|
||||
source: DatabaseChangedExtension.Source = DatabaseChangedExtension.Source.User
|
||||
) {
|
||||
val oldData = data(data.id)
|
||||
if (oldData != null) {
|
||||
// 已经删除的数据,将不处理
|
||||
if (oldData.deleted) {
|
||||
return
|
||||
}
|
||||
|
||||
lock.withLock {
|
||||
transaction(database) {
|
||||
DataEntity.update({ (DataEntity.id eq data.id) }) {
|
||||
it[DataEntity.data] = data.data
|
||||
it[DataEntity.version] = oldData.version + 1
|
||||
it[DataEntity.synced] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 触发更改
|
||||
DatabaseChangedExtension.fireDataChanged(
|
||||
data.id,
|
||||
data.type,
|
||||
DatabaseChangedExtension.Action.Changed,
|
||||
source
|
||||
)
|
||||
} else {
|
||||
save(data)
|
||||
}
|
||||
}
|
||||
|
||||
fun save(data: Data, source: DatabaseChangedExtension.Source = DatabaseChangedExtension.Source.User) {
|
||||
var action = DatabaseChangedExtension.Action.Changed
|
||||
lock.withLock {
|
||||
transaction(database) {
|
||||
val exists = DataEntity.selectAll()
|
||||
.where { (DataEntity.id eq data.id) }
|
||||
.any()
|
||||
|
||||
if (exists) {
|
||||
DataEntity.update({ (DataEntity.id eq data.id) }) {
|
||||
it[DataEntity.data] = data.data
|
||||
it[DataEntity.version] = data.version
|
||||
it[DataEntity.synced] = data.synced
|
||||
it[DataEntity.deleted] = data.deleted
|
||||
}
|
||||
action = DatabaseChangedExtension.Action.Changed
|
||||
} else {
|
||||
DataEntity.insert {
|
||||
it[DataEntity.id] = data.id
|
||||
it[DataEntity.ownerId] = data.ownerId
|
||||
it[DataEntity.ownerType] = data.ownerType
|
||||
it[DataEntity.type] = data.type
|
||||
it[DataEntity.data] = data.data
|
||||
it[DataEntity.synced] = data.synced
|
||||
it[DataEntity.deleted] = data.deleted
|
||||
it[DataEntity.version] = data.version
|
||||
}
|
||||
action = DatabaseChangedExtension.Action.Added
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 触发更改
|
||||
DatabaseChangedExtension.fireDataChanged(data.id, data.type, action, source)
|
||||
}
|
||||
|
||||
fun delete(
|
||||
id: String,
|
||||
type: String,
|
||||
source: DatabaseChangedExtension.Source = DatabaseChangedExtension.Source.User
|
||||
) {
|
||||
|
||||
lock.withLock {
|
||||
transaction(database) {
|
||||
DataEntity.update({ DataEntity.id eq id }) {
|
||||
it[DataEntity.deleted] = true
|
||||
// 如果是本地用户,那么删除是不需要同步的,云端用户才需要同步
|
||||
it[DataEntity.synced] = accountManager.isLocally()
|
||||
it[DataEntity.data] = StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 触发更改
|
||||
DatabaseChangedExtension.fireDataChanged(id, type, DatabaseChangedExtension.Action.Removed, source)
|
||||
}
|
||||
|
||||
fun getSettings(): Map<String, String> {
|
||||
val map = mutableMapOf<String, String>()
|
||||
lock.withLock {
|
||||
transaction(database) {
|
||||
for (row in SettingEntity.selectAll().toList()) {
|
||||
map[row[SettingEntity.name]] = row[SettingEntity.value]
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
fun setSetting(name: String, value: String) {
|
||||
lock.withLock {
|
||||
transaction(database) {
|
||||
for (row in SettingEntity.selectAll().where { SettingEntity.name eq name }.toList()) {
|
||||
SettingEntity.deleteWhere { SettingEntity.id eq row[SettingEntity.id] }
|
||||
}
|
||||
SettingEntity.insert {
|
||||
it[SettingEntity.name] = name
|
||||
it[SettingEntity.value] = value
|
||||
}
|
||||
}
|
||||
map[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
fun getSetting(name: String): String? {
|
||||
if (map.containsKey(name)) {
|
||||
return map[name]
|
||||
}
|
||||
lock.withLock {
|
||||
transaction(database) {
|
||||
map[name] = SettingEntity.selectAll()
|
||||
.where { SettingEntity.name eq name }.toList()
|
||||
.singleOrNull()?.getOrNull(SettingEntity.value)
|
||||
}
|
||||
}
|
||||
return map[name]
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
lock.withLock {
|
||||
TransactionManager.closeAndUnregister(database)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private inner class AccountDataTransferExtension : AccountExtension {
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
override fun onAccountChanged(oldAccount: Account, newAccount: Account) {
|
||||
if (oldAccount.isLocally && newAccount.isLocally) {
|
||||
return
|
||||
}
|
||||
|
||||
if (oldAccount.id == newAccount.id) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果之前是本地用户,现在是云端用户,那么把之前的数据复制一份到云端用户
|
||||
// 复制到云端之后就可以删除本地数据了
|
||||
if (oldAccount.isLocally && newAccount.isLocally.not()) {
|
||||
transferData(newAccount)
|
||||
}
|
||||
|
||||
// 如果之前是云端用户,退出登录了要删除本地数据
|
||||
if (oldAccount.isLocally.not()) {
|
||||
lock.withLock {
|
||||
transaction(database) {
|
||||
// 删除用户的数据
|
||||
DataEntity.deleteWhere {
|
||||
DataEntity.ownerId.eq(oldAccount.id) and (DataEntity.ownerType.eq(OwnerType.User.name))
|
||||
}
|
||||
// 删除团队的数据
|
||||
for (team in oldAccount.teams) {
|
||||
DataEntity.deleteWhere {
|
||||
DataEntity.ownerId.eq(team.id) and (DataEntity.ownerType.eq(OwnerType.Team.name))
|
||||
}
|
||||
}
|
||||
DatabaseChangedExtension.fireDataChanged(
|
||||
StringUtils.EMPTY,
|
||||
StringUtils.EMPTY,
|
||||
DatabaseChangedExtension.Action.Removed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun transferData(account: Account) {
|
||||
val deleteIds = mutableSetOf<String>()
|
||||
|
||||
for (host in hostManager.hosts()) {
|
||||
// 不是用户数据,那么忽略
|
||||
if (host.ownerType.isNotBlank() && host.ownerType != OwnerType.User.name) continue
|
||||
// 不是本地用户数据,那么忽略
|
||||
if (AccountManager.isLocally(host.ownerId).not()) continue
|
||||
// 转移资产
|
||||
val newHost = host.copy(
|
||||
id = randomUUID(),
|
||||
ownerId = account.id,
|
||||
ownerType = OwnerType.User.name,
|
||||
)
|
||||
// 保存数据
|
||||
save(
|
||||
Data(
|
||||
id = newHost.id,
|
||||
ownerId = newHost.ownerId,
|
||||
ownerType = newHost.ownerType,
|
||||
type = DataType.Host.name,
|
||||
data = ohMyJson.encodeToString(newHost),
|
||||
)
|
||||
)
|
||||
|
||||
deleteIds.add(host.id)
|
||||
}
|
||||
|
||||
if (deleteIds.isNotEmpty()) {
|
||||
lock.withLock {
|
||||
transaction(database) {
|
||||
DataEntity.deleteWhere { DataEntity.id.inList(deleteIds) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun ordered(): Long {
|
||||
return 0
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private inner class AccountTeamChangedExtension : AccountExtension {
|
||||
|
||||
override fun onAccountChanged(oldAccount: Account, newAccount: Account) {
|
||||
if (oldAccount.isLocally && newAccount.isLocally) {
|
||||
return
|
||||
}
|
||||
|
||||
if (oldAccount.id == newAccount.id) {
|
||||
return
|
||||
}
|
||||
|
||||
for (team in oldAccount.teams) {
|
||||
// 如果被踢出团队,那么移除该团队的所有资产
|
||||
if (newAccount.teams.none { it.id == team.id }) {
|
||||
lock.withLock {
|
||||
transaction(database) {
|
||||
DataEntity.deleteWhere {
|
||||
DataEntity.ownerId.eq(team.id) and (DataEntity.ownerType.eq(
|
||||
OwnerType.Team.name
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun ordered(): Long {
|
||||
return -1
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
abstract class IProperties(
|
||||
private val databaseManager: DatabaseManager,
|
||||
private val name: String
|
||||
) {
|
||||
|
||||
private val map get() = databaseManager.map
|
||||
|
||||
protected open fun getString(key: String): String? {
|
||||
return databaseManager.getSetting("${name}.$key")
|
||||
}
|
||||
|
||||
|
||||
protected open fun putString(key: String, value: String) {
|
||||
databaseManager.setSetting("${name}.$key", value)
|
||||
}
|
||||
|
||||
|
||||
fun getProperties(): Map<String, String> {
|
||||
val properties = mutableMapOf<String, String>()
|
||||
for (e in map.entries) {
|
||||
if (e.key.startsWith("${name}.")) {
|
||||
properties[e.key] = e.value ?: continue
|
||||
}
|
||||
}
|
||||
return properties
|
||||
}
|
||||
|
||||
|
||||
protected abstract inner class PropertyLazyDelegate<T>(protected val initializer: () -> T) :
|
||||
ReadWriteProperty<Any?, T> {
|
||||
private var value: T? = null
|
||||
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
|
||||
if (value == null) {
|
||||
val v = getString(property.name)
|
||||
value = if (v == null) {
|
||||
initializer.invoke()
|
||||
} else {
|
||||
convertValue(v)
|
||||
}
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
value = initializer.invoke()
|
||||
}
|
||||
return value!!
|
||||
}
|
||||
|
||||
abstract fun convertValue(value: String): T
|
||||
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
|
||||
this.value = value
|
||||
putString(property.name, value.toString())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected abstract inner class PropertyDelegate<T>(private val defaultValue: T) :
|
||||
PropertyLazyDelegate<T>({ defaultValue })
|
||||
|
||||
|
||||
protected inner class StringPropertyDelegate(defaultValue: String) :
|
||||
PropertyDelegate<String>(defaultValue) {
|
||||
override fun convertValue(value: String): String {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class IntPropertyDelegate(defaultValue: Int) :
|
||||
PropertyDelegate<Int>(defaultValue) {
|
||||
override fun convertValue(value: String): Int {
|
||||
return value.toIntOrNull() ?: initializer.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class DoublePropertyDelegate(defaultValue: Double) :
|
||||
PropertyDelegate<Double>(defaultValue) {
|
||||
override fun convertValue(value: String): Double {
|
||||
return value.toDoubleOrNull() ?: initializer.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected inner class LongPropertyDelegate(defaultValue: Long) :
|
||||
PropertyDelegate<Long>(defaultValue) {
|
||||
override fun convertValue(value: String): Long {
|
||||
return value.toLongOrNull() ?: initializer.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class BooleanPropertyDelegate(defaultValue: Boolean) :
|
||||
PropertyDelegate<Boolean>(defaultValue) {
|
||||
override fun convertValue(value: String): Boolean {
|
||||
return value.toBooleanStrictOrNull() ?: initializer.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
protected open inner class StringPropertyLazyDelegate(initializer: () -> String) :
|
||||
PropertyLazyDelegate<String>(initializer) {
|
||||
override fun convertValue(value: String): String {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected inner class CursorStylePropertyDelegate(defaultValue: CursorStyle) :
|
||||
PropertyDelegate<CursorStyle>(defaultValue) {
|
||||
override fun convertValue(value: String): CursorStyle {
|
||||
return try {
|
||||
CursorStyle.valueOf(value)
|
||||
} catch (_: Exception) {
|
||||
initializer.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端设置
|
||||
*/
|
||||
class Terminal(databaseManager: DatabaseManager) : IProperties(databaseManager, "Setting.Terminal") {
|
||||
|
||||
/**
|
||||
* 字体
|
||||
*/
|
||||
var font by StringPropertyDelegate("JetBrains Mono")
|
||||
|
||||
/**
|
||||
* 默认终端
|
||||
*/
|
||||
var localShell by StringPropertyLazyDelegate { Application.getDefaultShell() }
|
||||
|
||||
/**
|
||||
* 字体大小
|
||||
*/
|
||||
var fontSize by IntPropertyDelegate(14)
|
||||
|
||||
/**
|
||||
* 最大行数
|
||||
*/
|
||||
var maxRows by IntPropertyDelegate(5000)
|
||||
|
||||
/**
|
||||
* 调试模式
|
||||
*/
|
||||
var debug by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 蜂鸣声
|
||||
*/
|
||||
var beep by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* 超链接
|
||||
*/
|
||||
var hyperlink by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* 光标闪烁
|
||||
*/
|
||||
var cursorBlink by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 选中复制
|
||||
*/
|
||||
var selectCopy by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 光标样式
|
||||
*/
|
||||
var cursor by CursorStylePropertyDelegate(CursorStyle.Block)
|
||||
|
||||
/**
|
||||
* 终端断开连接时自动关闭Tab
|
||||
*/
|
||||
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 是否显示悬浮工具栏
|
||||
*/
|
||||
var floatingToolbar by BooleanPropertyDelegate(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用属性
|
||||
*/
|
||||
class Properties(databaseManager: DatabaseManager) : IProperties(databaseManager, "Setting.Properties") {
|
||||
public override fun getString(key: String): String? {
|
||||
return super.getString(key)
|
||||
}
|
||||
|
||||
|
||||
fun getString(key: String, defaultValue: String): String {
|
||||
return getString(key) ?: defaultValue
|
||||
}
|
||||
|
||||
public override fun putString(key: String, value: String) {
|
||||
super.putString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 外观
|
||||
*/
|
||||
class Appearance(databaseManager: DatabaseManager) : IProperties(databaseManager, "Setting.Appearance") {
|
||||
|
||||
|
||||
/**
|
||||
* 外观
|
||||
*/
|
||||
var theme by StringPropertyDelegate("Light")
|
||||
|
||||
/**
|
||||
* 跟随系统
|
||||
*/
|
||||
var followSystem by BooleanPropertyDelegate(true)
|
||||
var darkTheme by StringPropertyDelegate("Dark")
|
||||
var lightTheme by StringPropertyDelegate("Light")
|
||||
|
||||
/**
|
||||
* 允许后台运行,也就是托盘
|
||||
*/
|
||||
var backgroundRunning by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 关闭 Tab 前询问
|
||||
*/
|
||||
var confirmTabClose by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 背景图片的地址
|
||||
*/
|
||||
var backgroundImage by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* 语言
|
||||
*/
|
||||
var language by StringPropertyLazyDelegate {
|
||||
I18n.containsLanguage(Locale.getDefault()) ?: Locale.US.toString()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 透明度
|
||||
*/
|
||||
var opacity by DoublePropertyDelegate(1.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* SFTP
|
||||
*/
|
||||
class SFTP(databaseManager: DatabaseManager) : IProperties(databaseManager, "Setting.SFTP") {
|
||||
|
||||
|
||||
/**
|
||||
* 编辑命令
|
||||
*/
|
||||
var editCommand by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
|
||||
/**
|
||||
* sftp command
|
||||
*/
|
||||
var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* defaultDirectory
|
||||
*/
|
||||
var defaultDirectory by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
|
||||
/**
|
||||
* 是否固定在标签栏
|
||||
*/
|
||||
var pinTab by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 是否保留原始文件时间
|
||||
*/
|
||||
var preserveModificationTime by BooleanPropertyDelegate(false)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package app.termora.database
|
||||
|
||||
import app.termora.database.DatabaseManager.Companion.log
|
||||
import app.termora.plugin.DispatchThread
|
||||
import app.termora.plugin.Extension
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
interface DatabaseReadyExtension : Extension {
|
||||
|
||||
companion object {
|
||||
fun fireReady(databaseManager: DatabaseManager) {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
for (extension in ExtensionManager.getInstance().getExtensions(DatabaseReadyExtension::class.java)) {
|
||||
try {
|
||||
extension.ready(databaseManager)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SwingUtilities.invokeLater { fireReady(databaseManager) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库初始化完成
|
||||
*/
|
||||
fun ready(databaseManager: DatabaseManager) {}
|
||||
|
||||
override fun getDispatchThread(): DispatchThread {
|
||||
return DispatchThread.BGT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package app.termora.database
|
||||
|
||||
import org.jetbrains.exposed.v1.core.Column
|
||||
import org.jetbrains.exposed.v1.core.ColumnWithTransform
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.core.TextColumnType
|
||||
import org.jetbrains.exposed.v1.crypt.Encryptor
|
||||
import org.jetbrains.exposed.v1.crypt.StringEncryptionTransformer
|
||||
|
||||
fun Table.encryptedText(name: String, encryptor: Encryptor): Column<String> =
|
||||
registerColumn(name, EncryptedTextColumnType(encryptor))
|
||||
|
||||
class EncryptedTextColumnType(
|
||||
encryptor: Encryptor,
|
||||
) : ColumnWithTransform<String, String>(TextColumnType(), StringEncryptionTransformer(encryptor))
|
||||
6
src/main/kotlin/app/termora/database/OwnerType.kt
Normal file
6
src/main/kotlin/app/termora/database/OwnerType.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
package app.termora.database
|
||||
|
||||
enum class OwnerType {
|
||||
User,
|
||||
Team,
|
||||
}
|
||||
30
src/main/kotlin/app/termora/database/SettingEntity.kt
Normal file
30
src/main/kotlin/app/termora/database/SettingEntity.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package app.termora.database
|
||||
|
||||
import app.termora.LocalSecret
|
||||
import app.termora.randomUUID
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jetbrains.exposed.v1.core.Column
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.crypt.Algorithms
|
||||
|
||||
object SettingEntity : Table() {
|
||||
val id: Column<String> = char("id", length = 32).clientDefault { randomUUID() }
|
||||
val name: Column<String> = varchar("name", length = 128).index()
|
||||
val value: Column<String> = encryptedText(
|
||||
"value", Algorithms.AES_256_PBE_GCM(
|
||||
LocalSecret.getInstance().password,
|
||||
LocalSecret.getInstance().salt
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 备用字段1-3
|
||||
*/
|
||||
val extra1: Column<String> = text("extra1").clientDefault { StringUtils.EMPTY }
|
||||
val extra2: Column<String> = text("extra2").clientDefault { StringUtils.EMPTY }
|
||||
val extra3: Column<String> = text("extra3").clientDefault { StringUtils.EMPTY }
|
||||
|
||||
override val primaryKey: PrimaryKey get() = PrimaryKey(id)
|
||||
override val tableName: String
|
||||
get() = "tb_setting"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user