Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/plugins/terminal/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
</js-module>

<platform name="android">

<framework src="org.java-websocket:Java-WebSocket:1.6.0" />

<config-file parent="/*" target="res/xml/config.xml">
<feature name="Executor">
<param name="android-package" value="com.foxdebug.acode.rk.exec.terminal.Executor" />
Expand All @@ -28,6 +31,7 @@
<source-file src="src/android/StreamHandler.java" target-dir="src/com/foxdebug/acode/rk/exec/terminal" />

<source-file src="src/android/Executor.java" target-dir="src/com/foxdebug/acode/rk/exec/terminal" />
<source-file src="src/android/ProcessServer.java" target-dir="src/com/foxdebug/acode/rk/exec/terminal" />

<source-file src="src/android/TerminalService.java" target-dir="src/com/foxdebug/acode/rk/exec/terminal" />

Expand Down
32 changes: 32 additions & 0 deletions src/plugins/terminal/src/android/Executor.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
import android.app.Activity;
import com.foxdebug.acode.rk.exec.terminal.*;

import java.net.ServerSocket;



public class Executor extends CordovaPlugin {

private Messenger serviceMessenger;
Expand All @@ -42,6 +46,8 @@ public class Executor extends CordovaPlugin {

private static final int REQUEST_POST_NOTIFICATIONS = 1001;



private void askNotificationPermission(Activity context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
Expand Down Expand Up @@ -252,6 +258,32 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo
return true;
}

if (action.equals("spawn")) {
try {
JSONArray cmdArr = args.getJSONArray(0);
String[] cmd = new String[cmdArr.length()];
for (int i = 0; i < cmdArr.length(); i++) {
cmd[i] = cmdArr.getString(i);
}

int port;
try (ServerSocket socket = new ServerSocket(0)) {
port = socket.getLocalPort();
}

ProcessServer server = new ProcessServer(port, cmd);
server.startAndAwait(); // blocks until onStart() fires — server is listening before port is returned

callbackContext.success(port);
} catch (Exception e) {
e.printStackTrace();
callbackContext.error("Failed to spawn process: " + e.getMessage());
}

return true;
}


// For all other actions, ensure service is bound first
if (!ensureServiceBound(callbackContext)) {
// Error already sent by ensureServiceBound
Expand Down
118 changes: 118 additions & 0 deletions src/plugins/terminal/src/android/ProcessServer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.foxdebug.acode.rk.exec.terminal;

import org.java_websocket.WebSocket;
import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer;

import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;

class ProcessServer extends WebSocketServer {

private final String[] cmd;
private final CountDownLatch readyLatch = new CountDownLatch(1);
private final AtomicReference<Exception> startError = new AtomicReference<>();

private static final class ConnState {
final Process process;
final OutputStream stdin;

ConnState(Process process, OutputStream stdin) {
this.process = process;
this.stdin = stdin;
}
}

ProcessServer(int port, String[] cmd) {
super(new InetSocketAddress("127.0.0.1", port));
this.cmd = cmd;
}

void startAndAwait() throws Exception {
start();
readyLatch.await();
Exception err = startError.get();
if (err != null) throw err;
}

@Override
public void onStart() {
readyLatch.countDown();
}

@Override
public void onError(WebSocket conn, Exception ex) {
if (conn == null) {
// Bind/startup failure — unblock startAndAwait() so it can throw.
startError.set(ex);
readyLatch.countDown();
}
// Per-connection errors: do nothing. onClose fires immediately after
// for the same connection, which is the single place cleanup happens.
}

@Override
public void onOpen(WebSocket conn, ClientHandshake handshake) {
try {
Process process = new ProcessBuilder(cmd).redirectErrorStream(true).start();
InputStream stdout = process.getInputStream();
OutputStream stdin = process.getOutputStream();

conn.setAttachment(new ConnState(process, stdin));

new Thread(() -> {
try {
byte[] buf = new byte[8192];
int len;
while ((len = stdout.read(buf)) != -1) {
conn.send(ByteBuffer.wrap(buf, 0, len));
}
} catch (Exception ignored) {}
conn.close(1000, "process exited");
}).start();

} catch (Exception e) {
conn.close(1011, "Failed to start process: " + e.getMessage());
}
}

@Override
public void onMessage(WebSocket conn, ByteBuffer msg) {
try {
ConnState state = conn.getAttachment();
state.stdin.write(msg.array(), msg.position(), msg.remaining());
state.stdin.flush();
} catch (Exception ignored) {}
}

@Override
public void onMessage(WebSocket conn, String message) {
try {
ConnState state = conn.getAttachment();
state.stdin.write(message.getBytes(StandardCharsets.UTF_8));
state.stdin.flush();
} catch (Exception ignored) {}
}

@Override
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
try {
ConnState state = conn.getAttachment();
if (state != null) state.process.destroy();
} catch (Exception ignored) {}

// stop() calls w.join() on every worker thread. If called directly from
// onClose (which runs on a WebSocketWorker thread), it deadlocks waiting
// for itself to finish. A separate thread sidesteps that entirely.
new Thread(() -> {
try {
stop();
} catch (Exception ignored) {}
}).start();
}
}
27 changes: 27 additions & 0 deletions src/plugins/terminal/www/Executor.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,31 @@ class Executor {
constructor(BackgroundExecutor = false) {
this.ExecutorType = BackgroundExecutor ? "BackgroundExecutor" : "Executor";
}

/**
* Spawns a process and exposes it as a raw WebSocket stream.
*
* @param {string[]} cmd - Command and arguments to execute (e.g. `["sh", "-c", "echo hi"]`).
* @param {(ws: WebSocket) => void} callback - Called with the connected WebSocket once the
* process is ready. Use `ws.send()` to write to stdin and `ws.onmessage` to read stdout.
*/
spawnStream(cmd, callback, onError) {
exec((port) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
ws.binaryType = "arraybuffer";

ws.onopen = () => {
callback(ws);
};

ws.onerror = (e) => {
if (onError) onError(e);
};

}, (err) => { if (onError) onError(err); }, "Executor", "spawn", [cmd]);
}


/**
* Starts a shell process and enables real-time streaming of stdout, stderr, and exit status.
*
Expand Down Expand Up @@ -150,6 +175,8 @@ class Executor {
*
* @returns {Promise<string>} Resolves when the service has been stopped.
*
* Note: This does not gurantee that all running processes have been killed, but the service will no longer be active. Use with caution.
*
* @example
* executor.stopService();
*/
Expand Down