Skip to content

Commit fd0e833

Browse files
feat: spawn stream support for Executor (Acode-Foundation#1972)
* feat: spawn stream support for Executor * fix issues * feat: improvements * feat: improvements * Update src/plugins/terminal/www/Executor.js Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update src/plugins/terminal/src/android/Executor.java Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update src/plugins/terminal/src/android/ProcessServer.java Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update src/plugins/terminal/src/android/ProcessServer.java Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: issues * Update src/plugins/terminal/src/android/Executor.java Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update src/plugins/terminal/src/android/ProcessServer.java Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: issues * fix: issues * Update src/plugins/terminal/src/android/Executor.java Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: remove duplicate import --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent d2efece commit fd0e833

File tree

4 files changed

+181
-0
lines changed

4 files changed

+181
-0
lines changed

src/plugins/terminal/plugin.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
</js-module>
1313

1414
<platform name="android">
15+
16+
<framework src="org.java-websocket:Java-WebSocket:1.6.0" />
17+
1518
<config-file parent="/*" target="res/xml/config.xml">
1619
<feature name="Executor">
1720
<param name="android-package" value="com.foxdebug.acode.rk.exec.terminal.Executor" />
@@ -28,6 +31,7 @@
2831
<source-file src="src/android/StreamHandler.java" target-dir="src/com/foxdebug/acode/rk/exec/terminal" />
2932

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

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

src/plugins/terminal/src/android/Executor.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
import android.app.Activity;
3030
import com.foxdebug.acode.rk.exec.terminal.*;
3131

32+
import java.net.ServerSocket;
33+
34+
35+
3236
public class Executor extends CordovaPlugin {
3337

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

4347
private static final int REQUEST_POST_NOTIFICATIONS = 1001;
4448

49+
50+
4551
private void askNotificationPermission(Activity context) {
4652
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
4753
if (ContextCompat.checkSelfPermission(
@@ -252,6 +258,32 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo
252258
return true;
253259
}
254260

261+
if (action.equals("spawn")) {
262+
try {
263+
JSONArray cmdArr = args.getJSONArray(0);
264+
String[] cmd = new String[cmdArr.length()];
265+
for (int i = 0; i < cmdArr.length(); i++) {
266+
cmd[i] = cmdArr.getString(i);
267+
}
268+
269+
int port;
270+
try (ServerSocket socket = new ServerSocket(0)) {
271+
port = socket.getLocalPort();
272+
}
273+
274+
ProcessServer server = new ProcessServer(port, cmd);
275+
server.startAndAwait(); // blocks until onStart() fires — server is listening before port is returned
276+
277+
callbackContext.success(port);
278+
} catch (Exception e) {
279+
e.printStackTrace();
280+
callbackContext.error("Failed to spawn process: " + e.getMessage());
281+
}
282+
283+
return true;
284+
}
285+
286+
255287
// For all other actions, ensure service is bound first
256288
if (!ensureServiceBound(callbackContext)) {
257289
// Error already sent by ensureServiceBound
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package com.foxdebug.acode.rk.exec.terminal;
2+
3+
import org.java_websocket.WebSocket;
4+
import org.java_websocket.handshake.ClientHandshake;
5+
import org.java_websocket.server.WebSocketServer;
6+
7+
import java.io.InputStream;
8+
import java.io.OutputStream;
9+
import java.net.InetSocketAddress;
10+
import java.nio.ByteBuffer;
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.concurrent.CountDownLatch;
13+
import java.util.concurrent.atomic.AtomicReference;
14+
15+
class ProcessServer extends WebSocketServer {
16+
17+
private final String[] cmd;
18+
private final CountDownLatch readyLatch = new CountDownLatch(1);
19+
private final AtomicReference<Exception> startError = new AtomicReference<>();
20+
21+
private static final class ConnState {
22+
final Process process;
23+
final OutputStream stdin;
24+
25+
ConnState(Process process, OutputStream stdin) {
26+
this.process = process;
27+
this.stdin = stdin;
28+
}
29+
}
30+
31+
ProcessServer(int port, String[] cmd) {
32+
super(new InetSocketAddress("127.0.0.1", port));
33+
this.cmd = cmd;
34+
}
35+
36+
void startAndAwait() throws Exception {
37+
start();
38+
readyLatch.await();
39+
Exception err = startError.get();
40+
if (err != null) throw err;
41+
}
42+
43+
@Override
44+
public void onStart() {
45+
readyLatch.countDown();
46+
}
47+
48+
@Override
49+
public void onError(WebSocket conn, Exception ex) {
50+
if (conn == null) {
51+
// Bind/startup failure — unblock startAndAwait() so it can throw.
52+
startError.set(ex);
53+
readyLatch.countDown();
54+
}
55+
// Per-connection errors: do nothing. onClose fires immediately after
56+
// for the same connection, which is the single place cleanup happens.
57+
}
58+
59+
@Override
60+
public void onOpen(WebSocket conn, ClientHandshake handshake) {
61+
try {
62+
Process process = new ProcessBuilder(cmd).redirectErrorStream(true).start();
63+
InputStream stdout = process.getInputStream();
64+
OutputStream stdin = process.getOutputStream();
65+
66+
conn.setAttachment(new ConnState(process, stdin));
67+
68+
new Thread(() -> {
69+
try {
70+
byte[] buf = new byte[8192];
71+
int len;
72+
while ((len = stdout.read(buf)) != -1) {
73+
conn.send(ByteBuffer.wrap(buf, 0, len));
74+
}
75+
} catch (Exception ignored) {}
76+
conn.close(1000, "process exited");
77+
}).start();
78+
79+
} catch (Exception e) {
80+
conn.close(1011, "Failed to start process: " + e.getMessage());
81+
}
82+
}
83+
84+
@Override
85+
public void onMessage(WebSocket conn, ByteBuffer msg) {
86+
try {
87+
ConnState state = conn.getAttachment();
88+
state.stdin.write(msg.array(), msg.position(), msg.remaining());
89+
state.stdin.flush();
90+
} catch (Exception ignored) {}
91+
}
92+
93+
@Override
94+
public void onMessage(WebSocket conn, String message) {
95+
try {
96+
ConnState state = conn.getAttachment();
97+
state.stdin.write(message.getBytes(StandardCharsets.UTF_8));
98+
state.stdin.flush();
99+
} catch (Exception ignored) {}
100+
}
101+
102+
@Override
103+
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
104+
try {
105+
ConnState state = conn.getAttachment();
106+
if (state != null) state.process.destroy();
107+
} catch (Exception ignored) {}
108+
109+
// stop() calls w.join() on every worker thread. If called directly from
110+
// onClose (which runs on a WebSocketWorker thread), it deadlocks waiting
111+
// for itself to finish. A separate thread sidesteps that entirely.
112+
new Thread(() -> {
113+
try {
114+
stop();
115+
} catch (Exception ignored) {}
116+
}).start();
117+
}
118+
}

src/plugins/terminal/www/Executor.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,31 @@ class Executor {
1212
constructor(BackgroundExecutor = false) {
1313
this.ExecutorType = BackgroundExecutor ? "BackgroundExecutor" : "Executor";
1414
}
15+
16+
/**
17+
* Spawns a process and exposes it as a raw WebSocket stream.
18+
*
19+
* @param {string[]} cmd - Command and arguments to execute (e.g. `["sh", "-c", "echo hi"]`).
20+
* @param {(ws: WebSocket) => void} callback - Called with the connected WebSocket once the
21+
* process is ready. Use `ws.send()` to write to stdin and `ws.onmessage` to read stdout.
22+
*/
23+
spawnStream(cmd, callback, onError) {
24+
exec((port) => {
25+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
26+
ws.binaryType = "arraybuffer";
27+
28+
ws.onopen = () => {
29+
callback(ws);
30+
};
31+
32+
ws.onerror = (e) => {
33+
if (onError) onError(e);
34+
};
35+
36+
}, (err) => { if (onError) onError(err); }, "Executor", "spawn", [cmd]);
37+
}
38+
39+
1540
/**
1641
* Starts a shell process and enables real-time streaming of stdout, stderr, and exit status.
1742
*
@@ -150,6 +175,8 @@ class Executor {
150175
*
151176
* @returns {Promise<string>} Resolves when the service has been stopped.
152177
*
178+
* Note: This does not gurantee that all running processes have been killed, but the service will no longer be active. Use with caution.
179+
*
153180
* @example
154181
* executor.stopService();
155182
*/

0 commit comments

Comments
 (0)