diff --git a/biome.json b/biome.json index da281e682..5479d49cd 100644 --- a/biome.json +++ b/biome.json @@ -34,7 +34,13 @@ "globals": ["Global1"] }, "files": { - "include": ["src/**/*", "utils/**/*.js", "www/**/*.js", "www/res/**/*.css"], + "include": [ + "src/**/*", + "utils/**/*.js", + "www/**/*.js", + "www/res/**/*.css", + "src/plugins/terminal" + ], "ignore": [ "ace-builds", "www/js/**/*.js", diff --git a/hooks/post-process.js b/hooks/post-process.js index f425a061b..4e6e872cf 100644 --- a/hooks/post-process.js +++ b/hooks/post-process.js @@ -1,6 +1,7 @@ /* eslint-disable no-console */ const path = require('path'); const fs = require('fs'); +const { execSync } = require('child_process'); const buildFilePath = path.resolve(__dirname, '../build.json'); const copyToPath = path.resolve(__dirname, '../platforms/android/build.json'); @@ -28,6 +29,35 @@ deleteDirRecursively(resPath, [ 'xml', ]); copyDirRecursively(localResPath, resPath); +enableLegacyJni() + + +function enableLegacyJni() { + const prefix = execSync('npm prefix').toString().trim(); + const gradleFile = path.join(prefix, 'platforms/android/app/build.gradle'); + + if (!fs.existsSync(gradleFile)) return; + + let content = fs.readFileSync(gradleFile, 'utf-8'); + // Check for correct block to avoid duplicate insertion + if (content.includes('useLegacyPackaging = true')) return; + + // Inject under android block with correct Groovy syntax + content = content.replace(/android\s*{/, match => { + return ( + match + + ` + packagingOptions { + jniLibs { + useLegacyPackaging = true + } + }` + ); + }); + + fs.writeFileSync(gradleFile, content, 'utf-8'); + console.log('[Cordova Hook] ✅ Enabled legacy JNI packaging'); +} /** * Copy directory recursively diff --git a/package-lock.json b/package-lock.json index ffde819d0..286c79db3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "sass": "^1.77.2", "sass-loader": "^14.2.1", "style-loader": "^4.0.0", + "terminal": "^0.1.4", "webpack": "^5.94.0", "webpack-cli": "^5.1.4" } @@ -9595,6 +9596,17 @@ "version": "3.0.13", "license": "CC0-1.0" }, + "node_modules/sprintf": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/sprintf/-/sprintf-0.1.5.tgz", + "integrity": "sha512-4X5KsuXFQ7f+d7Y+bi4qSb6eI+YoifDTGr0MQJXRoYO7BO7evfRCjds6kk3z7l5CiJYxgDN1x5Er4WiyCt+zTQ==", + "deprecated": "The sprintf package is deprecated in favor of sprintf-js.", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.2.4" + } + }, "node_modules/sshpk": { "version": "1.17.0", "license": "MIT", @@ -9840,6 +9852,18 @@ "version": "4.0.0", "license": "ISC" }, + "node_modules/terminal": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/terminal/-/terminal-0.1.4.tgz", + "integrity": "sha512-w6OAFpUO+TimZUdQ46dK3fYYOCCBIsS2QUfIEkzX21oJ8tvJOJvJkcmrbleLH5KG02SNohYFDj81bL3VPaULsQ==", + "dev": true, + "dependencies": { + "sprintf": ">= 0.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/terser": { "version": "5.27.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", @@ -10755,10 +10779,10 @@ } }, "src/plugins/Executor": { - "name": "com.foxdebug.acode.exec", + "name": "com.foxdebug.acode.rk.exec.terminal", "version": "1.0.0", "extraneous": true, - "license": "ISC" + "license": "MIT" }, "src/plugins/ftp": { "name": "cordova-plugin-ftp", diff --git a/package.json b/package.json index 923a79917..f71296d54 100644 --- a/package.json +++ b/package.json @@ -31,13 +31,13 @@ "cordova-plugin-sdcard": {}, "cordova-plugin-browser": {}, "cordova-plugin-iap": {}, - "cordova-plugin-system": {}, "cordova-plugin-advanced-http": { "ANDROIDBLACKLISTSECURESOCKETPROTOCOLS": "SSLv3,TLSv1" }, "cordova-plugin-websocket": {}, - "com.foxdebug.acode.rk.exec.terminal": {}, - "cordova-plugin-buildinfo": {} + "cordova-plugin-buildinfo": {}, + "cordova-plugin-system": {}, + "com.foxdebug.acode.rk.exec.terminal": {} }, "platforms": [ "android" @@ -88,6 +88,7 @@ "sass": "^1.77.2", "sass-loader": "^14.2.1", "style-loader": "^4.0.0", + "terminal": "^0.1.4", "webpack": "^5.94.0", "webpack-cli": "^5.1.4" }, diff --git a/src/lib/checkFiles.js b/src/lib/checkFiles.js index a964e2899..9f585f9b4 100644 --- a/src/lib/checkFiles.js +++ b/src/lib/checkFiles.js @@ -47,7 +47,8 @@ export default async function checkFiles() { * @returns {Promise} */ async function checkFile(file) { - if (file.isUnsaved || !file.loaded || file.loading) return; + if (file === undefined || file.isUnsaved || !file.loaded || file.loading) + return; if (file.uri) { const fs = fsOperation(file.uri); diff --git a/src/lib/main.js b/src/lib/main.js index 3998e034f..fc7d5bae2 100644 --- a/src/lib/main.js +++ b/src/lib/main.js @@ -54,7 +54,6 @@ import NotificationManager from "lib/notificationManager"; import { addedFolder } from "lib/openFolder"; import { getEncoding, initEncodings } from "utils/encodings"; import auth, { loginEvents } from "./auth"; -import constants from "./constants"; const previousVersionCode = Number.parseInt(localStorage.versionCode, 10); diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index 7bc710ae1..358fd469e 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -1033,6 +1033,33 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { ); } + // Check for Terminal Home Directory storage + try { + const isTerminalInstalled = await Terminal.isInstalled(); + if (typeof Terminal !== "undefined" && isTerminalInstalled) { + const isTerminalSupported = await Terminal.isSupported(); + + if (isTerminalSupported && isTerminalInstalled) { + const terminalHomeUrl = cordova.file.dataDirectory + "alpine/home"; + + // Check if this storage is not already in the list + const terminalStorageExists = allStorages.find( + (storage) => + storage.uuid === "terminal-home" || + storage.url === terminalHomeUrl, + ); + + if (!terminalStorageExists) { + util.pushFolder(allStorages, "Terminal Home", terminalHomeUrl, { + uuid: "terminal-home", + }); + } + } + } + } catch (error) { + console.error("Error checking Terminal installation:", error); + } + try { const res = await externalFs.listStorages(); res.forEach((storage) => { diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index fb4839dc1..049439a88 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -1,5 +1,10 @@ package com.foxdebug.system; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.io.IOException; import android.app.Activity; import android.app.PendingIntent; import android.content.ClipData; @@ -47,6 +52,10 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; + public class System extends CordovaPlugin { @@ -156,6 +165,66 @@ public void run() { } ); return true; + case "fileExists": + callbackContext.success(fileExists(args.getString(0),args.getString(1)) ? 1 : 0); + return true; + + case "createSymlink": + boolean success = createSymlink(args.getString(0), args.getString(1)); + callbackContext.success(success ? 1 : 0); + return true; + + case "getNativeLibraryPath": + callbackContext.success(getNativeLibraryPath()); + return true; + + case "getFilesDir": + callbackContext.success(getFilesDir()); + return true; + + case "getParentPath": + callbackContext.success(getParentPath(args.getString(0))); + return true; + + case "listChildren": + callbackContext.success(listChildren(args.getString(0))); + return true; + case "writeText": { + try { + String filePath = args.getString(0); + String content = args.getString(1); + + Files.write(Paths.get(filePath), + Collections.singleton(content), + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + + callbackContext.success("File written successfully"); + } catch (Exception e) { + callbackContext.error("Failed to write file: " + e.getMessage()); + } + return true; +} + + case "getArch": + String arch; + + if (android.os.Build.VERSION.SDK_INT >= 21) { + arch = android.os.Build.SUPPORTED_ABIS[0]; + } else { + arch = android.os.Build.CPU_ABI; + } + + callbackContext.success(arch); + return true; + case "mkdirs": + File file = new File(args.getString(0)); + if(file.mkdirs()){ + callbackContext.success(); + }else{ + callbackContext.error("mkdirs failed"); + } + return true; default: return false; } @@ -399,6 +468,59 @@ private void hasPermission(String permission, CallbackContext callback) { callback.error("No permission passed to check."); } + public boolean fileExists(String path, String countSymlinks) { + Path p = new File(path).toPath(); + try { + if (Boolean.parseBoolean(countSymlinks)) { + // This will return true even for broken symlinks + return Files.exists(p, LinkOption.NOFOLLOW_LINKS); + } else { + // Check target file, not symlink itself + return Files.exists(p) && !Files.isSymbolicLink(p); + } + } catch (Exception e) { + return false; + } +} + + public boolean createSymlink(String target, String linkPath) { + try { + Process process = Runtime.getRuntime().exec(new String[]{"ln", "-s", target, linkPath}); + return process.waitFor() == 0; + } catch (Exception e) { + return false; + } + } + + public String getNativeLibraryPath() { + ApplicationInfo appInfo = context.getApplicationInfo(); + return appInfo.nativeLibraryDir; + } + + public String getFilesDir() { + return context.getFilesDir().getAbsolutePath(); + } + + public String getParentPath(String path) { + File file = new File(path); + File parent = file.getParentFile(); + return parent != null ? parent.getAbsolutePath() : null; + } + + public JSONArray listChildren(String path) throws JSONException { + File dir = new File(path); + JSONArray result = new JSONArray(); + if (dir.exists() && dir.isDirectory()) { + File[] files = dir.listFiles(); + if (files != null) { + for (File file : files) { + result.put(file.getAbsolutePath()); + } + } + } + return result; + } + public void onRequestPermissionResult( int code, String[] permissions, diff --git a/src/plugins/system/www/plugin.js b/src/plugins/system/www/plugin.js index 914c50072..89314ac35 100644 --- a/src/plugins/system/www/plugin.js +++ b/src/plugins/system/www/plugin.js @@ -1,4 +1,37 @@ module.exports = { + fileExists: function (path,countSymlinks, success, error) { + cordova.exec(success, error, 'System', 'fileExists', [path,String(countSymlinks)]); + }, + + createSymlink: function (target, linkPath, success, error) { + cordova.exec(success, error, 'System', 'createSymlink', [target, linkPath]); + }, + writeText: function (path, content, success, error) { + cordova.exec(success, error, 'System', 'writeText', [path, content]); + }, + + getNativeLibraryPath: function (success, error) { + cordova.exec(success, error, 'System', 'getNativeLibraryPath', []); + }, + + getFilesDir: function (success, error) { + cordova.exec(success, error, 'System', 'getFilesDir', []); + }, + + getParentPath: function (path, success, error) { + cordova.exec(success, error, 'System', 'getParentPath', [path]); + }, + + listChildren: function (path, success, error) { + cordova.exec(success, error, 'System', 'listChildren', [path]); + }, + mkdirs: function (path, success, error) { + cordova.exec(success, error, 'System', 'mkdirs', [path]); + }, + getArch: function (success, error) { + cordova.exec(success, error, 'System', 'getArch', []); + }, + clearCache: function (success, fail) { return cordova.exec(success, fail, "System", "clearCache", []); }, diff --git a/src/plugins/terminal/libs/arm32/libproot-xed.so b/src/plugins/terminal/libs/arm32/libproot-xed.so new file mode 100644 index 000000000..398f67621 Binary files /dev/null and b/src/plugins/terminal/libs/arm32/libproot-xed.so differ diff --git a/src/plugins/terminal/libs/proot-arm.so b/src/plugins/terminal/libs/arm32/libproot.so similarity index 100% rename from src/plugins/terminal/libs/proot-arm.so rename to src/plugins/terminal/libs/arm32/libproot.so diff --git a/src/plugins/terminal/libs/arm32/libtalloc.so b/src/plugins/terminal/libs/arm32/libtalloc.so new file mode 100644 index 000000000..c8d6c13fa Binary files /dev/null and b/src/plugins/terminal/libs/arm32/libtalloc.so differ diff --git a/src/plugins/terminal/libs/arm64/libproot-xed.so b/src/plugins/terminal/libs/arm64/libproot-xed.so new file mode 100644 index 000000000..33761eca7 Binary files /dev/null and b/src/plugins/terminal/libs/arm64/libproot-xed.so differ diff --git a/src/plugins/terminal/libs/proot-aarch64.so b/src/plugins/terminal/libs/arm64/libproot.so similarity index 100% rename from src/plugins/terminal/libs/proot-aarch64.so rename to src/plugins/terminal/libs/arm64/libproot.so diff --git a/src/plugins/terminal/libs/arm64/libproot32.so b/src/plugins/terminal/libs/arm64/libproot32.so new file mode 100644 index 000000000..27b95417c Binary files /dev/null and b/src/plugins/terminal/libs/arm64/libproot32.so differ diff --git a/src/plugins/terminal/libs/arm64/libtalloc.so b/src/plugins/terminal/libs/arm64/libtalloc.so new file mode 100644 index 000000000..b564e19de Binary files /dev/null and b/src/plugins/terminal/libs/arm64/libtalloc.so differ diff --git a/src/plugins/terminal/libs/x64/libproot-xed.so b/src/plugins/terminal/libs/x64/libproot-xed.so new file mode 100644 index 000000000..89952891a Binary files /dev/null and b/src/plugins/terminal/libs/x64/libproot-xed.so differ diff --git a/src/plugins/terminal/libs/x64/libproot.so b/src/plugins/terminal/libs/x64/libproot.so new file mode 100644 index 000000000..b54862e9d Binary files /dev/null and b/src/plugins/terminal/libs/x64/libproot.so differ diff --git a/src/plugins/terminal/libs/x64/libproot32.so b/src/plugins/terminal/libs/x64/libproot32.so new file mode 100644 index 000000000..8bbcc6dd4 Binary files /dev/null and b/src/plugins/terminal/libs/x64/libproot32.so differ diff --git a/src/plugins/terminal/libs/x64/libtalloc.so b/src/plugins/terminal/libs/x64/libtalloc.so new file mode 100644 index 000000000..19cbc7f51 Binary files /dev/null and b/src/plugins/terminal/libs/x64/libtalloc.so differ diff --git a/src/plugins/terminal/plugin.xml b/src/plugins/terminal/plugin.xml index 781385e0a..931cd536f 100644 --- a/src/plugins/terminal/plugin.xml +++ b/src/plugins/terminal/plugin.xml @@ -1,25 +1,49 @@ Terminal + + + + - + - - - + + + + + + - - + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/plugins/terminal/scripts/init-alpine.sh b/src/plugins/terminal/scripts/init-alpine.sh new file mode 100644 index 000000000..07e0828ec --- /dev/null +++ b/src/plugins/terminal/scripts/init-alpine.sh @@ -0,0 +1,48 @@ +set -e # Exit immediately on Failure + +export PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/share/bin:/usr/share/sbin:/usr/local/bin:/usr/local/sbin:/system/bin:/system/xbin:$PREFIX/local/bin +export PS1="\[\e[38;5;46m\]\u\[\033[39m\]@localhost \[\033[39m\]\w \[\033[0m\]\\$ " +export PIP_BREAK_SYSTEM_PACKAGES=1 +export HOME=/home +export TERM=xterm-256color +required_packages="bash" + +missing_packages="" +for pkg in $required_packages; do + if ! apk info -e $pkg >/dev/null 2>&1; then + missing_packages="$missing_packages $pkg" + fi +done +if [ -n "$missing_packages" ]; then + echo -e "\e[34;1m[*] \e[0mInstalling Important packages\e[0m" + apk update && apk upgrade + apk add $missing_packages + if [ $? -eq 0 ]; then + echo -e "\e[32;1m[+] \e[0mSuccessfully Installed\e[0m" + fi + echo -e "\e[34m[*] \e[0mUse \e[32mapk\e[0m to install new packages\e[0m" +fi + + +if [[ ! -f /linkerconfig/ld.config.txt ]];then + mkdir -p /linkerconfig + touch /linkerconfig/ld.config.txt +fi + +[ ! -L /bin/login ] && mv /bin/login /bin/real_login +ln -sf /bin/bash /bin/login + +if [ "$1" = "--installing" ]; then + echo "Installation completed." + exit 0 +fi + + + +if [ "$#" -eq 0 ]; then + echo "$$" > $PREFIX/pid + chmod +x $PREFIX/axs + $PREFIX/axs +else + exec "$@" +fi \ No newline at end of file diff --git a/src/plugins/terminal/scripts/init-sandbox.sh b/src/plugins/terminal/scripts/init-sandbox.sh new file mode 100644 index 000000000..aedc007f4 --- /dev/null +++ b/src/plugins/terminal/scripts/init-sandbox.sh @@ -0,0 +1,28 @@ +export LD_LIBRARY_PATH=$PREFIX +export PROOT_TMP_DIR=$PREFIX/tmp + + +if [ -f "$NATIVE_DIR/libproot.so" ]; then + export PROOT_LOADER="$NATIVE_DIR/libproot.so" +fi + +if [ -f "$NATIVE_DIR/libproot32.so" ]; then + export PROOT_LOADER32="$NATIVE_DIR/libproot32.so" +fi + +mkdir -p "$PREFIX/tmp" + +if [ "$FDROID" = "true" ]; then + export PROOT="$PREFIX/libproot-xed.so" + chmod +x $PROOT + chmod +x $PREFIX/libtalloc.so.2 +else + if [ -e "$PREFIX/libtalloc.so.2" ] || [ -L "$PREFIX/libtalloc.so.2" ]; then + rm "$PREFIX/libtalloc.so.2" + fi + ln -s "$NATIVE_DIR/libtalloc.so" "$PREFIX/libtalloc.so.2" + export PROOT="$NATIVE_DIR/libproot-xed.so" +fi + + +$PROOT --kill-on-exit -b $PREFIX:$PREFIX -b /data:/data -b /system:/system -b /vendor:/vendor -b /sdcard:/sdcard -b /storage:/storage -S $PREFIX/alpine /bin/sh $PREFIX/init-alpine.sh "$@" \ No newline at end of file diff --git a/src/plugins/terminal/src/android/Executor.java b/src/plugins/terminal/src/android/Executor.java index de2dddd21..85096d49f 100644 --- a/src/plugins/terminal/src/android/Executor.java +++ b/src/plugins/terminal/src/android/Executor.java @@ -6,6 +6,10 @@ import java.io.*; import java.util.*; import java.util.concurrent.*; +import android.content.Context; +import android.app.Activity; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; public class Executor extends CordovaPlugin { @@ -13,13 +17,26 @@ public class Executor extends CordovaPlugin { private final Map processInputs = new ConcurrentHashMap<>(); private final Map processCallbacks = new ConcurrentHashMap<>(); + private Context context; + + + @Override + public void initialize(CordovaInterface cordova, CordovaWebView webView) { + super.initialize(cordova, webView); + this.context = cordova.getContext(); + + } + + + + @Override public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException { switch (action) { case "start": String cmdStart = args.getString(0); String pid = UUID.randomUUID().toString(); - startProcess(pid, cmdStart, callbackContext); + startProcess(pid, cmdStart,args.getString(1), callbackContext); return true; case "write": String pidWrite = args.getString(0); @@ -31,8 +48,10 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo stopProcess(pidStop, callbackContext); return true; case "exec": - String cmdExec = args.getString(0); - exec(cmdExec, callbackContext); + exec(args.getString(0),args.getString(1), callbackContext); + return true; + case "isRunning": + isProcessRunning(args.getString(0), callbackContext); return true; default: callbackContext.error("Unknown action: " + action); @@ -40,10 +59,30 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo } } - private void exec(String cmd, CallbackContext callbackContext) { + private void exec(String cmd,String alpine, CallbackContext callbackContext) { try { if (cmd != null && !cmd.isEmpty()) { - Process process = Runtime.getRuntime().exec(cmd); + String xcmd = cmd; + if(alpine.equals("true")){ + xcmd = "source $PREFIX/init-sandbox.sh "+cmd; + } + + ProcessBuilder builder = new ProcessBuilder("sh", "-c", xcmd); + + // Set environment variables + Map env = builder.environment(); + env.put("PREFIX", context.getFilesDir().getAbsolutePath()); + env.put("NATIVE_DIR", context.getApplicationInfo().nativeLibraryDir); + + try { + int target = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).applicationInfo.targetSdkVersion; + env.put("FDROID", String.valueOf(target <= 28)); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + } + + + Process process = builder.start(); // Capture stdout BufferedReader stdOutReader = new BufferedReader( @@ -81,10 +120,31 @@ private void exec(String cmd, CallbackContext callbackContext) { } } - private void startProcess(String pid, String cmd, CallbackContext callbackContext) { + private void startProcess(String pid, String cmd,String alpine, CallbackContext callbackContext) { cordova.getThreadPool().execute(() -> { try { - Process process = Runtime.getRuntime().exec(cmd); + String xcmd = cmd; + if(alpine.equals("true")){ + xcmd = "source $PREFIX/init-sandbox.sh "+cmd; + } + ProcessBuilder builder = new ProcessBuilder("sh", "-c", xcmd); + + // Set environment variables + Map env = builder.environment(); + env.put("PREFIX", context.getFilesDir().getAbsolutePath()); + env.put("NATIVE_DIR", context.getApplicationInfo().nativeLibraryDir); + + try { + int target = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).applicationInfo.targetSdkVersion; + env.put("FDROID", String.valueOf(target <= 28)); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + } + + + + Process process = builder.start(); + processes.put(pid, process); processInputs.put(pid, process.getOutputStream()); processCallbacks.put(pid, callbackContext); @@ -133,6 +193,22 @@ private void stopProcess(String pid, CallbackContext callbackContext) { } } + private void isProcessRunning(String pid, CallbackContext callbackContext) { + Process process = processes.get(pid); + + if (process != null) { + if (process.isAlive()) { + callbackContext.success("running"); + } else { + cleanup(pid); + callbackContext.success("exited"); + } + } else { + callbackContext.success("not_found"); + } + } + + private void streamOutput(InputStream inputStream, String pid, String streamType) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { String line; diff --git a/src/plugins/terminal/www/Executor.js b/src/plugins/terminal/www/Executor.js index 18c2f8863..53ec73464 100644 --- a/src/plugins/terminal/www/Executor.js +++ b/src/plugins/terminal/www/Executor.js @@ -1,99 +1,117 @@ /** - * Executor module for interacting with shell processes on Cordova. - * Allows starting processes with real-time streaming, writing input, - * stopping processes, and traditional one-time execution. - * * @module Executor + * @description + * This module provides an interface to run shell commands from a Cordova app. + * It supports real-time process streaming, writing input to running processes, + * stopping them, and executing one-time commands. */ const exec = require('cordova/exec'); const Executor = { /** - * Starts a shell process and sets up real-time streaming for stdout, stderr, and exit events. + * Starts a shell process and enables real-time streaming of stdout, stderr, and exit status. * - * @param {string} command - The shell command to execute (e.g., `"sh"`, `"ls -al"`). - * @param {(type: 'stdout' | 'stderr' | 'exit', data: string) => void} onData - Callback to handle real-time output: + * @param {string} command - The shell command to run (e.g., `"sh"`, `"ls -al"`). + * @param {(type: 'stdout' | 'stderr' | 'exit', data: string) => void} onData - Callback that receives real-time output: * - `"stdout"`: Standard output line. * - `"stderr"`: Standard error line. - * - `"exit"`: Process exit code. - * - * @returns {Promise} Resolves with the process ID (PID). + * - `"exit"`: Exit code of the process. + * @param {boolean} alpine - Whether to run the command inside the Alpine sandbox environment (`true`) or on Android directly (`false`). + * @returns {Promise} Resolves with a unique process ID (UUID) used for future references like `write()` or `stop()`. * * @example * Executor.start('sh', (type, data) => { * console.log(`[${type}] ${data}`); - * }).then(pid => { - * Executor.write(pid, 'echo Hello World'); - * Executor.stop(pid); + * }).then(uuid => { + * Executor.write(uuid, 'echo Hello World'); + * Executor.stop(uuid); * }); */ - start(command, onData) { + start(command, onData, alpine = false) { return new Promise((resolve, reject) => { exec( (message) => { + // Stream stdout, stderr, or exit notifications if (message.startsWith("stdout:")) return onData("stdout", message.slice(7)); if (message.startsWith("stderr:")) return onData("stderr", message.slice(7)); if (message.startsWith("exit:")) return onData("exit", message.slice(5)); - // First message is PID + + // First message is always the process UUID resolve(message); }, reject, "Executor", "start", - [command] + [command, String(alpine)] ); }); }, /** - * Sends input to the stdin of a running process. + * Sends input to a running process's stdin. * - * @param {string} pid - The process ID returned by {@link Executor.start}. - * @param {string} input - The input string to send to the process. - * @returns {Promise} Resolves when the input is successfully written. + * @param {string} uuid - The process ID returned by {@link Executor.start}. + * @param {string} input - Input string to send (e.g., shell commands). + * @returns {Promise} Resolves once the input is written. * * @example - * Executor.write(pid, 'ls /data'); + * Executor.write(uuid, 'ls /data'); */ - write(pid, input) { + write(uuid, input) { return new Promise((resolve, reject) => { - exec(resolve, reject, "Executor", "write", [pid, input]); + exec(resolve, reject, "Executor", "write", [uuid, input]); }); }, /** - * Stops a running process. + * Terminates a running process. * - * @param {string} pid - The process ID returned by {@link Executor.start}. - * @returns {Promise} Resolves when the process is terminated. + * @param {string} uuid - The process ID returned by {@link Executor.start}. + * @returns {Promise} Resolves when the process has been stopped. * * @example - * Executor.stop(pid); + * Executor.stop(uuid); */ - stop(pid) { + stop(uuid) { return new Promise((resolve, reject) => { - exec(resolve, reject, "Executor", "stop", [pid]); + exec(resolve, reject, "Executor", "stop", [uuid]); }); }, /** - * Executes a shell command and waits for it to finish. - * Unlike `start()`, this is a one-time execution and does not stream real-time output. + * Checks if a process is still running. * - * @param {string} cmd - The command to execute. - * @returns {Promise} Resolves with stdout if the command succeeds, rejects with stderr or error message if it fails. + * @param {string} uuid - The process ID returned by {@link Executor.start}. + * @returns {Promise} Resolves `true` if the process is running, `false` otherwise. * * @example - * Executor.execute('ls -l').then(output => { - * console.log(output); - * }).catch(error => { - * console.error(error); - * }); + * const isAlive = await Executor.isRunning(uuid); + */ + isRunning(uuid) { + return new Promise((resolve, reject) => { + exec((result) => { + resolve(result === "running"); + }, reject, "Executor", "isRunning", [uuid]); + }); + }, + + /** + * Executes a shell command once and waits for it to finish. + * Unlike {@link Executor.start}, this does not stream output. + * + * @param {string} command - The shell command to execute. + * @param {boolean} alpine - Whether to run the command in the Alpine sandbox (`true`) or Android environment (`false`). + * @returns {Promise} Resolves with standard output on success, rejects with an error or standard error on failure. + * + * @example + * Executor.execute('ls -l') + * .then(console.log) + * .catch(console.error); */ - execute(cmd) { + execute(command, alpine = false) { return new Promise((resolve, reject) => { - exec(resolve, reject, "Executor", "exec", [cmd]); + exec(resolve, reject, "Executor", "exec", [command, String(alpine)]); }); } }; diff --git a/src/plugins/terminal/www/Terminal.js b/src/plugins/terminal/www/Terminal.js new file mode 100644 index 000000000..bda51ba6e --- /dev/null +++ b/src/plugins/terminal/www/Terminal.js @@ -0,0 +1,230 @@ +const Executor = require("./Executor"); + +/** + * AXS server version tag to be used in downloads. + * @constant {string} + */ +const AXS_VERSION_TAG = "v0.2.5"; + +const Terminal = { + /** + * Starts the AXS environment by writing init scripts and executing the sandbox. + * @param {boolean} [installing=false] - Whether AXS is being started during installation. + * @param {Function} [logger=console.log] - Function to log standard output. + * @param {Function} [err_logger=console.error] - Function to log errors. + * @returns {Promise} + */ + async startAxs(installing = false, logger = console.log, err_logger = console.error) { + const filesDir = await new Promise((resolve, reject) => { + system.getFilesDir(resolve, reject); + }); + + readAsset("init-alpine.sh", async (content) => { + system.writeText(`${filesDir}/init-alpine.sh`, content, logger, err_logger); + }); + + readAsset("init-sandbox.sh", (content) => { + system.writeText(`${filesDir}/init-sandbox.sh`, content, logger, err_logger); + + Executor.start("sh", (type, data) => { + logger(`${type} ${data}`); + }).then(async (uuid) => { + await Executor.write(uuid, `source ${filesDir}/init-sandbox.sh ${installing ? "--installing" : ""}; exit`); + }); + }); + }, + + /** + * Stops the AXS process by forcefully killing it. + * @returns {Promise} + */ + async stopAxs() { + await Executor.execute(`kill -KILL $(cat $PREFIX/pid)`); + }, + + /** + * Checks if the AXS process is currently running. + * @returns {Promise} - `true` if AXS is running, `false` otherwise. + */ + async isAxsRunning() { + const filesDir = await new Promise((resolve, reject) => { + system.getFilesDir(resolve, reject); + }); + + const pidExists = await new Promise((resolve, reject) => { + system.fileExists(`${filesDir}/pid`, false, (result) => { + resolve(result == 1); + }, reject); + }); + + if (!pidExists) return false; + + const result = await Executor.execute(`kill -0 $(cat $PREFIX/pid) 2>/dev/null && echo "true" || echo "false"`); + return String(result).toLowerCase() === "true"; + }, + + /** + * Installs Alpine by downloading binaries and extracting the root filesystem. + * Also sets up additional dependencies for F-Droid variant. + * @param {Function} [logger=console.log] - Function to log standard output. + * @param {Function} [err_logger=console.error] - Function to log errors. + * @returns {Promise} + */ + async install(logger = console.log, err_logger = console.error) { + if (await this.isInstalled()) return; + if (!(await this.isSupported())) return; + + const filesDir = await new Promise((resolve, reject) => { + system.getFilesDir(resolve, reject); + }); + + const arch = await new Promise((resolve, reject) => { + system.getArch(resolve, reject); + }); + + try { + let alpineUrl; + let axsUrl; + let prootUrl = ""; + let libTalloc = ""; + + if (arch === "arm64-v8a") { + axsUrl = `https://github.com/bajrangCoder/acodex_server/releases/download/${AXS_VERSION_TAG}/axs-musl-android-arm64`; + alpineUrl = "https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/aarch64/alpine-minirootfs-3.21.0-aarch64.tar.gz"; + } else if (arch === "armeabi-v7a") { + axsUrl = `https://github.com/bajrangCoder/acodex_server/releases/download/${AXS_VERSION_TAG}/axs-musl-android-armv7`; + alpineUrl = "https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/armhf/alpine-minirootfs-3.21.0-armhf.tar.gz"; + } else if (arch === "x86_64") { + axsUrl = `https://github.com/bajrangCoder/acodex_server/releases/download/${AXS_VERSION_TAG}/axs-musl-android-x86_64`; + alpineUrl = "https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/x86_64/alpine-minirootfs-3.21.0-x86_64.tar.gz"; + } else { + throw new Error(`Unsupported architecture: ${arch}`); + } + + logger("Downloading files..."); + + await new Promise((resolve, reject) => { + cordova.plugin.http.downloadFile( + alpineUrl, {}, {}, + cordova.file.dataDirectory + "alpine.tar.gz", + resolve, reject + ); + }); + + await new Promise((resolve, reject) => { + cordova.plugin.http.downloadFile( + axsUrl, {}, {}, + cordova.file.dataDirectory + "axs", + resolve, reject + ); + }); + + const isFdroid = await Executor.execute("echo $FDROID"); + if (isFdroid === "true") { + logger("Fdroid flavor detected, downloading extra files..."); + await new Promise((resolve, reject) => { + cordova.plugin.http.downloadFile( + prootUrl, {}, {}, + cordova.file.dataDirectory + "libproot-xed.so", + resolve, reject + ); + }); + + await new Promise((resolve, reject) => { + cordova.plugin.http.downloadFile( + libTalloc, {}, {}, + cordova.file.dataDirectory + "libtalloc.so.2", + resolve, reject + ); + }); + } + + logger("✅ Download complete"); + + await new Promise((resolve, reject) => { + system.mkdirs(`${filesDir}/.downloaded`, resolve, reject); + }); + + const alpineDir = `${filesDir}/alpine`; + + await new Promise((resolve, reject) => { + system.mkdirs(alpineDir, resolve, reject); + }); + + logger("Extracting..."); + await Executor.execute(`tar -xf ${filesDir}/alpine.tar.gz -C ${alpineDir}`); + + system.writeText(`${alpineDir}/etc/resolv.conf`, `nameserver 8.8.4.4 \nnameserver 8.8.8.8`); + + logger("✅ Extraction complete"); + + await new Promise((resolve, reject) => { + system.mkdirs(`${filesDir}/.extracted`, resolve, reject); + }); + + this.startAxs(true, logger, err_logger); + + } catch (e) { + err_logger("Installation failed:", e); + } + }, + + /** + * Checks if alpine is already installed. + * @returns {Promise} - Returns true if all required files and directories exist. + */ + isInstalled() { + return new Promise(async (resolve, reject) => { + const filesDir = await new Promise((resolve, reject) => { + system.getFilesDir(resolve, reject); + }); + + const alpineExists = await new Promise((resolve, reject) => { + system.fileExists(`${filesDir}/alpine.tar.gz`, false, (result) => { + resolve(result == 1); + }, reject); + }); + + const downloaded = alpineExists && await new Promise((resolve, reject) => { + system.fileExists(`${filesDir}/.downloaded`, false, (result) => { + resolve(result == 1); + }, reject); + }); + + const extracted = alpineExists && await new Promise((resolve, reject) => { + system.fileExists(`${filesDir}/.extracted`, false, (result) => { + resolve(result == 1); + }, reject); + }); + + resolve(alpineExists && downloaded && extracted); + }); + }, + + /** + * Checks if the current device architecture is supported. + * @returns {Promise} - `true` if architecture is supported, otherwise `false`. + */ + isSupported() { + return new Promise((resolve, reject) => { + system.getArch((arch) => { + resolve(["arm64-v8a", "armeabi-v7a", "x86_64"].includes(arch)); + }, reject); + }); + } +}; + + +function readAsset(assetPath, callback) { + const assetUrl = "file:///android_asset/" + assetPath; + + window.resolveLocalFileSystemURL(assetUrl, fileEntry => { + fileEntry.file(file => { + const reader = new FileReader(); + reader.onloadend = () => callback(reader.result); + reader.readAsText(file); + }, console.error); + }, console.error); +} + +module.exports = Terminal;