Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b13d42930 | ||
|
|
72913c5571 |
@@ -19,7 +19,8 @@ APIs directly:
|
|||||||
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
|
||||||
|
reachability
|
||||||
- native Tools page for health, server status, timeline, preferences, hook
|
- native Tools page for health, server status, timeline, preferences, hook
|
||||||
events, image file/URL upload, image preview info, and native image preview
|
events, image file/URL upload, image preview info, and native image preview
|
||||||
display
|
display
|
||||||
@@ -93,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.
|
||||||
@@ -146,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()}\"")
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ public final class MainActivity extends Activity {
|
|||||||
private static final int MAX_TERMINAL_CHARS = 40_000;
|
private static final int MAX_TERMINAL_CHARS = 40_000;
|
||||||
private static final long TERMINAL_RENDER_INTERVAL_MS = 80L;
|
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_PROJECTS = "Projects";
|
||||||
@@ -116,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));
|
||||||
@@ -1438,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());
|
||||||
|
|
||||||
@@ -1476,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);
|
||||||
}
|
}
|
||||||
@@ -1504,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...");
|
||||||
|
|||||||
@@ -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,32 +47,34 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +89,7 @@ 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);
|
||||||
sendMessage(
|
sendMessageSync(
|
||||||
"attach",
|
"attach",
|
||||||
"tabId", "android-" + System.currentTimeMillis(),
|
"tabId", "android-" + System.currentTimeMillis(),
|
||||||
"sessionName", sessionName,
|
"sessionName", sessionName,
|
||||||
@@ -98,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) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,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) {
|
||||||
@@ -253,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());
|
||||||
@@ -268,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) {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ Implemented now:
|
|||||||
preview display
|
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
|
||||||
@@ -102,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
|
||||||
|
|||||||
Reference in New Issue
Block a user