Compare commits

...
20 Commits
Author SHA1 Message Date
Codex f1db46ecbe Replace heuristic reading with chat toggle
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m3s
2026-07-12 09:45:38 +00:00
Codex c0904e6aaa Show live terminal status in chat
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Successful in 12m9s
2026-07-12 09:03:14 +00:00
Codex 1d9492aff5 Add structured session chat view
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m24s
2026-07-12 07:24:58 +00:00
Codex 9416fa6518 Show version in terminal header
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m25s
2026-07-12 02:26:32 +00:00
Codex 360e2f3af0 Top align terminal reading mode
Gitea Android APK / build (push) Failing after 57s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-12 02:19:06 +00:00
Codex 0119c7cb0e Add expandable terminal reading summaries
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m54s
2026-07-12 02:14:25 +00:00
Codex daf2990b15 Clear stale terminal frames on resize
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m24s
2026-07-12 02:00:55 +00:00
Codex 96fe5ecb8f Improve mobile session reading and pane layout
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Has been cancelled
2026-07-12 01:59:55 +00:00
Codex f103e9e065 Render ANSI dim terminal text
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m47s
2026-07-11 08:41:33 +00:00
Codex 6a1052d437 Insert uploaded image paths
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m50s
2026-07-11 08:11:46 +00:00
Codex 7e028b09d4 Reduce terminal key overflow
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m17s
2026-07-11 08:01:21 +00:00
Codex f6e801defc Rearrange terminal composer controls
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m15s
2026-07-11 03:12:52 +00:00
Codex 90f2088265 Support mixed Gitea runners
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m12s
2026-07-10 16:18:55 +00:00
Codex 0d0f218d52 Compact terminal input controls
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-10 15:54:51 +00:00
Codex 01f2c66f48 Refine terminal input controls
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m32s
2026-07-10 15:11:12 +00:00
Codex 8fda3fa2da Align primary UI with v0 design
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m54s
2026-07-10 14:23:25 +00:00
Codex 69f40e7d36 Apply v0 tmux management UI
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-10 10:47:27 +00:00
Codex bc918e7664 Use full terminal text width
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m19s
2026-07-10 09:11:03 +00:00
Codex aba3930417 Fix TLS socket wrapper compilation
Gitea Android APK / build (push) Failing after 0s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-10 03:57:57 +00:00
Codex 4476f41aa6 Reconnect terminal websockets automatically
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Has been cancelled
2026-07-10 03:51:42 +00:00
6 changed files with 1948 additions and 396 deletions
+22 -4
View File
@@ -16,13 +16,31 @@ jobs:
run: |
set -eu
install_packages() {
if command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y "$@"
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache "$@"
elif command -v dnf >/dev/null 2>&1; then
dnf install -y "$@"
else
echo "No supported package manager found." >&2
exit 1
fi
}
if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1; then
apt-get update
apt-get install -y git curl unzip
install_packages git curl unzip
fi
if ! command -v java >/dev/null 2>&1; then
apt-get update
apt-get install -y openjdk-17-jdk-headless
if command -v apk >/dev/null 2>&1; then
install_packages openjdk17-jdk
elif command -v dnf >/dev/null 2>&1; then
install_packages java-17-openjdk-devel
else
install_packages openjdk-17-jdk-headless
fi
fi
java -version
@@ -4,6 +4,7 @@ import android.util.Base64;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
@@ -14,16 +15,22 @@ import java.util.Arrays;
import javax.net.ssl.SSLSocketFactory;
final class AppEventSocketClient {
private static final long HEARTBEAT_INTERVAL_MS = 15000L;
private static final int SOCKET_CONNECT_TIMEOUT_MS = 10000;
private static final int SOCKET_READ_TIMEOUT_MS = 45000;
interface Listener {
void onMessage(String text);
void onClosed();
}
private final Listener listener;
private final Object writeLock = new Object();
private Socket socket;
private BufferedInputStream input;
private BufferedOutputStream output;
private volatile boolean closed;
private Thread heartbeatThread;
AppEventSocketClient(Listener listener) {
this.listener = listener;
@@ -40,12 +47,11 @@ final class AppEventSocketClient {
sendFrame(8, new byte[0]);
} catch (Exception ignored) {
}
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
closeSocketQuietly();
}
boolean isClosed() {
return closed;
}
private void run(String baseUrl) {
@@ -55,6 +61,7 @@ final class AppEventSocketClient {
input = new BufferedInputStream(socket.getInputStream());
output = new BufferedOutputStream(socket.getOutputStream());
handshake(uri);
startHeartbeat();
while (!closed) {
Frame frame = readFrame();
if (frame.opcode == 1) {
@@ -69,12 +76,7 @@ final class AppEventSocketClient {
} finally {
closed = true;
listener.onClosed();
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
closeSocketQuietly();
}
}
@@ -91,10 +93,39 @@ final class AppEventSocketClient {
if (port == -1) {
port = "wss".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
Socket raw = new Socket();
raw.connect(new InetSocketAddress(uri.getHost(), port), SOCKET_CONNECT_TIMEOUT_MS);
Socket connected;
if ("wss".equalsIgnoreCase(uri.getScheme())) {
return SSLSocketFactory.getDefault().createSocket(uri.getHost(), port);
connected = ((SSLSocketFactory) SSLSocketFactory.getDefault())
.createSocket(raw, uri.getHost(), port, true);
} else {
connected = raw;
}
return new Socket(uri.getHost(), port);
connected.setKeepAlive(true);
connected.setTcpNoDelay(true);
connected.setSoTimeout(SOCKET_READ_TIMEOUT_MS);
return connected;
}
private void startHeartbeat() {
heartbeatThread = new Thread(() -> {
while (!closed) {
try {
Thread.sleep(HEARTBEAT_INTERVAL_MS);
if (!closed) {
sendFrame(9, new byte[0]);
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
return;
} catch (Exception error) {
closeSocketQuietly();
return;
}
}
}, "app-events-ws-heartbeat");
heartbeatThread.start();
}
private void handshake(URI uri) throws Exception {
@@ -181,32 +212,47 @@ final class AppEventSocketClient {
}
private void sendFrame(int opcode, byte[] payload) throws Exception {
if (output == null) {
return;
}
output.write(0x80 | opcode);
byte[] mask = new byte[4];
new SecureRandom().nextBytes(mask);
int length = payload.length;
if (length < 126) {
output.write(0x80 | length);
} else if (length <= 0xffff) {
output.write(0x80 | 126);
output.write((length >>> 8) & 0xff);
output.write(length & 0xff);
} else {
output.write(0x80 | 127);
for (int i = 7; i >= 0; i--) {
output.write((length >>> (8 * i)) & 0xff);
synchronized (writeLock) {
if (output == null) {
return;
}
output.write(0x80 | opcode);
byte[] mask = new byte[4];
new SecureRandom().nextBytes(mask);
int length = payload.length;
if (length < 126) {
output.write(0x80 | length);
} else if (length <= 0xffff) {
output.write(0x80 | 126);
output.write((length >>> 8) & 0xff);
output.write(length & 0xff);
} else {
output.write(0x80 | 127);
for (int i = 7; i >= 0; i--) {
output.write((length >>> (8 * i)) & 0xff);
}
}
output.write(mask);
byte[] masked = Arrays.copyOf(payload, payload.length);
for (int i = 0; i < masked.length; i++) {
masked[i] = (byte) (masked[i] ^ mask[i % 4]);
}
output.write(masked);
output.flush();
}
output.write(mask);
byte[] masked = Arrays.copyOf(payload, payload.length);
for (int i = 0; i < masked.length; i++) {
masked[i] = (byte) (masked[i] ^ mask[i % 4]);
}
private void closeSocketQuietly() {
if (heartbeatThread != null) {
heartbeatThread.interrupt();
heartbeatThread = null;
}
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
output.write(masked);
output.flush();
}
private static final class Frame {
@@ -0,0 +1,85 @@
package com.neatstudio.tmuxandroid;
import org.json.JSONObject;
final class ConversationMessage {
final String messageId;
final String sessionName;
final String role;
final String contentType;
final String content;
final String status;
final String toolName;
final String parentMessageId;
final String createdAt;
final boolean local;
ConversationMessage(
String messageId,
String sessionName,
String role,
String contentType,
String content,
String status,
String toolName,
String parentMessageId,
String createdAt,
boolean local
) {
this.messageId = messageId;
this.sessionName = sessionName;
this.role = role;
this.contentType = contentType;
this.content = content;
this.status = status;
this.toolName = toolName;
this.parentMessageId = parentMessageId;
this.createdAt = createdAt;
this.local = local;
}
static ConversationMessage fromJson(JSONObject object) {
return new ConversationMessage(
value(object, "messageId", object.optString("id", "")),
object.optString("sessionName", ""),
object.optString("role", "assistant"),
object.optString("contentType", "text"),
object.optString("content", ""),
object.optString("status", "complete"),
object.optString("toolName", ""),
value(object, "parentMessageId", ""),
object.optString("createdAt", ""),
false
);
}
static ConversationMessage localUser(String sessionName, String content) {
long now = System.currentTimeMillis();
return new ConversationMessage(
"local-" + now,
sessionName,
"user",
"text",
content,
"sending",
"",
"",
"~" + now,
true
);
}
boolean isTool() {
return "tool".equals(role)
|| "tool".equals(contentType)
|| "command".equals(contentType)
|| "code".equals(contentType);
}
private static String value(JSONObject object, String key, String fallback) {
if (!object.has(key) || object.isNull(key)) {
return fallback;
}
return object.optString(key, fallback);
}
}
File diff suppressed because it is too large Load Diff
@@ -4,16 +4,22 @@ import android.graphics.Color;
import android.graphics.Typeface;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.style.BackgroundColorSpan;
import android.text.style.ClickableSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.view.View;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
final class TerminalScreenBuffer {
private static final int DEFAULT_FG = 0xffe6ebf2;
private static final int DEFAULT_BG = Color.TRANSPARENT;
private static final int TERMINAL_BG = 0xff0b0e13;
private int cols;
private int rows;
@@ -27,6 +33,11 @@ final class TerminalScreenBuffer {
private int fg = DEFAULT_FG;
private int bg = DEFAULT_BG;
private boolean bold;
private boolean dim;
interface FocusToggle {
void toggle(String key);
}
TerminalScreenBuffer(int cols, int rows) {
resize(cols, rows);
@@ -35,9 +46,6 @@ final class TerminalScreenBuffer {
void resize(int nextCols, int nextRows) {
nextCols = Math.max(1, nextCols);
nextRows = Math.max(1, nextRows);
Cell[][] previous = cells;
int previousRows = rows;
int previousCols = cols;
cols = nextCols;
rows = nextRows;
cells = new Cell[rows][cols];
@@ -46,21 +54,11 @@ final class TerminalScreenBuffer {
cells[row][col] = new Cell();
}
}
if (previous != null) {
int copyRows = Math.min(previousRows, rows);
int copyCols = Math.min(previousCols, cols);
int previousStart = Math.max(0, previousRows - copyRows);
int nextStart = Math.max(0, rows - copyRows);
for (int row = 0; row < copyRows; row++) {
for (int col = 0; col < copyCols; col++) {
cells[nextStart + row][col].copyFrom(previous[previousStart + row][col]);
}
}
}
cursorRow = clamp(cursorRow, 0, rows - 1);
cursorCol = clamp(cursorCol, 0, cols - 1);
savedRow = clamp(savedRow, 0, rows - 1);
savedCol = clamp(savedCol, 0, cols - 1);
cursorRow = 0;
cursorCol = 0;
savedRow = 0;
savedCol = 0;
wrapPending = false;
}
void clear() {
@@ -74,6 +72,7 @@ final class TerminalScreenBuffer {
fg = DEFAULT_FG;
bg = DEFAULT_BG;
bold = false;
dim = false;
}
void write(String text) {
@@ -131,6 +130,120 @@ final class TerminalScreenBuffer {
return output;
}
String renderTail(int maxRows) {
List<String> visible = new ArrayList<>();
for (int row = rows - 1; row >= 0 && visible.size() < maxRows; row--) {
String text = rowText(row).trim();
if (!text.isEmpty()) {
visible.add(0, text);
}
}
if (visible.isEmpty()) {
return "Waiting for terminal output";
}
return String.join("\n", visible);
}
CharSequence renderFocused(Set<String> expandedBlocks, FocusToggle toggle) {
SpannableStringBuilder output = new SpannableStringBuilder();
List<String> hiddenRows = new ArrayList<>();
int hiddenStart = -1;
for (int row = 0; row < rows; row++) {
String text = rowText(row).trim();
if (containsHan(text)) {
if (!hiddenRows.isEmpty()) {
appendFocusBlock(output, hiddenRows, hiddenStart, row - 1, expandedBlocks, toggle);
hiddenRows.clear();
hiddenStart = -1;
}
appendLine(output, text);
} else if (!text.isEmpty()) {
if (hiddenStart < 0) {
hiddenStart = row;
}
hiddenRows.add(text);
}
}
if (!hiddenRows.isEmpty()) {
appendFocusBlock(output, hiddenRows, hiddenStart, rows - 1, expandedBlocks, toggle);
}
if (output.length() == 0) {
output.append("暂无可读内容");
}
return output;
}
private void appendFocusBlock(
SpannableStringBuilder output,
List<String> lines,
int startRow,
int endRow,
Set<String> expandedBlocks,
FocusToggle toggle
) {
String key = startRow + ":" + endRow;
boolean expanded = expandedBlocks.contains(key);
String summary = focusSummary(lines);
int actionStart = output.length();
appendLine(output, (expanded ? "" : "") + summary);
int actionEnd = output.length();
output.setSpan(new ClickableSpan() {
@Override
public void onClick(View widget) {
toggle.toggle(key);
}
@Override
public void updateDrawState(TextPaint paint) {
paint.setColor(0xff67da91);
paint.setUnderlineText(false);
paint.setFakeBoldText(true);
}
}, actionStart, actionEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (expanded) {
for (String line : lines) {
appendLine(output, " " + line);
}
}
}
private String focusSummary(List<String> lines) {
String joined = String.join(" ", lines).toLowerCase(Locale.ROOT);
String type;
if (joined.contains("error") || joined.contains("failed") || joined.contains("exception")) {
type = "错误输出";
} else if (joined.contains("working") || joined.contains("running")
|| joined.contains("waiting") || joined.contains("interrupt")) {
type = "运行状态";
} else if (joined.contains("test") || joined.contains("build") || joined.contains("compile")
|| joined.contains("gradle") || joined.contains("webpack") || joined.contains("npm")) {
type = "构建/测试";
} else if (joined.contains("git ") || joined.contains("commit") || joined.contains("push")) {
type = "Git 操作";
} else if (lines.get(0).startsWith(">") || lines.get(0).startsWith("$")
|| lines.get(0).startsWith("!")) {
type = "命令与输出";
} else {
type = "终端细节";
}
return type + " · " + lines.size() + " 行 · " + compactSummary(lines.get(0), 42);
}
private String compactSummary(String text, int maxChars) {
String compact = text.replaceAll("\\s+", " ").trim();
if (compact.length() <= maxChars) {
return compact;
}
return compact.substring(0, maxChars - 1) + "";
}
private void appendLine(SpannableStringBuilder output, String text) {
if (output.length() > 0) {
output.append('\n');
}
output.append(text);
}
private int handleEscape(String text, int index) {
if (index + 1 >= text.length()) {
return -1;
@@ -257,10 +370,14 @@ final class TerminalScreenBuffer {
fg = DEFAULT_FG;
bg = DEFAULT_BG;
bold = false;
dim = false;
} else if (value == 1) {
bold = true;
} else if (value == 2) {
dim = true;
} else if (value == 22) {
bold = false;
dim = false;
} else if (value == 39) {
fg = DEFAULT_FG;
} else if (value == 49) {
@@ -302,14 +419,34 @@ final class TerminalScreenBuffer {
wrapPending = false;
newLine();
}
cells[cursorRow][cursorCol].set(value, fg, bg, bold);
if (cursorCol == cols - 1) {
int width = isWideCharacter(value) ? 2 : 1;
if (width == 2 && cursorCol == cols - 1) {
newLine();
}
cells[cursorRow][cursorCol].set(value, fg, bg, bold, dim);
if (width == 2) {
cells[cursorRow][cursorCol + 1].setContinuation(fg, bg, bold, dim);
}
if (cursorCol + width >= cols) {
cursorCol = cols - 1;
wrapPending = true;
} else {
cursorCol++;
cursorCol += width;
}
}
private boolean isWideCharacter(char value) {
return value >= '\u1100' && (value <= '\u115f'
|| value == '\u2329' || value == '\u232a'
|| (value >= '\u2e80' && value <= '\ua4cf' && value != '\u303f')
|| (value >= '\uac00' && value <= '\ud7a3')
|| (value >= '\uf900' && value <= '\ufaff')
|| (value >= '\ufe10' && value <= '\ufe19')
|| (value >= '\ufe30' && value <= '\ufe6f')
|| (value >= '\uff00' && value <= '\uff60')
|| (value >= '\uffe0' && value <= '\uffe6'));
}
private void newLine() {
wrapPending = false;
cursorRow++;
@@ -453,15 +590,21 @@ final class TerminalScreenBuffer {
int fgColor = first.fg;
int bgColor = first.bg;
boolean isBold = first.bold;
boolean isDim = first.dim;
while (col < cols) {
Cell cell = cells[row][col];
if (cell.fg != fgColor || cell.bg != bgColor || cell.bold != isBold) {
if (cell.fg != fgColor || cell.bg != bgColor || cell.bold != isBold || cell.dim != isDim) {
break;
}
output.append(cell.value);
if (!cell.continuation) {
output.append(cell.value);
}
col++;
}
int end = output.length();
if (isDim) {
fgColor = blendColor(fgColor, bgColor == DEFAULT_BG ? TERMINAL_BG : bgColor, 0.55f);
}
output.setSpan(new ForegroundColorSpan(fgColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (bgColor != DEFAULT_BG) {
output.setSpan(new BackgroundColorSpan(bgColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
@@ -472,6 +615,29 @@ final class TerminalScreenBuffer {
}
}
private String rowText(int row) {
StringBuilder text = new StringBuilder(cols);
for (int col = 0; col < cols; col++) {
Cell cell = cells[row][col];
if (!cell.continuation) {
text.append(cell.value);
}
}
return text.toString();
}
private boolean containsHan(String text) {
for (int index = 0; index < text.length(); index++) {
Character.UnicodeBlock block = Character.UnicodeBlock.of(text.charAt(index));
if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS) {
return true;
}
}
return false;
}
private void saveCursor() {
savedRow = cursorRow;
savedCol = cursorCol;
@@ -613,6 +779,15 @@ final class TerminalScreenBuffer {
return value == 0 ? 0 : 55 + value * 40;
}
private static int blendColor(int foreground, int background, float foregroundRatio) {
float backgroundRatio = 1f - foregroundRatio;
return Color.rgb(
Math.round(Color.red(foreground) * foregroundRatio + Color.red(background) * backgroundRatio),
Math.round(Color.green(foreground) * foregroundRatio + Color.green(background) * backgroundRatio),
Math.round(Color.blue(foreground) * foregroundRatio + Color.blue(background) * backgroundRatio)
);
}
private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
@@ -622,19 +797,34 @@ final class TerminalScreenBuffer {
int fg = DEFAULT_FG;
int bg = DEFAULT_BG;
boolean bold;
boolean dim;
boolean continuation;
void clear() {
value = ' ';
fg = DEFAULT_FG;
bg = DEFAULT_BG;
bold = false;
dim = false;
continuation = false;
}
void set(char nextValue, int nextFg, int nextBg, boolean nextBold) {
void set(char nextValue, int nextFg, int nextBg, boolean nextBold, boolean nextDim) {
value = nextValue;
fg = nextFg;
bg = nextBg;
bold = nextBold;
dim = nextDim;
continuation = false;
}
void setContinuation(int nextFg, int nextBg, boolean nextBold, boolean nextDim) {
value = ' ';
fg = nextFg;
bg = nextBg;
bold = nextBold;
dim = nextDim;
continuation = true;
}
void copyFrom(Cell other) {
@@ -642,6 +832,8 @@ final class TerminalScreenBuffer {
fg = other.fg;
bg = other.bg;
bold = other.bold;
dim = other.dim;
continuation = other.continuation;
}
}
}
@@ -6,6 +6,7 @@ import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
@@ -19,6 +20,10 @@ import java.util.concurrent.RejectedExecutionException;
import javax.net.ssl.SSLSocketFactory;
final class TerminalSocketClient {
private static final long HEARTBEAT_INTERVAL_MS = 15000L;
private static final int SOCKET_CONNECT_TIMEOUT_MS = 10000;
private static final int SOCKET_READ_TIMEOUT_MS = 45000;
interface Listener {
void onConnected();
void onOutput(String data);
@@ -35,6 +40,7 @@ final class TerminalSocketClient {
private BufferedOutputStream output;
private volatile boolean closed;
private Thread thread;
private Thread heartbeatThread;
TerminalSocketClient(Listener listener) {
this.listener = listener;
@@ -97,6 +103,7 @@ final class TerminalSocketClient {
"rows", rows
);
listener.onConnected();
startHeartbeat();
readLoop();
} catch (Exception error) {
if (!closed) {
@@ -123,10 +130,39 @@ final class TerminalSocketClient {
if (port == -1) {
port = "wss".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
Socket raw = new Socket();
raw.connect(new InetSocketAddress(uri.getHost(), port), SOCKET_CONNECT_TIMEOUT_MS);
Socket connected;
if ("wss".equalsIgnoreCase(uri.getScheme())) {
return SSLSocketFactory.getDefault().createSocket(uri.getHost(), port);
connected = ((SSLSocketFactory) SSLSocketFactory.getDefault())
.createSocket(raw, uri.getHost(), port, true);
} else {
connected = raw;
}
return new Socket(uri.getHost(), port);
connected.setKeepAlive(true);
connected.setTcpNoDelay(true);
connected.setSoTimeout(SOCKET_READ_TIMEOUT_MS);
return connected;
}
private void startHeartbeat() {
heartbeatThread = new Thread(() -> {
while (!closed) {
try {
Thread.sleep(HEARTBEAT_INTERVAL_MS);
if (!closed) {
sendFrame(9, new byte[0]);
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
return;
} catch (Exception error) {
closeSocketQuietly();
return;
}
}
}, "terminal-ws-heartbeat");
heartbeatThread.start();
}
private void handshake(URI uri) throws Exception {
@@ -279,6 +315,10 @@ final class TerminalSocketClient {
}
private void closeSocketQuietly() {
if (heartbeatThread != null) {
heartbeatThread.interrupt();
heartbeatThread = null;
}
try {
if (socket != null) {
socket.close();