chore!: migrate to version 2.x

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

View File

@@ -0,0 +1,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} &mdash; matches {@code com/test.jsp} but also
* {@code com/tast.jsp} or {@code com/txst.jsp}</li>
* <li>{@code com/*.jsp} &mdash; matches all {@code .jsp} files in the
* {@code com} directory</li>
* <li><code>com/&#42;&#42;/test.jsp</code> &mdash; matches all {@code test.jsp}
* files underneath the {@code com} path</li>
* <li><code>org/springframework/&#42;&#42;/*.jsp</code> &mdash; matches all
* {@code .jsp} files underneath the {@code org/springframework} path</li>
* <li><code>org/&#42;&#42;/servlet/bla.jsp</code> &mdash; 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} &rarr; ''</li>
* <li>'{@code /docs/*}' and '{@code /docs/cvs/commit} &rarr; '{@code cvs/commit}'</li>
* <li>'{@code /docs/cvs/*.html}' and '{@code /docs/cvs/commit.html} &rarr; '{@code commit.html}'</li>
* <li>'{@code /docs/**}' and '{@code /docs/cvs/commit} &rarr; '{@code cvs/commit}'</li>
* <li>'{@code /docs/**\/*.html}' and '{@code /docs/cvs/commit.html} &rarr; '{@code cvs/commit.html}'</li>
* <li>'{@code /*.html}' and '{@code /docs/cvs/commit.html} &rarr; '{@code docs/cvs/commit.html}'</li>
* <li>'{@code *.html}' and '{@code /docs/cvs/commit.html} &rarr; '{@code /docs/cvs/commit.html}'</li>
* <li>'{@code *}' and '{@code /docs/cvs/commit.html} &rarr; '{@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>&nbsp;</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/&#42;&#42;</td><td>/bookings</td><td>/hotels/&#42;&#42;/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/&#42;&#42;</td><td>{hotel}</td><td>/hotels/&#42;&#42;/{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;
}
}
}

View File

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

View File

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

View 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 + "]";
}
}

View 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;
}
}
}

View 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
}

View File

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

View File

@@ -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)
}
}
/**

View File

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

View 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
}

View File

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

View File

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

View 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
}
}

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

View 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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)
}
}
}
}

View File

@@ -0,0 +1,10 @@
package app.termora
import app.termora.plugin.Extension
interface FrameExtension : Extension {
/**
* 自定义
*/
fun customize(frame: TermoraFrame)
}

View 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)
}

View 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
}

View 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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)
}

View File

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

View 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
}
}

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

View File

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

View 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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}
}

View File

@@ -0,0 +1,10 @@
package app.termora.account
import app.termora.plugin.Extension
interface AccountExtension : Extension {
/**
* 账户发生变更
*/
fun onAccountChanged(oldAccount: Account, newAccount: Account)
}

View 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
}
}
}

View 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
}
}
}

View 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"
}
}

View 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) {
}

View 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)
}
}

View 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)
}

View File

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

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

View 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,
)
}

View 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) {}
}

View 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
)
}

View File

@@ -0,0 +1,6 @@
package app.termora.account
import kotlinx.serialization.Serializable
@Serializable
data class Server(val name: String, val server: String)

View 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)
}

View File

@@ -0,0 +1,10 @@
package app.termora.account
import app.termora.plugin.Extension
interface ServerSignedExtension : Extension {
/**
* 签名发生变化
*/
fun onSignedChanged(oldSigned: Boolean, newSigned: Boolean)
}

View 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,
)

View File

@@ -0,0 +1,8 @@
package app.termora.account
enum class SubscriptionPlan {
Free,
Pro,
Team,
Enterprise
}

View 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)
)
)
}
}

View 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,
)

View File

@@ -0,0 +1,6 @@
package app.termora.account
enum class TeamRole {
Member,
Owner,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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],
)
}
}
}

View 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"
}

View File

@@ -0,0 +1,11 @@
package app.termora.database
enum class DataType {
Host,
Snippet,
KeyPair,
Tag,
Macro,
KeywordHighlight,
Keymap,
}

View File

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

View 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)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package app.termora.database
enum class OwnerType {
User,
Team,
}

View 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