Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b13d42930 | ||
|
|
72913c5571 | ||
|
|
dd056c8027 |
@@ -13,14 +13,17 @@ APIs directly:
|
|||||||
session counts
|
session counts
|
||||||
- native tmux session list
|
- native tmux session list
|
||||||
- Sessions page with current API/server and loaded session count
|
- Sessions page with current API/server and loaded session count
|
||||||
|
- native Projects page for kanban project grouping, project agents, project
|
||||||
|
messages, add/remove session, create, and delete actions
|
||||||
- create, rename, send command, split pane, select pane, kill pane, pin, mute,
|
- create, rename, send command, split pane, select pane, kill pane, pin, mute,
|
||||||
and kill session through HTTP API
|
and kill session through HTTP API
|
||||||
- open one live terminal viewer through `/ws/terminal`
|
- open one live terminal viewer through `/ws/terminal`
|
||||||
- native `/ws/events` listener for session invalidation and hook notifications
|
- native `/ws/events` listener for session invalidation and hook notifications
|
||||||
- selectable GitHub or Gitea update manifest source
|
- selectable Gitea or GitHub update source, defaulting to Gitea for mobile
|
||||||
- native Tools page for health, server status, timeline, preferences,
|
reachability
|
||||||
kanban projects, group messages, hook events, image file/URL upload, image
|
- native Tools page for health, server status, timeline, preferences, hook
|
||||||
preview info, and native image preview display
|
events, image file/URL upload, image preview info, and native image preview
|
||||||
|
display
|
||||||
- mobile soft-key row for tmux-oriented input, including tmux prefix, detach,
|
- mobile soft-key row for tmux-oriented input, including tmux prefix, detach,
|
||||||
new window, previous/next window, Ctrl keys, arrows, page keys, and paste
|
new window, previous/next window, Ctrl keys, arrows, page keys, and paste
|
||||||
- automatic update checks against the selected release manifest
|
- automatic update checks against the selected release manifest
|
||||||
@@ -91,10 +94,10 @@ https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux
|
|||||||
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
|
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Those GitHub links are the primary public install/update channel. Gitea releases
|
Gitea is the app's default install/update channel because phones may not be able
|
||||||
are mirrored as a second public source. This Gitea instance does not support the
|
to reach GitHub reliably. GitHub remains an optional public source. This Gitea
|
||||||
GitHub-style `/releases/latest/download/...` URL, so the app uses the Gitea
|
instance does not support the GitHub-style `/releases/latest/download/...` URL,
|
||||||
Release API as the stable Gitea update entrypoint.
|
so the app uses the Gitea Release API as the stable Gitea update entrypoint.
|
||||||
|
|
||||||
Plain branch builds only create Actions artifacts; they are useful for CI
|
Plain branch builds only create Actions artifacts; they are useful for CI
|
||||||
verification, but releases are the stable download/update channel.
|
verification, but releases are the stable download/update channel.
|
||||||
@@ -111,17 +114,21 @@ messages unchanged: `attach`, `input`, `resize`, `scroll`, and `clear-history`.
|
|||||||
The first Android UI renders terminal output as monospace text with basic ANSI
|
The first Android UI renders terminal output as monospace text with basic ANSI
|
||||||
SGR color support. The terminal view stays bottom-aligned when output is short,
|
SGR color support. The terminal view stays bottom-aligned when output is short,
|
||||||
auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard
|
auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard
|
||||||
opens. It is enough for shell-oriented remote testing, but it is not yet a
|
opens. Rendering is throttled and the local terminal buffer is capped so opening
|
||||||
complete xterm-compatible renderer for full-screen TUIs such as `vim` or `top`.
|
busy sessions does not block the UI thread. Input typed before the terminal
|
||||||
|
attach message is sent is queued and flushed after the WebSocket client is ready.
|
||||||
|
It is enough for shell-oriented remote testing, but it is not yet a complete
|
||||||
|
xterm-compatible renderer for full-screen TUIs such as `vim` or `top`.
|
||||||
|
|
||||||
The terminal toolbar and shortcut row include tmux prefix helpers. The app sends
|
The terminal toolbar and shortcut row include tmux prefix helpers. The app sends
|
||||||
the same control bytes a keyboard would send, for example `Ctrl+B`, `Ctrl+B d`,
|
the same control bytes a keyboard would send, for example `Ctrl+B`, `Ctrl+B d`,
|
||||||
`Ctrl+B c`, `Ctrl+B n`, and `Ctrl+B p`.
|
`Ctrl+B c`, `Ctrl+B n`, and `Ctrl+B p`.
|
||||||
|
|
||||||
All app features are native Android controls. Complex server objects such as
|
All app features are native Android controls. Complex server objects such as
|
||||||
kanban projects, preferences, timeline events, group messages, and image metadata
|
preferences, timeline events, group messages, and image metadata currently use
|
||||||
currently use native forms plus native JSON detail dialogs; image preview uses a
|
native forms plus native JSON detail dialogs; kanban projects use a native
|
||||||
native `ImageView`. The app does not load the browser UI.
|
project/agent list; image preview uses a native `ImageView`. The app does not
|
||||||
|
load the browser UI.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
@@ -140,28 +147,24 @@ release manifest automatically, downloads the newer APK, verifies its SHA-256,
|
|||||||
then opens Android's package installer. The user still has to approve the
|
then opens Android's package installer. The user still has to approve the
|
||||||
install, and Android 8+ may require allowing this app to install unknown apps.
|
install, and Android 8+ may require allowing this app to install unknown apps.
|
||||||
|
|
||||||
The default update manifest is:
|
The default update source is the Gitea Release API:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The default source resolves `latest.json` and `tmux-android.apk` from Gitea
|
||||||
|
release assets, so APK downloads do not require GitHub. The optional GitHub
|
||||||
|
manifest is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
|
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The public manual APK download is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
The app checks only the selected update source. It does not probe GitHub and
|
The app checks only the selected update source. It does not probe GitHub and
|
||||||
Gitea during the same update check. Choose the source in the app's `Update`
|
Gitea during the same update check. Choose the source in the app's `Update`
|
||||||
page, or use a custom manifest/API URL.
|
page, or use a custom manifest/API URL.
|
||||||
|
|
||||||
The built-in Gitea API endpoint is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Gitea tag-specific assets are also public, for example:
|
Gitea tag-specific assets are also public, for example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ val repoSlug = providers.gradleProperty("repoSlug")
|
|||||||
val defaultServerUrl = providers.gradleProperty("defaultServerUrl")
|
val defaultServerUrl = providers.gradleProperty("defaultServerUrl")
|
||||||
.orElse("http://100.89.0.116:3000")
|
.orElse("http://100.89.0.116:3000")
|
||||||
val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
|
val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
|
||||||
|
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
|
||||||
|
val defaultGithubUpdateUrl = providers.gradleProperty("defaultGithubUpdateUrl")
|
||||||
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json")
|
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json")
|
||||||
val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl")
|
val defaultGiteaUpdateUrl = providers.gradleProperty("defaultGiteaUpdateUrl")
|
||||||
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
|
.orElse("https://gitea.neatcn.com/api/v1/repos/tmux/tmux-browser-android/releases/latest")
|
||||||
@@ -36,6 +38,7 @@ android {
|
|||||||
|
|
||||||
buildConfigField("String", "DEFAULT_SERVER_URL", "\"${defaultServerUrl.get()}\"")
|
buildConfigField("String", "DEFAULT_SERVER_URL", "\"${defaultServerUrl.get()}\"")
|
||||||
buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.get()}\"")
|
buildConfigField("String", "DEFAULT_UPDATE_URL", "\"${defaultUpdateUrl.get()}\"")
|
||||||
|
buildConfigField("String", "DEFAULT_GITHUB_UPDATE_URL", "\"${defaultGithubUpdateUrl.get()}\"")
|
||||||
buildConfigField("String", "DEFAULT_GITEA_UPDATE_URL", "\"${defaultGiteaUpdateUrl.get()}\"")
|
buildConfigField("String", "DEFAULT_GITEA_UPDATE_URL", "\"${defaultGiteaUpdateUrl.get()}\"")
|
||||||
buildConfigField("String", "DEFAULT_APK_URL", "\"${defaultApkUrl.get()}\"")
|
buildConfigField("String", "DEFAULT_APK_URL", "\"${defaultApkUrl.get()}\"")
|
||||||
buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"")
|
buildConfigField("String", "DEFAULT_RELEASE_PAGE_URL", "\"${defaultReleasePageUrl.get()}\"")
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import android.widget.ScrollView;
|
|||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
import java.io.BufferedInputStream;
|
import java.io.BufferedInputStream;
|
||||||
@@ -56,10 +57,13 @@ public final class MainActivity extends Activity {
|
|||||||
private static final long AUTO_UPDATE_INTERVAL_MS = 6L * 60L * 60L * 1000L;
|
private static final long AUTO_UPDATE_INTERVAL_MS = 6L * 60L * 60L * 1000L;
|
||||||
private static final int TERMINAL_COLS = 96;
|
private static final int TERMINAL_COLS = 96;
|
||||||
private static final int TERMINAL_ROWS = 32;
|
private static final int TERMINAL_ROWS = 32;
|
||||||
private static final int MAX_TERMINAL_CHARS = 120_000;
|
private static final int MAX_TERMINAL_CHARS = 40_000;
|
||||||
|
private static final long TERMINAL_RENDER_INTERVAL_MS = 80L;
|
||||||
private static final String OLD_LOCAL_DEFAULT_URL = "http://127.0.0.1:3000";
|
private static final String OLD_LOCAL_DEFAULT_URL = "http://127.0.0.1:3000";
|
||||||
|
private static final String OLD_GITHUB_DEFAULT_UPDATE_URL = "https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json";
|
||||||
private static final String DEFAULT_TAILSCALE_URL = "http://100.89.0.116:3000";
|
private static final String DEFAULT_TAILSCALE_URL = "http://100.89.0.116:3000";
|
||||||
private static final String PAGE_SESSIONS = "Sessions";
|
private static final String PAGE_SESSIONS = "Sessions";
|
||||||
|
private static final String PAGE_PROJECTS = "Projects";
|
||||||
private static final String PAGE_TOOLS = "Tools";
|
private static final String PAGE_TOOLS = "Tools";
|
||||||
private static final String PAGE_UPDATE = "Update";
|
private static final String PAGE_UPDATE = "Update";
|
||||||
private static final String PAGE_ABOUT = "About";
|
private static final String PAGE_ABOUT = "About";
|
||||||
@@ -82,6 +86,7 @@ public final class MainActivity extends Activity {
|
|||||||
private ProgressBar progressBar;
|
private ProgressBar progressBar;
|
||||||
private TextView statusText;
|
private TextView statusText;
|
||||||
private TextView sessionSummaryText;
|
private TextView sessionSummaryText;
|
||||||
|
private LinearLayout projectList;
|
||||||
private TextView terminalText;
|
private TextView terminalText;
|
||||||
private ScrollView terminalScroll;
|
private ScrollView terminalScroll;
|
||||||
private EditText inputField;
|
private EditText inputField;
|
||||||
@@ -89,6 +94,10 @@ public final class MainActivity extends Activity {
|
|||||||
private String activeMainPage = PAGE_SESSIONS;
|
private String activeMainPage = PAGE_SESSIONS;
|
||||||
private String pendingImageUploadSession;
|
private String pendingImageUploadSession;
|
||||||
private final StringBuilder terminalBuffer = new StringBuilder();
|
private final StringBuilder terminalBuffer = new StringBuilder();
|
||||||
|
private final StringBuilder queuedTerminalInput = new StringBuilder();
|
||||||
|
private boolean terminalConnected;
|
||||||
|
private boolean terminalRenderPending;
|
||||||
|
private long lastTerminalRenderMs;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
@@ -108,6 +117,7 @@ public final class MainActivity extends Activity {
|
|||||||
.putBoolean("tailscale_defaults_applied_v1", true)
|
.putBoolean("tailscale_defaults_applied_v1", true)
|
||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
migrateDefaultUpdateSourceToGitea();
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
getWindow().setStatusBarColor(Color.rgb(17, 20, 24));
|
getWindow().setStatusBarColor(Color.rgb(17, 20, 24));
|
||||||
getWindow().setNavigationBarColor(Color.rgb(17, 20, 24));
|
getWindow().setNavigationBarColor(Color.rgb(17, 20, 24));
|
||||||
@@ -282,6 +292,59 @@ public final class MainActivity extends Activity {
|
|||||||
setStatus("Tools");
|
setStatus("Tools");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void renderProjectsScreen() {
|
||||||
|
closeTerminalSocket();
|
||||||
|
activeSessionName = null;
|
||||||
|
activeMainPage = PAGE_PROJECTS;
|
||||||
|
root.removeAllViews();
|
||||||
|
root.addView(createServerBar(), matchWrap());
|
||||||
|
root.addView(createMainTabs(PAGE_PROJECTS), new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
dp(48)
|
||||||
|
));
|
||||||
|
root.addView(progressBar, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
dp(3)
|
||||||
|
));
|
||||||
|
|
||||||
|
ScrollView scroll = new ScrollView(this);
|
||||||
|
LinearLayout content = pageContent();
|
||||||
|
content.addView(infoBlock(
|
||||||
|
"Project groups",
|
||||||
|
"Kanban projects group tmux sessions and group messages through the server API."
|
||||||
|
));
|
||||||
|
content.addView(sectionTitle("Projects"));
|
||||||
|
content.addView(actionPanel(
|
||||||
|
actionButton("Refresh", view -> refreshProjects()),
|
||||||
|
actionButton("New project", view -> promptCreateKanbanProject()),
|
||||||
|
actionButton("Delete project", view -> promptDeleteKanbanProject()),
|
||||||
|
actionButton("Remove session", view -> promptRemoveKanbanSession())
|
||||||
|
));
|
||||||
|
projectList = new LinearLayout(this);
|
||||||
|
projectList.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
content.addView(projectList, matchWrap());
|
||||||
|
|
||||||
|
content.addView(sectionTitle("Group messages"));
|
||||||
|
content.addView(actionPanel(
|
||||||
|
actionButton("Messages", view -> promptGroupMessages()),
|
||||||
|
actionButton("Send message", view -> promptSendGroupMessage()),
|
||||||
|
actionButton("Scan message", view -> promptScanGroupMessage()),
|
||||||
|
actionButton("Post hook", view -> promptPostHookEvent())
|
||||||
|
));
|
||||||
|
scroll.addView(content);
|
||||||
|
root.addView(scroll, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
));
|
||||||
|
root.addView(statusText, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
dp(28)
|
||||||
|
));
|
||||||
|
setStatus("Projects");
|
||||||
|
refreshProjects();
|
||||||
|
}
|
||||||
|
|
||||||
private void renderUpdateScreen() {
|
private void renderUpdateScreen() {
|
||||||
closeTerminalSocket();
|
closeTerminalSocket();
|
||||||
activeSessionName = null;
|
activeSessionName = null;
|
||||||
@@ -435,6 +498,11 @@ public final class MainActivity extends Activity {
|
|||||||
actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles()));
|
actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (PAGE_PROJECTS.equals(activeMainPage)) {
|
||||||
|
actionRow.addView(toolbarButton("Refresh", view -> refreshProjects()));
|
||||||
|
actionRow.addView(toolbarButton("New", view -> promptCreateKanbanProject()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (PAGE_TOOLS.equals(activeMainPage)) {
|
if (PAGE_TOOLS.equals(activeMainPage)) {
|
||||||
actionRow.addView(toolbarButton("Health", view -> showRaw("Health", () -> api.health())));
|
actionRow.addView(toolbarButton("Health", view -> showRaw("Health", () -> api.health())));
|
||||||
actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles()));
|
actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles()));
|
||||||
@@ -496,6 +564,7 @@ public final class MainActivity extends Activity {
|
|||||||
renderSessionScreen();
|
renderSessionScreen();
|
||||||
refreshSessions();
|
refreshSessions();
|
||||||
}));
|
}));
|
||||||
|
row.addView(navButton(PAGE_PROJECTS, selected, view -> renderProjectsScreen()));
|
||||||
row.addView(navButton(PAGE_TOOLS, selected, view -> renderToolsScreen()));
|
row.addView(navButton(PAGE_TOOLS, selected, view -> renderToolsScreen()));
|
||||||
row.addView(navButton(PAGE_UPDATE, selected, view -> renderUpdateScreen()));
|
row.addView(navButton(PAGE_UPDATE, selected, view -> renderUpdateScreen()));
|
||||||
row.addView(navButton(PAGE_ABOUT, selected, view -> renderAboutScreen()));
|
row.addView(navButton(PAGE_ABOUT, selected, view -> renderAboutScreen()));
|
||||||
@@ -617,6 +686,8 @@ public final class MainActivity extends Activity {
|
|||||||
connectAppEvents();
|
connectAppEvents();
|
||||||
if (PAGE_SESSIONS.equals(activeMainPage)) {
|
if (PAGE_SESSIONS.equals(activeMainPage)) {
|
||||||
refreshSessions();
|
refreshSessions();
|
||||||
|
} else if (PAGE_PROJECTS.equals(activeMainPage)) {
|
||||||
|
refreshProjects();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,6 +711,141 @@ public final class MainActivity extends Activity {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void refreshProjects() {
|
||||||
|
if (projectList != null) {
|
||||||
|
projectList.removeAllViews();
|
||||||
|
projectList.addView(projectStateText("Loading projects..."), matchWrap());
|
||||||
|
}
|
||||||
|
setStatus("Loading projects from " + api.getBaseUrl());
|
||||||
|
progressBar.setVisibility(View.VISIBLE);
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
String text = api.kanbanProjects();
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
renderProjectList(text);
|
||||||
|
setStatus("Loaded projects");
|
||||||
|
});
|
||||||
|
} catch (Exception error) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
if (projectList != null) {
|
||||||
|
projectList.removeAllViews();
|
||||||
|
projectList.addView(projectStateText("Project load failed:\n" + error.getMessage()), matchWrap());
|
||||||
|
}
|
||||||
|
showMessage("Project load failed: " + error.getMessage());
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
runOnUiThread(() -> progressBar.setVisibility(View.GONE));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderProjectList(String text) {
|
||||||
|
if (projectList == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
projectList.removeAllViews();
|
||||||
|
try {
|
||||||
|
JSONObject rootObject = new JSONObject(text == null || text.isEmpty() ? "{}" : text);
|
||||||
|
JSONArray projects = rootObject.optJSONArray("projects");
|
||||||
|
if (projects == null || projects.length() == 0) {
|
||||||
|
projectList.addView(projectStateText("No projects"), matchWrap());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int index = 0; index < projects.length(); index++) {
|
||||||
|
projectList.addView(projectCard(projects.getJSONObject(index)), matchWrap());
|
||||||
|
}
|
||||||
|
} catch (Exception error) {
|
||||||
|
projectList.addView(projectStateText(text == null || text.isEmpty() ? "(empty)" : text), matchWrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextView projectStateText(String text) {
|
||||||
|
TextView view = bodyText(text);
|
||||||
|
view.setTypeface(Typeface.MONOSPACE);
|
||||||
|
view.setTextIsSelectable(true);
|
||||||
|
view.setPadding(dp(12), dp(10), dp(12), dp(10));
|
||||||
|
view.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(42, 51, 61), 1));
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
private View projectCard(JSONObject project) {
|
||||||
|
String name = project.optString("name", "(unnamed)");
|
||||||
|
String path = project.optString("path", "");
|
||||||
|
String server = project.isNull("server") ? "" : project.optString("server", "");
|
||||||
|
JSONArray agents = project.optJSONArray("agents");
|
||||||
|
int agentCount = agents == null ? 0 : agents.length();
|
||||||
|
|
||||||
|
LinearLayout card = new LinearLayout(this);
|
||||||
|
card.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
card.setPadding(dp(12), dp(11), dp(12), dp(11));
|
||||||
|
card.setBackground(rounded(Color.rgb(27, 33, 40), 8, Color.rgb(45, 54, 64), 1));
|
||||||
|
|
||||||
|
TextView title = new TextView(this);
|
||||||
|
title.setText(name);
|
||||||
|
title.setTextColor(Color.WHITE);
|
||||||
|
title.setTextSize(17);
|
||||||
|
title.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
|
||||||
|
String detail = "path:" + defaultValue(path, "~")
|
||||||
|
+ (server.isEmpty() ? "" : " server:" + server)
|
||||||
|
+ " agents:" + agentCount;
|
||||||
|
TextView meta = bodyText(detail);
|
||||||
|
meta.setPadding(0, dp(4), 0, dp(8));
|
||||||
|
|
||||||
|
LinearLayout actions = new LinearLayout(this);
|
||||||
|
actions.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
actions.addView(toolbarButton("Messages", view -> showRaw("Group messages: " + name, () -> api.groupMessages(name))));
|
||||||
|
actions.addView(toolbarButton("Add", view -> promptAddKanbanSessionToProject(name)));
|
||||||
|
actions.addView(toolbarButton("Delete", view -> runApiAction("Delete kanban project", () -> api.deleteKanbanProject(name))));
|
||||||
|
|
||||||
|
card.addView(title);
|
||||||
|
card.addView(meta);
|
||||||
|
card.addView(actions);
|
||||||
|
|
||||||
|
if (agents != null && agents.length() > 0) {
|
||||||
|
TextView agentsTitle = bodyText("Agents");
|
||||||
|
agentsTitle.setTextColor(Color.rgb(139, 148, 158));
|
||||||
|
agentsTitle.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
agentsTitle.setPadding(0, dp(10), 0, dp(4));
|
||||||
|
card.addView(agentsTitle);
|
||||||
|
for (int index = 0; index < agents.length(); index++) {
|
||||||
|
JSONObject agent = agents.optJSONObject(index);
|
||||||
|
if (agent != null) {
|
||||||
|
card.addView(projectAgentRow(name, agent), matchWrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
);
|
||||||
|
params.bottomMargin = dp(8);
|
||||||
|
card.setLayoutParams(params);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
private View projectAgentRow(String projectName, JSONObject agent) {
|
||||||
|
String sessionName = defaultValue(agent.optString("sessionName", ""), agent.optString("name", ""));
|
||||||
|
String label = defaultValue(agent.optString("name", ""), sessionName);
|
||||||
|
String kind = agent.optString("kind", "session");
|
||||||
|
|
||||||
|
LinearLayout row = new LinearLayout(this);
|
||||||
|
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
row.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
|
row.setPadding(0, dp(3), 0, dp(3));
|
||||||
|
|
||||||
|
TextView name = bodyText(kind + " " + label);
|
||||||
|
name.setSingleLine(true);
|
||||||
|
row.addView(name, new LinearLayout.LayoutParams(0, dp(38), 1));
|
||||||
|
if (!sessionName.isEmpty()) {
|
||||||
|
row.addView(toolbarButton("Open", view -> openTerminal(sessionName)));
|
||||||
|
row.addView(toolbarButton("Remove", view -> runApiAction("Remove kanban session", () ->
|
||||||
|
api.removeKanbanSession(projectName, sessionName, false))));
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
private void renderSessionList(List<SessionSummary> sessions) {
|
private void renderSessionList(List<SessionSummary> sessions) {
|
||||||
LinearLayout list = root.findViewWithTag("session-list");
|
LinearLayout list = root.findViewWithTag("session-list");
|
||||||
if (list == null) {
|
if (list == null) {
|
||||||
@@ -753,6 +959,10 @@ public final class MainActivity extends Activity {
|
|||||||
private void openTerminal(String sessionName) {
|
private void openTerminal(String sessionName) {
|
||||||
activeSessionName = sessionName;
|
activeSessionName = sessionName;
|
||||||
terminalBuffer.setLength(0);
|
terminalBuffer.setLength(0);
|
||||||
|
queuedTerminalInput.setLength(0);
|
||||||
|
terminalConnected = false;
|
||||||
|
terminalRenderPending = false;
|
||||||
|
lastTerminalRenderMs = 0L;
|
||||||
root.removeAllViews();
|
root.removeAllViews();
|
||||||
root.addView(createTerminalTopBar(sessionName), matchWrap());
|
root.addView(createTerminalTopBar(sessionName), matchWrap());
|
||||||
|
|
||||||
@@ -1023,6 +1233,10 @@ public final class MainActivity extends Activity {
|
|||||||
promptText("Add to kanban project", "project", "", projectName -> api.addKanbanSession(projectName, sessionName));
|
promptText("Add to kanban project", "project", "", projectName -> api.addKanbanSession(projectName, sessionName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void promptAddKanbanSessionToProject(String projectName) {
|
||||||
|
promptText("Add session to " + projectName, "session", "", sessionName -> api.addKanbanSession(projectName, sessionName));
|
||||||
|
}
|
||||||
|
|
||||||
private void promptRemoveKanbanSession() {
|
private void promptRemoveKanbanSession() {
|
||||||
LinearLayout form = formRoot();
|
LinearLayout form = formRoot();
|
||||||
EditText project = formField(form, "Project", "");
|
EditText project = formField(form, "Project", "");
|
||||||
@@ -1226,8 +1440,8 @@ public final class MainActivity extends Activity {
|
|||||||
.append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL))
|
.append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL))
|
||||||
.append('\n');
|
.append('\n');
|
||||||
text.append("Available sources:\n");
|
text.append("Available sources:\n");
|
||||||
text.append("GitHub: ").append(BuildConfig.DEFAULT_UPDATE_URL).append('\n');
|
text.append("Gitea default: ").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
|
||||||
text.append("Gitea: ").append(BuildConfig.DEFAULT_GITEA_UPDATE_URL).append('\n');
|
text.append("GitHub optional: ").append(BuildConfig.DEFAULT_GITHUB_UPDATE_URL).append('\n');
|
||||||
text.append('\n');
|
text.append('\n');
|
||||||
text.append(permissionSummary());
|
text.append(permissionSummary());
|
||||||
|
|
||||||
@@ -1264,17 +1478,17 @@ public final class MainActivity extends Activity {
|
|||||||
|
|
||||||
private void showUpdateSourcePicker() {
|
private void showUpdateSourcePicker() {
|
||||||
String[] items = {
|
String[] items = {
|
||||||
"GitHub: " + BuildConfig.DEFAULT_UPDATE_URL,
|
"Gitea default: " + BuildConfig.DEFAULT_GITEA_UPDATE_URL,
|
||||||
"Gitea: " + BuildConfig.DEFAULT_GITEA_UPDATE_URL,
|
"GitHub optional: " + BuildConfig.DEFAULT_GITHUB_UPDATE_URL,
|
||||||
"Custom URL"
|
"Custom URL"
|
||||||
};
|
};
|
||||||
new AlertDialog.Builder(this)
|
new AlertDialog.Builder(this)
|
||||||
.setTitle("Update source")
|
.setTitle("Update source")
|
||||||
.setItems(items, (dialog, which) -> {
|
.setItems(items, (dialog, which) -> {
|
||||||
if (which == 0) {
|
if (which == 0) {
|
||||||
setUpdateUrl(BuildConfig.DEFAULT_UPDATE_URL);
|
|
||||||
} else if (which == 1) {
|
|
||||||
setUpdateUrl(BuildConfig.DEFAULT_GITEA_UPDATE_URL);
|
setUpdateUrl(BuildConfig.DEFAULT_GITEA_UPDATE_URL);
|
||||||
|
} else if (which == 1) {
|
||||||
|
setUpdateUrl(BuildConfig.DEFAULT_GITHUB_UPDATE_URL);
|
||||||
} else {
|
} else {
|
||||||
promptText("Custom update manifest", "https://.../latest.json", prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL), this::setUpdateUrl);
|
promptText("Custom update manifest", "https://.../latest.json", prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL), this::setUpdateUrl);
|
||||||
}
|
}
|
||||||
@@ -1292,6 +1506,15 @@ public final class MainActivity extends Activity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void migrateDefaultUpdateSourceToGitea() {
|
||||||
|
String current = prefs.getString("update_url", "");
|
||||||
|
if (current == null || current.trim().isEmpty() || OLD_GITHUB_DEFAULT_UPDATE_URL.equals(current.trim())) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString("update_url", BuildConfig.DEFAULT_GITEA_UPDATE_URL)
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void probeServerProfiles() {
|
private void probeServerProfiles() {
|
||||||
progressBar.setVisibility(View.VISIBLE);
|
progressBar.setVisibility(View.VISIBLE);
|
||||||
setStatus("Probing Tailscale APIs...");
|
setStatus("Probing Tailscale APIs...");
|
||||||
@@ -1405,7 +1628,9 @@ public final class MainActivity extends Activity {
|
|||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
progressBar.setVisibility(View.GONE);
|
progressBar.setVisibility(View.GONE);
|
||||||
showMessage(label + " done");
|
showMessage(label + " done");
|
||||||
if (activeSessionName == null) {
|
if (PAGE_PROJECTS.equals(activeMainPage)) {
|
||||||
|
refreshProjects();
|
||||||
|
} else if (activeSessionName == null) {
|
||||||
refreshSessions();
|
refreshSessions();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1525,12 +1750,18 @@ public final class MainActivity extends Activity {
|
|||||||
|
|
||||||
private void connectTerminal(String sessionName) {
|
private void connectTerminal(String sessionName) {
|
||||||
closeTerminalSocket();
|
closeTerminalSocket();
|
||||||
|
queuedTerminalInput.setLength(0);
|
||||||
|
terminalConnected = false;
|
||||||
setStatus("Connecting " + sessionName);
|
setStatus("Connecting " + sessionName);
|
||||||
appendTerminal("[connecting]\r\n");
|
appendTerminal("[connecting]\r\n");
|
||||||
terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() {
|
terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() {
|
||||||
@Override
|
@Override
|
||||||
public void onConnected() {
|
public void onConnected() {
|
||||||
runOnUiThread(() -> setStatus("Connected " + sessionName));
|
runOnUiThread(() -> {
|
||||||
|
terminalConnected = true;
|
||||||
|
setStatus("Connected " + sessionName);
|
||||||
|
flushQueuedTerminalInput();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -1548,7 +1779,10 @@ public final class MainActivity extends Activity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onClosed() {
|
public void onClosed() {
|
||||||
runOnUiThread(() -> setStatus("Disconnected " + sessionName));
|
runOnUiThread(() -> {
|
||||||
|
terminalConnected = false;
|
||||||
|
setStatus("Disconnected " + sessionName);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
terminalSocket.connect(api.getBaseUrl(), sessionName, TERMINAL_COLS, TERMINAL_ROWS);
|
terminalSocket.connect(api.getBaseUrl(), sessionName, TERMINAL_COLS, TERMINAL_ROWS);
|
||||||
@@ -1572,10 +1806,15 @@ public final class MainActivity extends Activity {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
TerminalSocketClient socket = terminalSocket;
|
TerminalSocketClient socket = terminalSocket;
|
||||||
if (socket != null) {
|
if (socket != null && !socket.isClosed() && terminalConnected) {
|
||||||
socket.sendInput(data);
|
socket.sendInput(data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (socket != null && !socket.isClosed()) {
|
||||||
|
queuedTerminalInput.append(data);
|
||||||
|
setStatus("Queued input until terminal connects");
|
||||||
|
return;
|
||||||
|
}
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
for (int i = 0; i < data.length(); i += 200) {
|
for (int i = 0; i < data.length(); i += 200) {
|
||||||
@@ -1587,6 +1826,15 @@ public final class MainActivity extends Activity {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void flushQueuedTerminalInput() {
|
||||||
|
if (!terminalConnected || terminalSocket == null || queuedTerminalInput.length() == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String data = queuedTerminalInput.toString();
|
||||||
|
queuedTerminalInput.setLength(0);
|
||||||
|
terminalSocket.sendInput(data);
|
||||||
|
}
|
||||||
|
|
||||||
private void pasteClipboard() {
|
private void pasteClipboard() {
|
||||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||||
if (clipboard == null || !clipboard.hasPrimaryClip()) {
|
if (clipboard == null || !clipboard.hasPrimaryClip()) {
|
||||||
@@ -1607,6 +1855,27 @@ public final class MainActivity extends Activity {
|
|||||||
if (terminalBuffer.length() > MAX_TERMINAL_CHARS) {
|
if (terminalBuffer.length() > MAX_TERMINAL_CHARS) {
|
||||||
terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS);
|
terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS);
|
||||||
}
|
}
|
||||||
|
scheduleTerminalRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleTerminalRender() {
|
||||||
|
if (terminalRenderPending || terminalText == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminalRenderPending = true;
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long delay = Math.max(0L, TERMINAL_RENDER_INTERVAL_MS - (now - lastTerminalRenderMs));
|
||||||
|
terminalText.postDelayed(() -> {
|
||||||
|
terminalRenderPending = false;
|
||||||
|
renderTerminalNow();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderTerminalNow() {
|
||||||
|
if (terminalText == null || terminalScroll == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastTerminalRenderMs = System.currentTimeMillis();
|
||||||
terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString()));
|
terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString()));
|
||||||
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
|
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
|
||||||
}
|
}
|
||||||
@@ -1639,21 +1908,35 @@ public final class MainActivity extends Activity {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
int start = output.length();
|
int runStart = index;
|
||||||
output.append(item);
|
while (index < text.length()) {
|
||||||
int finish = output.length();
|
char runItem = text.charAt(index);
|
||||||
output.setSpan(new ForegroundColorSpan(fg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
if (runItem == '\r' || (runItem == '\u001b' && index + 1 < text.length() && text.charAt(index + 1) == '[')) {
|
||||||
if (bg != Color.TRANSPARENT) {
|
break;
|
||||||
output.setSpan(new BackgroundColorSpan(bg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
}
|
||||||
|
index++;
|
||||||
}
|
}
|
||||||
if (bold) {
|
appendTerminalRun(output, text.substring(runStart, index), fg, bg, bold);
|
||||||
output.setSpan(new StyleSpan(Typeface.BOLD), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void appendTerminalRun(SpannableStringBuilder output, String text, int fg, int bg, boolean bold) {
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int start = output.length();
|
||||||
|
output.append(text);
|
||||||
|
int finish = output.length();
|
||||||
|
output.setSpan(new ForegroundColorSpan(fg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
if (bg != Color.TRANSPARENT) {
|
||||||
|
output.setSpan(new BackgroundColorSpan(bg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
}
|
||||||
|
if (bold) {
|
||||||
|
output.setSpan(new StyleSpan(Typeface.BOLD), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private int findAnsiEnd(String text, int start) {
|
private int findAnsiEnd(String text, int start) {
|
||||||
for (int index = start; index < text.length(); index++) {
|
for (int index = start; index < text.length(); index++) {
|
||||||
char item = text.charAt(index);
|
char item = text.charAt(index);
|
||||||
@@ -1885,6 +2168,9 @@ public final class MainActivity extends Activity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void closeTerminalSocket() {
|
private void closeTerminalSocket() {
|
||||||
|
terminalConnected = false;
|
||||||
|
queuedTerminalInput.setLength(0);
|
||||||
|
terminalRenderPending = false;
|
||||||
if (terminalSocket != null) {
|
if (terminalSocket != null) {
|
||||||
terminalSocket.close();
|
terminalSocket.close();
|
||||||
terminalSocket = null;
|
terminalSocket = null;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
|
|
||||||
import javax.net.ssl.SSLSocketFactory;
|
import javax.net.ssl.SSLSocketFactory;
|
||||||
|
|
||||||
@@ -25,6 +28,8 @@ final class TerminalSocketClient {
|
|||||||
|
|
||||||
private final Object writeLock = new Object();
|
private final Object writeLock = new Object();
|
||||||
private final Listener listener;
|
private final Listener listener;
|
||||||
|
private final ExecutorService writeExecutor = Executors.newSingleThreadExecutor(runnable ->
|
||||||
|
new Thread(runnable, "terminal-ws-write"));
|
||||||
private Socket socket;
|
private Socket socket;
|
||||||
private BufferedInputStream input;
|
private BufferedInputStream input;
|
||||||
private BufferedOutputStream output;
|
private BufferedOutputStream output;
|
||||||
@@ -42,35 +47,41 @@ final class TerminalSocketClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void sendInput(String data) {
|
void sendInput(String data) {
|
||||||
sendMessage("input", "data", data);
|
sendMessageAsync("input", "data", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
void resize(int cols, int rows) {
|
void resize(int cols, int rows) {
|
||||||
sendMessage("resize", "cols", cols, "rows", rows);
|
sendMessageAsync("resize", "cols", cols, "rows", rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
void scroll(int lines) {
|
void scroll(int lines) {
|
||||||
sendMessage("scroll", "lines", lines);
|
sendMessageAsync("scroll", "lines", lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearHistory() {
|
void clearHistory() {
|
||||||
sendMessage("clear-history");
|
sendMessageAsync("clear-history");
|
||||||
}
|
}
|
||||||
|
|
||||||
void close() {
|
void close() {
|
||||||
closed = true;
|
closed = true;
|
||||||
try {
|
try {
|
||||||
sendFrame(8, new byte[0]);
|
writeExecutor.execute(() -> {
|
||||||
} catch (Exception ignored) {
|
try {
|
||||||
}
|
sendFrame(8, new byte[0]);
|
||||||
try {
|
} catch (Exception ignored) {
|
||||||
if (socket != null) {
|
}
|
||||||
socket.close();
|
closeSocketQuietly();
|
||||||
}
|
});
|
||||||
} catch (Exception ignored) {
|
writeExecutor.shutdown();
|
||||||
|
} catch (RejectedExecutionException ignored) {
|
||||||
|
closeSocketQuietly();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean isClosed() {
|
||||||
|
return closed;
|
||||||
|
}
|
||||||
|
|
||||||
private void run(String baseUrl, String sessionName, int cols, int rows) {
|
private void run(String baseUrl, String sessionName, int cols, int rows) {
|
||||||
try {
|
try {
|
||||||
URI uri = buildWsUri(baseUrl);
|
URI uri = buildWsUri(baseUrl);
|
||||||
@@ -78,14 +89,14 @@ final class TerminalSocketClient {
|
|||||||
input = new BufferedInputStream(socket.getInputStream());
|
input = new BufferedInputStream(socket.getInputStream());
|
||||||
output = new BufferedOutputStream(socket.getOutputStream());
|
output = new BufferedOutputStream(socket.getOutputStream());
|
||||||
handshake(uri);
|
handshake(uri);
|
||||||
listener.onConnected();
|
sendMessageSync(
|
||||||
sendMessage(
|
|
||||||
"attach",
|
"attach",
|
||||||
"tabId", "android-" + System.currentTimeMillis(),
|
"tabId", "android-" + System.currentTimeMillis(),
|
||||||
"sessionName", sessionName,
|
"sessionName", sessionName,
|
||||||
"cols", cols,
|
"cols", cols,
|
||||||
"rows", rows
|
"rows", rows
|
||||||
);
|
);
|
||||||
|
listener.onConnected();
|
||||||
readLoop();
|
readLoop();
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
@@ -94,12 +105,8 @@ final class TerminalSocketClient {
|
|||||||
} finally {
|
} finally {
|
||||||
closed = true;
|
closed = true;
|
||||||
listener.onClosed();
|
listener.onClosed();
|
||||||
try {
|
closeSocketQuietly();
|
||||||
if (socket != null) {
|
writeExecutor.shutdownNow();
|
||||||
socket.close();
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +246,7 @@ final class TerminalSocketClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendJson(JSONObject object) {
|
private void sendJsonSync(JSONObject object) {
|
||||||
try {
|
try {
|
||||||
sendFrame(1, object.toString().getBytes(StandardCharsets.UTF_8));
|
sendFrame(1, object.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
@@ -249,14 +256,21 @@ final class TerminalSocketClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendMessage(String type, Object... keyValues) {
|
private void sendMessageAsync(String type, Object... keyValues) {
|
||||||
|
try {
|
||||||
|
writeExecutor.execute(() -> sendMessageSync(type, keyValues));
|
||||||
|
} catch (RejectedExecutionException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendMessageSync(String type, Object... keyValues) {
|
||||||
try {
|
try {
|
||||||
JSONObject object = new JSONObject();
|
JSONObject object = new JSONObject();
|
||||||
object.put("type", type);
|
object.put("type", type);
|
||||||
for (int i = 0; i + 1 < keyValues.length; i += 2) {
|
for (int i = 0; i + 1 < keyValues.length; i += 2) {
|
||||||
object.put(String.valueOf(keyValues[i]), keyValues[i + 1]);
|
object.put(String.valueOf(keyValues[i]), keyValues[i + 1]);
|
||||||
}
|
}
|
||||||
sendJson(object);
|
sendJsonSync(object);
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
listener.onError(error.getMessage() == null ? error.toString() : error.getMessage());
|
listener.onError(error.getMessage() == null ? error.toString() : error.getMessage());
|
||||||
@@ -264,6 +278,15 @@ final class TerminalSocketClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void closeSocketQuietly() {
|
||||||
|
try {
|
||||||
|
if (socket != null) {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void sendFrame(int opcode, byte[] payload) throws Exception {
|
private void sendFrame(int opcode, byte[] payload) throws Exception {
|
||||||
synchronized (writeLock) {
|
synchronized (writeLock) {
|
||||||
if (output == null) {
|
if (output == null) {
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ Implemented now:
|
|||||||
|
|
||||||
- configurable base URL, defaulting to `http://100.89.0.116:3000`
|
- configurable base URL, defaulting to `http://100.89.0.116:3000`
|
||||||
- support for Tailscale URLs such as `http://100.x.y.z:3000`
|
- support for Tailscale URLs such as `http://100.x.y.z:3000`
|
||||||
- native multi-page shell with `Sessions`, `Tools`, `Update`, and `About`
|
- native multi-page shell with `Sessions`, `Projects`, `Tools`, `Update`, and
|
||||||
|
`About`
|
||||||
- native session list from `GET /api/sessions`
|
- native session list from `GET /api/sessions`
|
||||||
- create, rename, command send, split, pane select, pane kill, pin, mute, and
|
- create, rename, command send, split, pane select, pane kill, pin, mute, and
|
||||||
kill session through documented session/preference endpoints
|
kill session through documented session/preference endpoints
|
||||||
@@ -79,12 +80,14 @@ Implemented now:
|
|||||||
- bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, `Ctrl+V`, arrows, page keys,
|
- bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, `Ctrl+V`, arrows, page keys,
|
||||||
tmux prefix actions, and paste
|
tmux prefix actions, and paste
|
||||||
- shortcut delivery through the terminal WebSocket `input` message
|
- shortcut delivery through the terminal WebSocket `input` message
|
||||||
- native Tools page for health, server status, timeline, preferences, kanban
|
- native Projects page for kanban project grouping, project agents, project
|
||||||
projects, group messages, hook events, image file/URL upload, image preview
|
messages, add/remove session, create, and delete actions
|
||||||
metadata, and native image preview display
|
- native Tools page for health, server status, timeline, preferences, hook
|
||||||
|
events, image file/URL upload, image preview metadata, and native image
|
||||||
|
preview display
|
||||||
- GitHub Actions APK build
|
- GitHub Actions APK build
|
||||||
- release manifest `latest.json`
|
- release manifest `latest.json`
|
||||||
- selected-source update checks; GitHub and Gitea are not probed in the same
|
- selected-source update checks; Gitea and GitHub are not probed in the same
|
||||||
update check
|
update check
|
||||||
- one-download-per-version APK cache, SHA-256 verification, and installer
|
- one-download-per-version APK cache, SHA-256 verification, and installer
|
||||||
handoff
|
handoff
|
||||||
@@ -99,10 +102,10 @@ open Android's package installer, but the user must approve the install. On
|
|||||||
Android 8+, the user may also need to allow this app to install unknown apps.
|
Android 8+, the user may also need to allow this app to install unknown apps.
|
||||||
|
|
||||||
The app checks exactly one update source per run: the selected manifest/API URL.
|
The app checks exactly one update source per run: the selected manifest/API URL.
|
||||||
GitHub is the default public source. Gitea is available as a public mirror, but
|
Gitea is the default public source because phones may not reach GitHub reliably.
|
||||||
the app does not fall back across both providers during a normal check. This
|
GitHub is available as an optional public source, but the app does not fall back
|
||||||
keeps update behavior predictable on mobile networks and avoids duplicate
|
across both providers during a normal check. This keeps update behavior
|
||||||
provider checks.
|
predictable on mobile networks and avoids duplicate provider checks.
|
||||||
|
|
||||||
Downloaded APKs are cached by `versionCode`. If a cached APK exists and its
|
Downloaded APKs are cached by `versionCode`. If a cached APK exists and its
|
||||||
SHA-256 matches the manifest, the app reuses it instead of downloading the same
|
SHA-256 matches the manifest, the app reuses it instead of downloading the same
|
||||||
@@ -121,6 +124,6 @@ is a useful feature or test batch for phone-side validation.
|
|||||||
To converge with the upstream mobile design, the next implementation should add
|
To converge with the upstream mobile design, the next implementation should add
|
||||||
native Android modules in this order:
|
native Android modules in this order:
|
||||||
|
|
||||||
1. richer native layouts for kanban, group messages, timeline, and preferences
|
1. richer native layouts for group messages, timeline, and preferences
|
||||||
2. `TerminalCore` with ANSI parsing, cursor state, colors, and dirty rows
|
2. `TerminalCore` with ANSI parsing, cursor state, colors, and dirty rows
|
||||||
3. configurable shortcut bar backed directly by WebSocket `input`
|
3. configurable shortcut bar backed directly by WebSocket `input`
|
||||||
|
|||||||
Reference in New Issue
Block a user