diff --git a/src/plugins/terminal/plugin.xml b/src/plugins/terminal/plugin.xml index 72273257f..54b030c40 100644 --- a/src/plugins/terminal/plugin.xml +++ b/src/plugins/terminal/plugin.xml @@ -12,6 +12,9 @@ + + + @@ -28,6 +31,7 @@ + diff --git a/src/plugins/terminal/src/android/Executor.java b/src/plugins/terminal/src/android/Executor.java index 5d0e90777..6fc2c4bd8 100644 --- a/src/plugins/terminal/src/android/Executor.java +++ b/src/plugins/terminal/src/android/Executor.java @@ -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; @@ -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( @@ -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 diff --git a/src/plugins/terminal/src/android/ProcessServer.java b/src/plugins/terminal/src/android/ProcessServer.java new file mode 100644 index 000000000..c6c264164 --- /dev/null +++ b/src/plugins/terminal/src/android/ProcessServer.java @@ -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 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(); + } +} \ No newline at end of file diff --git a/src/plugins/terminal/www/Executor.js b/src/plugins/terminal/www/Executor.js index 4cb6c970c..7724447a7 100644 --- a/src/plugins/terminal/www/Executor.js +++ b/src/plugins/terminal/www/Executor.js @@ -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. * @@ -150,6 +175,8 @@ class Executor { * * @returns {Promise} 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(); */