Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
33 changes: 33 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;
import java.io.IOException;
Comment thread
RohitKushvaha01 marked this conversation as resolved.
Outdated


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,33 @@ 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)) {
socket.setReuseAddress(true);
port = socket.getLocalPort();
}
Comment thread
RohitKushvaha01 marked this conversation as resolved.

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

callbackContext.success(port);
Comment thread
RohitKushvaha01 marked this conversation as resolved.
} catch (Exception e) {
e.printStackTrace();
Comment thread
RohitKushvaha01 marked this conversation as resolved.
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
112 changes: 112 additions & 0 deletions src/plugins/terminal/src/android/ProcessServer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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) {}
Comment thread
RohitKushvaha01 marked this conversation as resolved.
}
Comment thread
RohitKushvaha01 marked this conversation as resolved.

@Override
public void onMessage(WebSocket conn, String message) {
try {
ConnState state = conn.getAttachment();
Comment thread
RohitKushvaha01 marked this conversation as resolved.
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) {
// Single point of cleanup for every connection lifecycle ending —
// whether closed cleanly, after an error, or by the process exiting.
try {
ConnState state = conn.getAttachment();
if (state != null) state.process.destroy();
stop();
} catch (Exception ignored) {}
Comment thread
RohitKushvaha01 marked this conversation as resolved.
}
}
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}`);
Comment thread
RohitKushvaha01 marked this conversation as resolved.
ws.binaryType = "arraybuffer";

Comment thread
RohitKushvaha01 marked this conversation as resolved.
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