From 77d16ac8960b704025577fe47de04418e7c58179 Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 23 Feb 2024 00:16:04 +0800 Subject: [PATCH] js: support `spawn` jsapi --- js/README.md | 75 ++++++++++++- js/index.js | 79 +++++++++++-- .../me/weishu/kernelsu/ui/screen/WebScreen.kt | 105 ++++++++++++++++-- 3 files changed, 234 insertions(+), 25 deletions(-) diff --git a/js/README.md b/js/README.md index e81dae38..4af3a718 100644 --- a/js/README.md +++ b/js/README.md @@ -10,12 +10,12 @@ yarn add kernelsu ### exec -Execute a command in the **root** shell. +Spawns a **root** shell and runs a command within that shell, passing the `stdout` and `stderr` to a Promise when complete. -options: - -- `cwd` - Current working directory of the child process -- `env` - Environment key-value pairs +- `command` `` The command to run, with space-separated arguments. +- `options` `` + - `cwd` - Current working directory of the child process + - `env` - Environment key-value pairs ```javascript import { exec } from 'kernelsu'; @@ -27,6 +27,71 @@ if (errno === 0) { } ``` +### spawn + +Spawns a new process using the given `command` in **root** shell, with command-line arguments in `args`. If omitted, `args` defaults to an empty array. + +Returns a `ChildProcess`, Instances of the ChildProcess represent spawned child processes. + +- `command` `` The command to run. +- `args` `` List of string arguments. +- `options` ``: + - `cwd` `` - Current working directory of the child process + - `env` `` - Environment key-value pairs + +Example of running `ls -lh /data`, capturing `stdout`, `stderr`, and the exit code: + +```javascript +import { spawn } from 'kernelsu'; + +const ls = spawn('ls', ['-lh', '/data']); + +ls.stdout.on('data', (data) => { + console.log(`stdout: ${data}`); +}); + +ls.stderr.on('data', (data) => { + console.log(`stderr: ${data}`); +}); + +ls.on('exit', (code) => { + console.log(`child process exited with code ${code}`); +}); +``` + +#### ChildProcess + +##### Event 'exit' + +- `code` `` The exit code if the child exited on its own. + +The `'exit'` event is emitted after the child process ends. If the process exited, `code` is the final exit code of the process, otherwise null + +##### Event 'error' + +- `err` `` The error. + +The `'error'` event is emitted whenever: + +- The process could not be spawned. +- The process could not be killed. + +##### `stdout` + +A `Readable Stream` that represents the child process's `stdout`. + +```javascript +const subprocess = spawn('ls'); + +subprocess.stdout.on('data', (data) => { + console.log(`Received chunk ${data}`); +}); +``` + +#### `stderr` + +A `Readable Stream` that represents the child process's `stderr`. + ### fullScreen Request the WebView enter/exit full screen. diff --git a/js/index.js b/js/index.js index 6e7eaa7d..a7a7422c 100644 --- a/js/index.js +++ b/js/index.js @@ -1,6 +1,6 @@ let callbackCounter = 0; -function getUniqueCallbackName() { - return `_callback_${Date.now()}_${callbackCounter++}`; +function getUniqueCallbackName(prefix) { + return `${prefix}_callback_${Date.now()}_${callbackCounter++}`; } export function exec(command, options) { @@ -10,7 +10,7 @@ export function exec(command, options) { return new Promise((resolve, reject) => { // Generate a unique callback function name - const callbackFuncName = getUniqueCallbackName(); + const callbackFuncName = getUniqueCallbackName("exec"); // Define the success callback function window[callbackFuncName] = (errno, stdout, stderr) => { @@ -23,11 +23,7 @@ export function exec(command, options) { } try { - ksu.exec( - command, - JSON.stringify(options), - callbackFuncName, - ); + ksu.exec(command, JSON.stringify(options), callbackFuncName); } catch (error) { reject(error); cleanup(callbackFuncName); @@ -35,6 +31,73 @@ export function exec(command, options) { }); } +function Stdio() { + this.listeners = {}; + } + + Stdio.prototype.on = function (event, listener) { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(listener); + }; + + Stdio.prototype.emit = function (event, ...args) { + if (this.listeners[event]) { + this.listeners[event].forEach((listener) => listener(...args)); + } + }; + + function ChildProcess() { + this.listeners = {}; + this.stdin = new Stdio(); + this.stdout = new Stdio(); + this.stderr = new Stdio(); + } + + ChildProcess.prototype.on = function (event, listener) { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(listener); + }; + + ChildProcess.prototype.emit = function (event, ...args) { + if (this.listeners[event]) { + this.listeners[event].forEach((listener) => listener(...args)); + } + }; + + export function spawn(command, args, options) { + if (typeof args === "undefined") { + args = []; + } + + if (typeof options === "undefined") { + options = {}; + } + + const child = new ChildProcess(); + const childCallbackName = getUniqueCallbackName("spawn"); + window[childCallbackName] = child; + + function cleanup(name) { + delete window[name]; + } + try { + ksu.spawn( + command, + JSON.stringify(args), + JSON.stringify(options), + childCallbackName + ); + } catch (error) { + child.emit("error", error); + cleanup(childCallbackName); + } + return child; + } + export function fullScreen(isFullScreen) { ksu.fullScreen(isFullScreen); } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/WebScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/WebScreen.kt index 75a3e187..a40d9477 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/WebScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/WebScreen.kt @@ -25,10 +25,13 @@ import com.google.accompanist.web.WebView import com.google.accompanist.web.rememberWebViewState import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.topjohnwu.superuser.CallbackList import com.topjohnwu.superuser.ShellUtils import me.weishu.kernelsu.ui.util.createRootShell import me.weishu.kernelsu.ui.util.serveModule +import org.json.JSONArray import org.json.JSONObject +import java.util.concurrent.CompletableFuture @SuppressLint("SetJavaScriptEnabled") @Destination @@ -83,6 +86,23 @@ class WebViewInterface(val context: Context, val webView: WebView) { exec(cmd, null, callbackFunc) } + private fun processOptions(sb: StringBuilder, options: String?) { + val opts = if (options == null) JSONObject() else { + JSONObject(options) + } + + val cwd = opts.optString("cwd") + if (!TextUtils.isEmpty(cwd)) { + sb.append("cd ${cwd};") + } + + opts.optJSONObject("env")?.let { env -> + env.keys().forEach { key -> + sb.append("export ${key}=${env.getString(key)};") + } + } + } + @JavascriptInterface fun exec( cmd: String, @@ -94,18 +114,7 @@ class WebViewInterface(val context: Context, val webView: WebView) { } val finalCommand = StringBuilder() - - val cwd = opts.optString("cwd") - if (!TextUtils.isEmpty(cwd)) { - finalCommand.append("cd ${cwd};") - } - - opts.optJSONObject("env")?.let { env -> - env.keys().forEach { key -> - finalCommand.append("export ${key}=${env.getString(key)};") - } - } - + processOptions(finalCommand, options) finalCommand.append(cmd) val shell = createRootShell() @@ -124,6 +133,78 @@ class WebViewInterface(val context: Context, val webView: WebView) { } } + @JavascriptInterface + fun spawn(command: String, args: String, options: String?, callbackFunc: String) { + val finalCommand = StringBuilder() + + processOptions(finalCommand, options) + + if (!TextUtils.isEmpty(args)) { + finalCommand.append(command).append(" ") + JSONArray(args).let { argsArray -> + for (i in 0 until argsArray.length()) { + finalCommand.append(argsArray.getString(i)) + finalCommand.append(" ") + } + } + } else { + finalCommand.append(command) + } + + val shell = createRootShell() + + val emitData = fun(name: String, data: String) { + val jsCode = + "javascript: (function() { try { ${callbackFunc}.${name}.emit('data', ${ + JSONObject.quote( + data + ) + }); } catch(e) { console.error('emitData', e); } })();" + webView.post { + webView.loadUrl(jsCode) + } + } + + val stdout = object : CallbackList() { + override fun onAddElement(s: String) { + emitData("stdout", s) + } + } + + val stderr = object : CallbackList() { + override fun onAddElement(s: String) { + emitData("stderr", s) + } + } + + val future = shell.newJob().add(finalCommand.toString()).to(stdout, stderr).enqueue() + val completableFuture = CompletableFuture.supplyAsync { + future.get() + } + + completableFuture.thenAccept { result -> + val emitExitCode = + "javascript: (function() { try { ${callbackFunc}.emit('exit', ${result.code}); } catch(e) { console.error(`emitExit error: \${e}`); } })();" + webView.post { + webView.loadUrl(emitExitCode) + } + + if (result.code != 0) { + val emitErrCode = + "javascript: (function() { try { var err = new Error(); err.exitCode = ${result.code}; err.message = ${ + JSONObject.quote( + result.err.joinToString( + "\n" + ) + ) + };${callbackFunc}.emit('error', err); } catch(e) { console.error('emitErr', e); } })();" + webView.post { + webView.loadUrl(emitErrCode) + } + } + } + } + @JavascriptInterface fun toast(msg: String) { webView.post {