diff --git a/app/src/main/java/org/lsposed/manager/ConfigManager.java b/app/src/main/java/org/lsposed/manager/ConfigManager.java index 79a1236a7..e5377bbf1 100644 --- a/app/src/main/java/org/lsposed/manager/ConfigManager.java +++ b/app/src/main/java/org/lsposed/manager/ConfigManager.java @@ -409,4 +409,30 @@ public static boolean setAutoInclude(String packageName, boolean enable) { return false; } } + + public static String getCurrentCliPin() { + try { + return LSPManagerServiceHolder.getService().getCurrentCliPin(); + } catch (RemoteException e) { + Log.e(App.TAG, Log.getStackTraceString(e)); + return null; + } + } + + public static String resetCliPin() { + try { + return LSPManagerServiceHolder.getService().resetCliPin(); + } catch (RemoteException e) { + Log.e(App.TAG, Log.getStackTraceString(e)); + return null; + } + } + + public static void disableCli() { + try { + LSPManagerServiceHolder.getService().disableCli(); + } catch (RemoteException e) { + Log.e(App.TAG, Log.getStackTraceString(e)); + } + } } diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java index ef8c5f728..65097ce8b 100644 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java @@ -24,6 +24,7 @@ import android.os.Build; import android.os.Bundle; import android.provider.Settings; +import android.text.Html; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; @@ -58,6 +59,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Locale; +import java.util.UUID; import rikka.core.util.ResourceUtils; import rikka.material.app.LocaleDelegate; @@ -371,6 +373,12 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { translation_contributors.setSummary(translators); } } + + MaterialSwitchPreference prefCli = findPreference("enable_cli"); + if (prefCli != null) { + setupCliPreference(prefCli); + } + SimpleMenuPreference channel = findPreference("update_channel"); if (channel != null) { channel.setOnPreferenceChangeListener((preference, newValue) -> { @@ -398,5 +406,50 @@ public RecyclerView onCreateRecyclerView(@NonNull LayoutInflater inflater, @NonN } return recyclerView; } + + private void setupCliPreference(MaterialSwitchPreference prefCli) { + boolean installed = ConfigManager.isBinderAlive(); + if (!installed) { + prefCli.setEnabled(false); + return; + } + prefCli.setEnabled(true); + + // On load, check the daemon's memory for the current state. + String currentPin = ConfigManager.getCurrentCliPin(); + boolean isEnabled; + if (BuildConfig.DEBUG) { + // On DEBUG, the feature is considered enabled even if PIN is null (default-on state) + isEnabled = true; + prefCli.setEnabled(false); + } else { + isEnabled = currentPin != null; + } + prefCli.setChecked(isEnabled); + updateCliSummary(prefCli, currentPin); + + prefCli.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enable = (boolean) newValue; + String newPin = null; + if (enable) { + newPin = ConfigManager.resetCliPin(); + } else { + ConfigManager.disableCli(); + } + updateCliSummary(preference, newPin); + return true; + }); + } + + private void updateCliSummary(Preference pref, String pin) { + if (BuildConfig.DEBUG && pin == null) { + pref.setSummary(R.string.pref_summary_cli_debug); + } else if (pin != null) { + String summary = getString(R.string.pref_summary_cli_pin, pin); + pref.setSummary(Html.fromHtml(summary, Html.FROM_HTML_MODE_COMPACT)); + } else { + pref.setSummary(R.string.pref_summary_enable_cli); + } + } } } diff --git a/app/src/main/res/drawable/ic_outline_cmd_24.xml b/app/src/main/res/drawable/ic_outline_cmd_24.xml new file mode 100644 index 000000000..a52ab0836 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_cmd_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b4b2694e..8b7c2a37e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -209,6 +209,16 @@ Nightly build Xposed API call protection Block dynamically loaded module code to use Xposed API, this may break some modules but benefit security + Command line interface + Enable CLI feature + + Automate LSPosed with shell scripts via PIN authentication + + Your CLI auth PIN is: %1$s
+ + Enabled for debugging. No authentication required. + Disabled + Readme Releases diff --git a/app/src/main/res/xml/prefs.xml b/app/src/main/res/xml/prefs.xml index 6b9d8691c..f97483657 100644 --- a/app/src/main/res/xml/prefs.xml +++ b/app/src/main/res/xml/prefs.xml @@ -160,4 +160,14 @@ android:summary="@string/settings_show_hidden_icon_apps_enabled_summary" android:title="@string/settings_show_hidden_icon_apps_enabled" /> + + + + diff --git a/core/src/main/java/org/lsposed/lspd/core/ApplicationServiceClient.java b/core/src/main/java/org/lsposed/lspd/core/ApplicationServiceClient.java index 6eb01d0b3..3c5f80340 100644 --- a/core/src/main/java/org/lsposed/lspd/core/ApplicationServiceClient.java +++ b/core/src/main/java/org/lsposed/lspd/core/ApplicationServiceClient.java @@ -102,6 +102,14 @@ public ParcelFileDescriptor requestInjectedManagerBinder(List binder) { return null; } + @Override + public void requestCLIBinder(String sPin, List binder) { + try { + service.requestCLIBinder(sPin, binder); + } catch (RemoteException | NullPointerException ignored) { + } + } + @Override public IBinder asBinder() { return service.asBinder(); diff --git a/daemon/build.gradle.kts b/daemon/build.gradle.kts index 3514b4f5a..21935e03e 100644 --- a/daemon/build.gradle.kts +++ b/daemon/build.gradle.kts @@ -20,6 +20,7 @@ import com.android.build.api.dsl.ApplicationExtension import com.android.ide.common.signing.KeystoreHelper import java.io.PrintStream +import java.util.UUID plugins { alias(libs.plugins.agp.app) @@ -99,6 +100,7 @@ android.applicationVariants.all { sign?.keyPassword, sign?.keyAlias ) + val uuid = UUID.randomUUID().toString(); PrintStream(outSrc).print( """ |package org.lsposed.lspd.util; @@ -106,6 +108,7 @@ android.applicationVariants.all { | public static final byte[] CERTIFICATE = {${ certificateInfo.certificate.encoded.joinToString(",") }}; + | public static final String CLI_UUID = "$uuid"; |}""".trimMargin() ) } @@ -116,6 +119,7 @@ android.applicationVariants.all { dependencies { implementation(libs.libxposed.`interface`) implementation(libs.agp.apksig) + implementation(libs.picocli) implementation(projects.apache) implementation(projects.hiddenapi.bridge) implementation(projects.services.daemonService) diff --git a/daemon/proguard-rules.pro b/daemon/proguard-rules.pro index 74c73f81e..e8d2d4e63 100644 --- a/daemon/proguard-rules.pro +++ b/daemon/proguard-rules.pro @@ -1,6 +1,19 @@ -keepclasseswithmembers,includedescriptorclasses class * { native ; } +-keepattributes *Annotation*, Signature, Exception + +-keep class picocli.CommandLine { *; } +-keep class picocli.CommandLine$* { *; } +-keep class org.lsposed.lspd.cli.* {*;} + +-keepclassmembers class * extends java.util.concurrent.Callable { + public java.lang.Integer call(); +} + +-keepclasseswithmembers class org.lsposed.lspd.cli.Main { + public static void main(java.lang.String[]); +} -keepclasseswithmembers class org.lsposed.lspd.Main { public static void main(java.lang.String[]); } diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/GlobalOptions.java b/daemon/src/main/java/org/lsposed/lspd/cli/GlobalOptions.java new file mode 100644 index 000000000..67e71b577 --- /dev/null +++ b/daemon/src/main/java/org/lsposed/lspd/cli/GlobalOptions.java @@ -0,0 +1,8 @@ +package org.lsposed.lspd.cli; + +import picocli.CommandLine; + +public class GlobalOptions { + @CommandLine.Option(names = {"-j", "--json"}, description = "Output results in JSON format.") + public boolean jsonOutput; +} diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java new file mode 100644 index 000000000..3f2059b72 --- /dev/null +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java @@ -0,0 +1,835 @@ +package org.lsposed.lspd.cli; + +import static org.lsposed.lspd.cli.Utils.CMDNAME; +import static org.lsposed.lspd.cli.Utils.ERRCODES; + +import android.content.pm.PackageManager; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.Parcel; +import android.os.ParcelFileDescriptor; +import android.os.RemoteException; +import android.os.ServiceManager; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import org.lsposed.lspd.ICLIService; +import org.lsposed.lspd.models.Application; +import org.lsposed.lspd.service.ILSPApplicationService; + +import picocli.CommandLine; +import picocli.CommandLine.IExecutionExceptionHandler; + +/** + * Main entry point for the LSPosed Command Line Interface (CLI). + *

+ * This application uses the picocli framework to parse commands and arguments, + * and communicates with the LSPosed daemon via Binder IPC to perform actions. + */ + +//================================================================================ +// Sub-Commands +//================================================================================ + +@CommandLine.Command(name = "ls", description = "Lists installed Xposed modules.") +class ListModulesCommand implements Callable { + @CommandLine.ParentCommand + private ModulesCommand parent; + + @CommandLine.Mixin + private GlobalOptions globalOptions = new GlobalOptions(); + + @CommandLine.ArgGroup(exclusive = true) + LSModuleOpts objArgs = new LSModuleOpts(); + + static class LSModuleOpts { + @CommandLine.Option(names = {"-e", "--enabled"}, description = "List only modules that are currently enabled.", required = true) + boolean bEnable; + @CommandLine.Option(names = {"-d", "--disabled"}, description = "List only modules that are currently disabled.", required = true) + boolean bDisable; + } + + private static final int MATCH_ANY_USER = 0x00400000; + private static final int MATCH_ALL_FLAGS = PackageManager.MATCH_DISABLED_COMPONENTS | PackageManager.MATCH_DIRECT_BOOT_AWARE | + PackageManager.MATCH_DIRECT_BOOT_UNAWARE | PackageManager.MATCH_UNINSTALLED_PACKAGES | MATCH_ANY_USER; + + @Override + public Integer call() throws RemoteException, JSONException { + ICLIService manager = parent.parent.getManager(); + List lstEnabledModules = Arrays.asList(manager.enabledModules()); + var lstPackages = manager.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | MATCH_ALL_FLAGS, false); + JSONArray modulesArray = new JSONArray(); + boolean printedHeader = false; + + for (var packageInfo : lstPackages.getList()) { + if (packageInfo.applicationInfo.metaData != null && packageInfo.applicationInfo.metaData.containsKey("xposedmodule")) { + boolean isPkgEnabled = lstEnabledModules.contains(packageInfo.packageName); + + boolean shouldList = (!objArgs.bEnable && !objArgs.bDisable) || (objArgs.bEnable && isPkgEnabled) || (objArgs.bDisable && !isPkgEnabled); + + if (shouldList) { + if (globalOptions.jsonOutput) { + JSONObject moduleObject = new JSONObject(); + moduleObject.put("packageName", packageInfo.packageName); + moduleObject.put("uid", packageInfo.applicationInfo.uid); + moduleObject.put("enabled", isPkgEnabled); + modulesArray.put(moduleObject); + } else { + if (!printedHeader) { + System.out.println(String.format("%-45s %-10s %-8s", "PACKAGE", "UID", "STATUS")); + printedHeader = true; + } + System.out.println(String.format("%-45s %-10d %-8s", packageInfo.packageName, packageInfo.applicationInfo.uid, isPkgEnabled ? "enabled" : "disabled")); + } + } + } + } + + if (globalOptions.jsonOutput) { + System.out.println(modulesArray.toString(2)); + } + return ERRCODES.NOERROR.ordinal(); + } +} + +@CommandLine.Command(name = "set", description = "Enables or disables one or more modules.") +class SetModulesCommand implements Callable { + @CommandLine.ParentCommand + private ModulesCommand parent; + + @CommandLine.Mixin + private GlobalOptions globalOptions = new GlobalOptions(); + + @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") + SetModuleOpts objArgs = new SetModuleOpts(); + + static class SetModuleOpts { + @CommandLine.Option(names = {"-e", "--enable"}, description = "Enable the specified modules.", required = true) + boolean bEnable; + @CommandLine.Option(names = {"-d", "--disable"}, description = "Disable the specified modules.", required = true) + boolean bDisable; + } + + @CommandLine.Option(names = {"-i", "--ignore"}, description = "Ignore modules that are not installed.") + boolean bIgnore; + @CommandLine.Parameters(index = "0..*", description = "The package name(s) of the module(s) to modify.", paramLabel = "", arity = "1..*") + List lstModules; + + @Override + public Integer call() throws RemoteException, JSONException { + ICLIService manager = parent.parent.getManager(); + boolean rebootRequired = false; + boolean allSuccess = true; + JSONArray resultsArray = new JSONArray(); + + for (String module : lstModules) { + String status = "unknown"; + String message; + boolean success = false; + String action = objArgs.bEnable ? "enable" : "disable"; + + List scope = manager.getModuleScope(module); + if (scope == null) { + message = manager.getLastErrorMsg(); + allSuccess = false; + } else if (objArgs.bEnable && scope.size() == 0) { + message = "Cannot enable: module scope is empty."; + allSuccess = false; + } else { + if (objArgs.bEnable ? manager.enableModule(module) : manager.disableModule(module)) { + success = true; + status = objArgs.bEnable ? "enabled" : "disabled"; + message = "Module successfully " + status + "."; + if (Utils.checkPackageInScope("android", scope)) { + rebootRequired = true; + } + } else { + message = "Failed to " + action + " module via daemon."; + allSuccess = false; + } + } + + if (globalOptions.jsonOutput) { + JSONObject result = new JSONObject(); + result.put("module", module); + result.put("success", success); + result.put("status", status); + result.put("message", message); + resultsArray.put(result); + } else { + if (success) { + System.out.println(module + ": " + message); + } else { + System.err.println(module + ": Error! " + message); + } + } + } + + if (globalOptions.jsonOutput) { + JSONObject finalOutput = new JSONObject(); + finalOutput.put("success", allSuccess); + finalOutput.put("rebootRequired", rebootRequired); + finalOutput.put("results", resultsArray); + System.out.println(finalOutput.toString(2)); + } else if (rebootRequired) { + System.err.println("\nWarning: A reboot is required for some changes to take full effect."); + } + + return allSuccess ? ERRCODES.NOERROR.ordinal() : ERRCODES.ENABLE_DISABLE.ordinal(); + } +} + +@CommandLine.Command(name = "modules", description = "Manages Xposed modules.", subcommands = {ListModulesCommand.class, SetModulesCommand.class}) +class ModulesCommand implements Runnable { + @CommandLine.ParentCommand + Main parent; + + @CommandLine.Spec + CommandLine.Model.CommandSpec spec; + + public void run() { + throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand. See 'lsposed-cli modules --help'."); + } +} + +class Scope extends Application { + public static class Converter implements CommandLine.ITypeConverter { + @Override + public Scope convert(String value) { + String[] parts = value.split("/", 2); + if (parts.length != 2) { + throw new CommandLine.TypeConversionException("Invalid scope format. Expected 'package/user_id', but got '" + value + "'."); + } + try { + return new Scope(parts[0], Integer.parseInt(parts[1])); + } catch (NumberFormatException e) { + throw new CommandLine.TypeConversionException("Invalid user_id in scope '" + value + "'. Must be an integer."); + } + } + } + + public Scope(String packageName, int userId) { + this.packageName = packageName; + this.userId = userId; + } +} + +@CommandLine.Command(name = "ls", description = "Displays the scope of a specific module.") +class ListScopeCommand implements Callable { + @CommandLine.ParentCommand + private ScopeCommand parent; + + @CommandLine.Mixin + private GlobalOptions globalOptions = new GlobalOptions(); + + @CommandLine.Parameters(index = "0", description = "The package name of the module.", paramLabel = "") + String moduleName; + + @Override + public Integer call() throws RemoteException, JSONException { + ICLIService manager = parent.parent.getManager(); + List scopeList = manager.getModuleScope(moduleName); + + if (scopeList == null) { + System.err.println("Error: " + manager.getLastErrorMsg()); + return ERRCODES.LS_SCOPE.ordinal(); + } + + if (globalOptions.jsonOutput) { + JSONArray scopeArray = new JSONArray(); + for (Application app : scopeList) { + scopeArray.put(app.packageName + "/" + app.userId); + } + System.out.println(scopeArray.toString(2)); + } else { + for (Application app : scopeList) { + System.out.println(app.packageName + "/" + app.userId); + } + } + return ERRCODES.NOERROR.ordinal(); + } +} + +@CommandLine.Command(name = "set", description = "Sets, appends to, or removes from a module's scope.") +class SetScopeCommand implements Callable { + @CommandLine.ParentCommand + private ScopeCommand parent; + + @CommandLine.Mixin + private GlobalOptions globalOptions = new GlobalOptions(); + + @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") + ScopeOpts objArgs = new ScopeOpts(); + + static class ScopeOpts { + @CommandLine.Option(names = {"-s", "--set"}, description = "Overwrite the entire scope with the given list.", required = true) + boolean bSet; + @CommandLine.Option(names = {"-a", "--append"}, description = "Add the given applications to the existing scope.", required = true) + boolean bAppend; + @CommandLine.Option(names = {"-d", "--remove"}, description = "Remove the given applications from the existing scope.", required = true) + boolean bDel; + } + + @CommandLine.Parameters(index = "0", description = "The package name of the module to configure.", paramLabel = "", arity = "1") + String moduleName; + + @CommandLine.Parameters(index = "1..*", description = "Application(s) to form the scope, as 'package/user_id'.", paramLabel = "", arity = "1..*") + Scope[] scopes; + + @Override + public Integer call() throws RemoteException, JSONException { + ICLIService manager = parent.parent.getManager(); + boolean rebootRequired = false; + + for (Scope scope : scopes) { + if (!parent.parent.getCliUtils().validPackageNameAndUserId(manager, scope.packageName, scope.userId)) { + throw new RuntimeException("Error: Invalid application '" + scope.packageName + "/" + scope.userId + "'. Not an installed package for that user."); + } + if ("android".equals(scope.packageName)) { + rebootRequired = true; + } + } + + List finalScope; + if (objArgs.bSet) { + finalScope = new ArrayList<>(Arrays.asList(scopes)); + List oldScope = manager.getModuleScope(moduleName); + if (oldScope != null && Utils.checkPackageInScope("android", oldScope) && !rebootRequired) { + rebootRequired = true; // Reboot is needed if 'android' is removed from scope. + } + } else { + finalScope = manager.getModuleScope(moduleName); + if (finalScope == null) { + throw new RuntimeException("Error: " + manager.getLastErrorMsg()); + } + for (Scope scope : scopes) { + if (objArgs.bAppend) { + finalScope.add(scope); + } else { // bDel + finalScope.removeIf(app -> scope.packageName.equals(app.packageName) && scope.userId == app.userId); + } + } + } + + if (manager.setModuleScope(moduleName, finalScope)) { + if (finalScope.size() < 2) { + manager.disableModule(moduleName); + if (!globalOptions.jsonOutput) { + System.err.println("Warning: Scope is now empty or contains only the module itself. Module has been disabled."); + } + } + + if (globalOptions.jsonOutput) { + JSONObject result = new JSONObject(); + result.put("success", true); + result.put("module", moduleName); + result.put("rebootRequired", rebootRequired); + System.out.println(result.toString(2)); + } else { + System.out.println("Successfully updated scope for " + moduleName + "."); + if (rebootRequired) { + System.err.println("Warning: A reboot is required for changes to take full effect."); + } + } + } else { + throw new RuntimeException("Failed to set scope for " + moduleName + ". Reason: " + manager.getLastErrorMsg()); + } + return ERRCODES.NOERROR.ordinal(); + } +} + +@CommandLine.Command(name = "scope", description = "Manages module scopes.", subcommands = {ListScopeCommand.class, SetScopeCommand.class}) +class ScopeCommand implements Runnable { + @CommandLine.ParentCommand + Main parent; + + @CommandLine.Spec + CommandLine.Model.CommandSpec spec; + + public void run() { + throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand. See 'lsposed-cli scope --help'."); + } +} + +@CommandLine.Command(name = "backup", description = "Creates a compressed backup of module settings and scopes.") +class BackupCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + + @CommandLine.Parameters(index = "0..*", description = "Specific module(s) to back up. If omitted, all modules are backed up.", paramLabel = "") + String[] modulesName; + @CommandLine.Option(names = {"-f", "--file"}, description = "Output file path. If omitted, a timestamped file is created in the current directory.", paramLabel = "") + String file; + + private static final int VERSION = 2; + private static final int MATCH_ANY_USER = 0x00400000; + private static final int MATCH_ALL_FLAGS = PackageManager.MATCH_DISABLED_COMPONENTS | PackageManager.MATCH_DIRECT_BOOT_AWARE | + PackageManager.MATCH_DIRECT_BOOT_UNAWARE | PackageManager.MATCH_UNINSTALLED_PACKAGES | MATCH_ANY_USER; + + @Override + public Integer call() throws RemoteException { + ICLIService manager = parent.getManager(); + + if (modulesName == null || modulesName.length == 0) { + List modules = new ArrayList<>(); + var packages = manager.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | MATCH_ALL_FLAGS, false); + for (var packageInfo : packages.getList()) { + if (packageInfo.applicationInfo.metaData != null && packageInfo.applicationInfo.metaData.containsKey("xposedmodule")) { + modules.add(packageInfo.packageName); + } + } + modulesName = modules.toArray(new String[0]); + } + if (file == null) { + file = String.format("LSPosed_%s.lsp.gz", LocalDateTime.now()); + } + + List enabledModules = Arrays.asList(manager.enabledModules()); + JSONObject rootObject = new JSONObject(); + try { + rootObject.put("version", VERSION); + JSONArray modulesArray = new JSONArray(); + + for (String module : modulesName) { + JSONObject moduleObject = new JSONObject(); + moduleObject.put("enable", enabledModules.contains(module)); + moduleObject.put("package", module); + moduleObject.put("autoInclude", manager.getAutoInclude(module)); + + JSONArray scopeArray = new JSONArray(); + List scopes = manager.getModuleScope(module); + if (scopes != null) { + for (Application s : scopes) { + JSONObject app = new JSONObject(); + app.put("package", s.packageName); + app.put("userId", s.userId); + scopeArray.put(app); + } + } + moduleObject.put("scope", scopeArray); + modulesArray.put(moduleObject); + } + rootObject.put("modules", modulesArray); + + try (FileOutputStream fos = new FileOutputStream(file); + GZIPOutputStream gzipOutputStream = new GZIPOutputStream(fos)) { + gzipOutputStream.write(rootObject.toString().getBytes()); + } + System.out.println("Backup created successfully at: " + file); + } catch (Exception ex) { + throw new RuntimeException("Failed to create backup: " + ex.getMessage(), ex); + } + return ERRCODES.NOERROR.ordinal(); + } +} + +@CommandLine.Command(name = "log", description = "Streams and manages the LSPosed framework and module logs.") +class LogCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + + @CommandLine.Option(names = {"-f", "--follow"}, description = "Continuously print new log entries as they appear.") + boolean bFollow; + @CommandLine.Option(names = {"-v", "--verbose"}, description = "Access the verbose framework logs instead of module logs.") + boolean bVerboseLog; + @CommandLine.Option(names = {"-c", "--clear"}, description = "Clear the specified log file before streaming.") + boolean bClear; + + @Override + public Integer call() throws RemoteException { + ICLIService manager = parent.getManager(); + if (bClear) { + manager.clearLogs(bVerboseLog); + if (!bFollow) { + return 0; + } + } + ParcelFileDescriptor pfdLog = bVerboseLog ? manager.getVerboseLog() : manager.getModulesLog(); + printLog(pfdLog); + + return ERRCODES.NOERROR.ordinal(); + } + + private void printLog(ParcelFileDescriptor parcelFileDescriptor) { + try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(parcelFileDescriptor.getFileDescriptor())))) { + // TODO: Handle SIGINT for a graceful exit when in follow mode. + while (true) { + String sLine = br.readLine(); + if (sLine == null) { + if (bFollow) { + try { + Thread.sleep(500); + } catch (InterruptedException ignored) { + break; // Exit loop on interrupt + } + } else { + break; + } + } else { + System.out.println(sLine); + } + } + } catch (IOException e) { + System.err.println("Error reading log file: " + e.getMessage()); + } + } +} + +@CommandLine.Command(name = "login", description = "Authenticates the CLI and provides a session variable for subsequent commands.") +class LoginCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + + @CommandLine.Option(names = "--for-eval", description = "Output only the export command for use with `eval $(...)`.") + private boolean forEval; + + @Override + public Integer call() throws Exception { + // Authenticate by simply requesting the manager. An exception will be thrown on failure. + parent.getManager(); + + String pin = parent.pin; + if (pin == null) { + System.err.println("Error: Could not retrieve the PIN used for authentication."); + return 1; + } + + String exportCommand = "export LSPOSED_CLI_PIN=\"" + pin + "\""; + + if (forEval) { + System.out.println(exportCommand); + } else { + System.out.println("✅ Authentication successful."); + System.out.println("\nTo avoid typing the PIN for every command in this shell session, run:"); + System.out.println("\n " + exportCommand + "\n"); + System.out.println("You can then run commands like 'lsposed-cli status' without the --pin argument."); + } + return 0; + } +} + +@CommandLine.Command(name = "status", description = "Displays the status and version of the LSPosed framework.") +class StatusCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + + @CommandLine.Mixin + private GlobalOptions globalOptions = new GlobalOptions(); + + @Override + public Integer call() throws RemoteException, JSONException { + ICLIService manager = parent.getManager(); + if (globalOptions.jsonOutput) { + JSONObject status = new JSONObject(); + status.put("xposedApiVersion", manager.getXposedApiVersion()); + status.put("injectionInterface", manager.getApi()); + status.put("frameworkVersionName", manager.getXposedVersionName()); + status.put("frameworkVersionCode", manager.getXposedVersionCode()); + status.put("systemVersion", getSystemVersion()); + status.put("device", getDevice()); + status.put("systemAbi", Build.SUPPORTED_ABIS[0]); + System.out.println(status.toString(2)); + } else { + System.out.printf("API Version: %d\n", manager.getXposedApiVersion()); + System.out.printf("Injection Interface: %s\n", manager.getApi()); + System.out.printf("Framework Version: %s (%d)\n", manager.getXposedVersionName(), manager.getXposedVersionCode()); + System.out.printf("System Version: %s\n", getSystemVersion()); + System.out.printf("Device: %s\n", getDevice()); + System.out.printf("System ABI: %s\n", Build.SUPPORTED_ABIS[0]); + } + return ERRCODES.NOERROR.ordinal(); + } + + private String getSystemVersion() { + if (Build.VERSION.PREVIEW_SDK_INT != 0) { + return String.format("%s Preview (API %d)", Build.VERSION.CODENAME, Build.VERSION.SDK_INT); + } else { + return String.format("%s (API %d)", Build.VERSION.RELEASE, Build.VERSION.SDK_INT); + } + } + + private String getDevice() { + String manufacturer = Character.toUpperCase(Build.MANUFACTURER.charAt(0)) + Build.MANUFACTURER.substring(1); + if (!Build.BRAND.equals(Build.MANUFACTURER)) { + manufacturer += " " + Character.toUpperCase(Build.BRAND.charAt(0)) + Build.BRAND.substring(1); + } + manufacturer += " " + Build.MODEL; + return manufacturer; + } +} + +@CommandLine.Command(name = "restore", description = "Restores module settings and scopes from a backup file.") +class RestoreCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + + @CommandLine.Parameters(index = "0..*", description = "Specific module(s) to restore. If omitted, all modules in the backup are restored.", paramLabel = "") + String[] modulesName; + @CommandLine.Option(names = {"-f", "--file"}, description = "Path to the backup file to restore from.", required = true, paramLabel = "") + String file; + + private static final int VERSION = 2; + + @Override + public Integer call() throws RemoteException { + ICLIService manager = parent.getManager(); + + String jsonString; + try (FileInputStream fis = new FileInputStream(file); + GZIPInputStream gzipInputStream = new GZIPInputStream(fis); + ByteArrayOutputStream os = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + int length; + while ((length = gzipInputStream.read(buf)) > 0) { + os.write(buf, 0, length); + } + jsonString = os.toString(); + } catch (Exception ex) { + throw new RuntimeException("Failed to read backup file: " + ex.getMessage(), ex); + } + + List modulesToRestore = (modulesName == null || modulesName.length == 0) ? null : Arrays.asList(modulesName); + + try { + JSONObject rootObject = new JSONObject(jsonString); + int version = rootObject.getInt("version"); + if (version > VERSION) { + throw new RuntimeException("Unsupported backup version: " + version); + } + + JSONArray jsmodules = rootObject.getJSONArray("modules"); + for (int i = 0; i < jsmodules.length(); i++) { + JSONObject moduleObject = jsmodules.getJSONObject(i); + String name = moduleObject.getString("package"); + + if (modulesToRestore != null && !modulesToRestore.contains(name)) { + continue; // Skip module if it's not in the user's specified list + } + + System.out.println("Restoring settings for: " + name); + + if (moduleObject.getBoolean("enable")) { + manager.enableModule(name); + } else { + manager.disableModule(name); + } + + manager.setAutoInclude(name, moduleObject.optBoolean("autoInclude", false)); + + JSONArray scopeArray = moduleObject.getJSONArray("scope"); + List scopes = new ArrayList<>(); + for (int j = 0; j < scopeArray.length(); j++) { + if (version == VERSION) { + JSONObject app = scopeArray.getJSONObject(j); + scopes.add(new Scope(app.getString("package"), app.getInt("userId"))); + } else { // Legacy v1 format + scopes.add(new Scope(scopeArray.getString(j), 0)); + } + } + manager.setModuleScope(name, scopes); + } + System.out.println("Restore completed successfully."); + } catch (JSONException je) { + throw new RuntimeException("Failed to parse backup file: " + je.getMessage(), je); + } + return ERRCODES.NOERROR.ordinal(); + } +} + +@CommandLine.Command(name = "revoke-pin", description = "Revokes the current CLI PIN. Disables CLI access until re-enabled from the Manager app.") +class RevokePinCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + + @CommandLine.Mixin + private GlobalOptions globalOptions = new GlobalOptions(); + + @Override + public Integer call() throws Exception { + parent.getManager().revokeCurrentPin(); + if (globalOptions.jsonOutput) { + JSONObject result = new JSONObject(); + result.put("success", true); + result.put("message", "PIN has been revoked. Re-enable the CLI from the Manager app to generate a new one."); + System.out.println(result.toString(2)); + } else { + System.out.println("✅ PIN has been revoked. You must re-enable the CLI from the Manager app to generate a new one."); + } + return 0; + } +} + +//================================================================================ +// Main Application Class +//================================================================================ + +@CommandLine.Command(name = CMDNAME, + version = "LSPosed CLI 0.4", + mixinStandardHelpOptions = true, // Use picocli's built-in --help and --version + header = "LSPosed Command Line Interface", + description = "A tool to manage the LSPosed framework and modules from the command line.", + subcommands = { + ModulesCommand.class, + ScopeCommand.class, + BackupCommand.class, + LogCommand.class, + LoginCommand.class, + StatusCommand.class, + RestoreCommand.class, + RevokePinCommand.class + }) +public class Main implements Runnable { + + @CommandLine.Option(names = {"-p", "--pin"}, description = "Authentication PIN for the CLI.", scope = CommandLine.ScopeType.INHERIT) + String pin; + + @CommandLine.Spec + CommandLine.Model.CommandSpec spec; + + private ICLIService objManager; + private final Utils cliUtils; + + public Main() { + this.cliUtils = new Utils(); + } + + public Utils getCliUtils() { + return this.cliUtils; + } + + /** + * The main entry point for the CLI application. + * This method sets up picocli and includes robust, two-level error handling. + */ + public static void main(String[] args) { + try { + // Level 1: Handles errors during command execution (inside a command's call() method). + IExecutionExceptionHandler executionErrorHandler = (ex, commandLine, parseResult) -> { + commandLine.getErr().println("Error: " + ex.getMessage()); + // For debug purposes, uncomment the next line to see the full stack trace. + // ex.printStackTrace(commandLine.getErr()); + return ex instanceof SecurityException ? ERRCODES.AUTH_FAILED.ordinal() : ERRCODES.REMOTE_ERROR.ordinal(); + }; + + int exitCode = new CommandLine(new Main()) + .registerConverter(Scope.class, new Scope.Converter()) + .setExecutionExceptionHandler(executionErrorHandler) + .execute(args); + + System.exit(exitCode); + + } catch (Exception e) { + // Level 2: Catches errors during picocli initialization (e.g., parsing annotations). + // This is crucial for debugging the command structure itself. + System.err.println("A fatal initialization error occurred:"); + e.printStackTrace(System.err); + System.exit(ERRCODES.REMOTE_ERROR.ordinal()); + } + } + + @Override + public void run() { + // This is triggered if the user runs `lsposed-cli` with no subcommand. + throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand. Use '--help' to see available commands."); + } + + /** + * Gets or creates a connection to the LSPosed daemon service. + * This method caches the connection for the lifetime of the command. + */ + public final ICLIService getManager() { + if (objManager == null) { + try { + objManager = connectToService(); + if (objManager == null) { + throw new SecurityException("Authentication failed or daemon service not available."); + } + } catch (RemoteException | SecurityException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(ERRCODES.NO_DAEMON.ordinal()); + } + } + return objManager; + } + + /** + * Establishes a secure Binder connection to the LSPosed daemon. + * This method handles PIN retrieval, interactive prompts, and authentication. + */ + private ICLIService connectToService() throws RemoteException { + // Step 1: Determine the PIN provided by the user via argument or environment variable. + String initialPin = this.pin; + if (initialPin == null) { + initialPin = System.getenv("LSPOSED_CLI_PIN"); + } + this.pin = initialPin; // This instance variable will hold the PIN used for the actual attempt. + + // Step 2: Connect to the 'activity' service to request the LSPosed application service binder. + IBinder activityService = ServiceManager.getService("activity"); + if (activityService == null) throw new RemoteException("Could not get activity service."); + + Parcel data = Parcel.obtain(); + Parcel reply = Parcel.obtain(); + ILSPApplicationService service; + try { + data.writeInterfaceToken("LSPosed"); + data.writeInt(2); + data.writeString("lsp-cli:" + org.lsposed.lspd.util.SignInfo.CLI_UUID); + data.writeStrongBinder(new Binder()); + + if (!activityService.transact(1598837584, data, reply, 0)) { + throw new RemoteException("Transaction to activity service failed."); + } + reply.readException(); + IBinder serviceBinder = reply.readStrongBinder(); + if (serviceBinder == null) throw new RemoteException("Daemon did not return a service binder."); + service = ILSPApplicationService.Stub.asInterface(serviceBinder); + } finally { + data.recycle(); + reply.recycle(); + } + + // Step 3: First authentication attempt with the provided PIN (which could be null). + List lstBinder = new ArrayList<>(1); + service.requestCLIBinder(this.pin, lstBinder); + + // Step 4: If the first attempt failed and no PIN was provided in an interactive shell, + // prompt the user for the PIN as a final recovery step. + if (lstBinder.isEmpty() && this.pin == null && System.console() != null) { + System.err.println("Authentication required."); + char[] pinChars = System.console().readPassword("Enter CLI PIN: "); + if (pinChars != null) { + this.pin = new String(pinChars); + Arrays.fill(pinChars, ' '); // Clear the PIN from memory + service.requestCLIBinder(this.pin, lstBinder); // Second authentication attempt. + } + } + + // Step 5: Final validation and user-friendly error reporting. + if (lstBinder.isEmpty()) { + String errorMessage = (initialPin == null) + ? "Authentication required. Use the --pin option, set the LSPOSED_CLI_PIN environment variable, or use an interactive shell." + : "Authentication failed. The provided PIN is incorrect, has been revoked, or the CLI is disabled in the Manager app."; + throw new SecurityException(errorMessage); + } + + return ICLIService.Stub.asInterface(lstBinder.get(0)); + } +} diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java b/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java new file mode 100644 index 000000000..7761168a5 --- /dev/null +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java @@ -0,0 +1,69 @@ +package org.lsposed.lspd.cli; + +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.RemoteException; + +import org.lsposed.lspd.ICLIService; +import org.lsposed.lspd.models.Application; + +import java.util.HashMap; +import java.util.List; +import java.util.Objects; + +public class Utils { + + public static final String CMDNAME = "cli"; + + public enum ERRCODES { + NOERROR, + USAGE, + EMPTY_SCOPE, + ENABLE_DISABLE, + SET_SCOPE, + LS_SCOPE, + AUTH_FAILED, + NO_DAEMON, + REMOTE_ERROR + } + + private HashMap packagesMap; + + private void initPackagesMap(ICLIService managerService) throws RemoteException { + var packages = + managerService.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | PackageManager.MATCH_UNINSTALLED_PACKAGES, true).getList(); + packagesMap = new HashMap<>(); + for (var packageInfo: packages) { + int userid = packageInfo.applicationInfo.uid / 100000; + packagesMap.put(packageInfo.packageName + "|" + userid, packageInfo); + } + } + + public boolean validPackageNameAndUserId(ICLIService managerService, String packageName, int userId) throws RemoteException { + if (packagesMap == null) { + initPackagesMap(managerService); + } + + return packagesMap.containsKey(packageName + "|" + userId); + } + + public static boolean checkPackageInScope(String sPackageName, List lstScope) { + for (var app : lstScope) { + if (app.packageName.equals(sPackageName)) { + return true; + } + } + return false; + } + + public static boolean checkPackageModule(String moduleName, List lstScope) { + if (!checkPackageInScope(moduleName, lstScope)) { + Application app = new Application(); + app.packageName = moduleName; + app.userId = 0; + lstScope.add(app); + return true; + } + return false; + } +} diff --git a/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java b/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java new file mode 100644 index 000000000..87df256f6 --- /dev/null +++ b/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java @@ -0,0 +1,211 @@ +package org.lsposed.lspd.service; + +import static org.lsposed.lspd.service.ServiceManager.TAG; + +import android.app.INotificationManager; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Intent; +import android.content.pm.ParceledListSlice; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.ParcelFileDescriptor; +import android.os.RemoteException; +import android.util.Log; + +import org.lsposed.daemon.BuildConfig; +import org.lsposed.daemon.R; +import org.lsposed.lspd.ICLIService; +import org.lsposed.lspd.models.Application; +import org.lsposed.lspd.util.FakeContext; +import org.lsposed.lspd.util.SignInfo; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.time.LocalDateTime; + +import io.github.libxposed.service.IXposedService; +import rikka.parcelablelist.ParcelableListSlice; + +public class CLIService extends ICLIService.Stub { + + private static final HandlerThread worker = new HandlerThread("cli worker"); + private static final Handler workerHandler; + + private String sLastMsg; + + static { + worker.start(); + workerHandler = new Handler(worker.getLooper()); + } + + CLIService() { + } + + @Override + public void revokeCurrentPin() { + ConfigManager.getInstance().disableCli(); + } + + public static boolean basicCheck(int uid) { + return uid == 0; + } + + public static boolean applicationStageNameValid(int pid, String processName) { + var infoArr = processName.split(":"); + if (infoArr.length != 2 || !infoArr[0].equals("lsp-cli")) { + return false; + } + + if(infoArr[1].equals(SignInfo.CLI_UUID)) { + return true; + } + return false; + } + + private static boolean isValidXposedModule(String sPackageName) throws RemoteException { + var appInfo = PackageService.getApplicationInfo(sPackageName, PackageManager.GET_META_DATA | PackageService.MATCH_ALL_FLAGS, 0); + + return appInfo != null && appInfo.metaData != null && appInfo.metaData.containsKey("xposedmodule"); + } + + @Override + public IBinder asBinder() { + return this; + } + + @Override + public int getXposedApiVersion() { + return IXposedService.API; + } + + @Override + public int getXposedVersionCode() { + return BuildConfig.VERSION_CODE; + } + + @Override + public String getXposedVersionName() { + return BuildConfig.VERSION_NAME; + } + + @Override + public String getApi() { + return ConfigManager.getInstance().getApi(); + } + + @Override + public ParcelableListSlice getInstalledPackagesFromAllUsers(int flags, boolean filterNoProcess) throws RemoteException { + return PackageService.getInstalledPackagesFromAllUsers(flags, filterNoProcess); + } + + @Override + public String[] enabledModules() { + return ConfigManager.getInstance().enabledModules(); + } + + @Override + public boolean enableModule(String packageName) throws RemoteException { + if (!isValidXposedModule(packageName)) { + sLastMsg = "Module " + packageName + " is not a valid xposed module"; + return false; + } + return ConfigManager.getInstance().enableModule(packageName); + } + + @Override + public boolean setModuleScope(String packageName, List scope) throws RemoteException { + if (!isValidXposedModule(packageName)) { + sLastMsg = "Module " + packageName + " is not a valid xposed module"; + return false; + } + return ConfigManager.getInstance().setModuleScope(packageName, scope); + } + + @Override + public List getModuleScope(String packageName) throws RemoteException { + if (!isValidXposedModule(packageName)) { + sLastMsg = "Module " + packageName + " is not a valid xposed module"; + return null; + } + List list = ConfigManager.getInstance().getModuleScope(packageName); + if (list == null) return null; + else return list; + } + + @Override + public boolean disableModule(String packageName) throws RemoteException { + if (!isValidXposedModule(packageName)) { + sLastMsg = "Module " + packageName + " is not a valid xposed module"; + return false; + } + return ConfigManager.getInstance().disableModule(packageName); + } + + @Override + public boolean isVerboseLog() { + return ConfigManager.getInstance().verboseLog(); + } + + @Override + public void setVerboseLog(boolean enabled) { + ConfigManager.getInstance().setVerboseLog(enabled); + } + + @Override + public ParcelFileDescriptor getVerboseLog() { + return ConfigManager.getInstance().getVerboseLog(); + } + + @Override + public ParcelFileDescriptor getModulesLog() { + workerHandler.post(() -> ServiceManager.getLogcatService().checkLogFile()); + return ConfigManager.getInstance().getModulesLog(); + } + + @Override + public boolean clearLogs(boolean verbose) { + return ConfigManager.getInstance().clearLogs(verbose); + } + + @Override + public void getLogs(ParcelFileDescriptor zipFd) throws RemoteException { + ConfigFileManager.getLogs(zipFd); + } + + @Override + public String getLastErrorMsg() { + return sLastMsg; + } + + @Override + public boolean getAutoInclude(String packageName) throws RemoteException { + if (!isValidXposedModule(packageName)) { + sLastMsg = "Module " + packageName + " is not a valid xposed module"; + return false; + } + return ConfigManager.getInstance().getAutoInclude(packageName); + } + + @Override + public void setAutoInclude(String packageName, boolean add) throws RemoteException { + if (!isValidXposedModule(packageName)) { + sLastMsg = "Module " + packageName + " is not a valid xposed module"; + return; + } + ConfigManager.getInstance().setAutoInclude(packageName, add); + } +} diff --git a/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java b/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java index e058e68a3..904db106d 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java @@ -113,6 +113,10 @@ public class ConfigManager { private String api = "(???)"; + private String volatileCliPin = null; + private int failedCliAttempts = 0; + private static final int MAX_CLI_ATTEMPTS = 5; + static class ProcessScope { final String processName; final int uid; @@ -1073,6 +1077,39 @@ public void setEnableStatusNotification(boolean enable) { enableStatusNotification = enable; } + public void recordFailedCliAttempt() { + failedCliAttempts++; + if (failedCliAttempts >= MAX_CLI_ATTEMPTS) { + disableCli(); + failedCliAttempts = 0; + } + } + + public void resetCliFailedAttempts() { + failedCliAttempts = 0; + } + + public String getCurrentCliPin() { + return volatileCliPin; + } + + public String resetCliPin() { + // Generate a new, secure random PIN + this.volatileCliPin = java.util.UUID.randomUUID().toString().substring(0, 8); + return this.volatileCliPin; + } + + public void disableCli() { + this.volatileCliPin = null; + } + + public boolean isCliPinValid(String providedPin) { + if (volatileCliPin == null || providedPin == null) { + return false; // CLI is disabled or no PIN was provided + } + return volatileCliPin.equals(providedPin); + } + public ParcelFileDescriptor getManagerApk() { try { return ConfigFileManager.getManagerApk(); diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java index 259980267..febd6af8e 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java @@ -31,6 +31,7 @@ import androidx.annotation.NonNull; +import org.lsposed.daemon.BuildConfig; import org.lsposed.lspd.models.Module; import java.util.Collections; @@ -157,6 +158,35 @@ public ParcelFileDescriptor requestInjectedManagerBinder(List binder) t return ConfigManager.getInstance().getManagerApk(); } + @Override + public void requestCLIBinder(String pin, List binder) throws RemoteException { + ensureRegistered(); // Ensures caller is a valid process + ConfigManager config = ConfigManager.getInstance(); + + boolean allowAccess = false; + // Rule 1: Special case for DEBUG builds. + if (BuildConfig.DEBUG) { + // If the daemon is a debug build AND no PIN has been set in memory yet, + // we allow access by default without a PIN. + if (config.getCurrentCliPin() == null && pin == null) { + allowAccess = true; + } + } + + // Rule 2: Standard PIN validation for ALL builds. + // If access wasn't already granted by the debug rule, we perform the normal check. + if (!allowAccess && config.isCliPinValid(pin)) { + allowAccess = true; + } + + if (allowAccess) { + binder.add(ServiceManager.getCLIService()); + config.resetCliFailedAttempts(); + } else { + config.recordFailedCliAttempt(); + } + } + public boolean hasRegister(int uid, int pid) { return processes.containsKey(new Pair<>(uid, pid)); } diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java index 46a3cd542..d69796282 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java @@ -566,4 +566,19 @@ public boolean setAutoInclude(String packageName, boolean enabled) { public boolean getAutoInclude(String packageName) { return ConfigManager.getInstance().getAutoInclude(packageName); } + + @Override + public String getCurrentCliPin() { + return ConfigManager.getInstance().getCurrentCliPin(); + } + + @Override + public String resetCliPin() { + return ConfigManager.getInstance().resetCliPin(); + } + + @Override + public void disableCli() { + ConfigManager.getInstance().disableCli(); + } } diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPosedService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPosedService.java index b2e0e81a2..3ee62b436 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPosedService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPosedService.java @@ -94,6 +94,12 @@ public ILSPApplicationService requestApplicationService(int uid, int pid, String Log.d(TAG, "Skipped duplicated request for uid " + uid + " pid " + pid); return null; } + + if (CLIService.basicCheck(uid) && CLIService.applicationStageNameValid(pid, processName)) { + Log.d(TAG, "CLI start, pid: " + pid); + return ServiceManager.requestApplicationService(uid, pid, processName, heartBeat); + } + if (!ServiceManager.getManagerService().shouldStartManager(pid, uid, processName) && ConfigManager.getInstance().shouldSkipProcess(new ConfigManager.ProcessScope(processName, uid))) { Log.d(TAG, "Skipped " + processName + "/" + uid); return null; diff --git a/daemon/src/main/java/org/lsposed/lspd/service/ServiceManager.java b/daemon/src/main/java/org/lsposed/lspd/service/ServiceManager.java index 1e890081a..26a22129b 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/ServiceManager.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/ServiceManager.java @@ -65,6 +65,7 @@ public class ServiceManager { private static LSPSystemServerService systemServerService = null; private static LogcatService logcatService = null; private static Dex2OatService dex2OatService = null; + private static CLIService cliService = null; private static final ExecutorService executorService = Executors.newSingleThreadExecutor(); @@ -137,6 +138,8 @@ public static void start(String[] args) { applicationService = new LSPApplicationService(); managerService = new LSPManagerService(); systemServerService = new LSPSystemServerService(systemServerMaxRetry); + cliService = new CLIService(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { dex2OatService = new Dex2OatService(); dex2OatService.start(); @@ -208,6 +211,10 @@ public static LogcatService getLogcatService() { return logcatService; } + public static CLIService getCLIService() { + return cliService; + } + public static boolean systemServerRequested() { return systemServerService.systemServerRequested(); } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index efef50b3e..bbfa1615b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ appiconloader = { module = "me.zhanghai.android.appiconloader:appiconloader", ve material = { module = "com.google.android.material:material", version = "1.12.0" } gson = { module = "com.google.code.gson:gson", version = "2.13.1" } hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version = "6.1" } +picocli = { module = "info.picocli:picocli", version = "4.7.7" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.10.2" } diff --git a/magisk-loader/magisk_module/cli b/magisk-loader/magisk_module/cli new file mode 100644 index 000000000..a69193ed2 --- /dev/null +++ b/magisk-loader/magisk_module/cli @@ -0,0 +1,22 @@ +#!/system/bin/sh + +dex_path="" +for DEXDIR in /data/adb/modules $(magisk --path 2>/dev/null)/.magisk/modules +do + if [ -d "$DEXDIR/zygisk_lsposed" ]; then + dex_path="$DEXDIR/zygisk_lsposed" + break + fi +done + +if [ -z "$dex_path" ] +then + echo "No lsposed module path found" + exit 1 +fi + +dex_path="$dex_path/daemon.apk" + +java_potions="-Djava.class.path=$dex_path" + +exec app_process $java_potions /system/bin org.lsposed.lspd.cli.Main "$@" diff --git a/magisk-loader/magisk_module/customize.sh b/magisk-loader/magisk_module/customize.sh index 010a94324..050e60b13 100644 --- a/magisk-loader/magisk_module/customize.sh +++ b/magisk-loader/magisk_module/customize.sh @@ -82,6 +82,8 @@ extract "$ZIPFILE" 'daemon.apk' "$MODPATH" extract "$ZIPFILE" 'daemon' "$MODPATH" rm -f /data/adb/lspd/manager.apk extract "$ZIPFILE" 'manager.apk' "$MODPATH" +mkdir '/data/adb/lspd' +extract "$ZIPFILE" 'cli' '/data/adb/lspd/bin' if [ "$FLAVOR" == "zygisk" ]; then mkdir -p "$MODPATH/zygisk" @@ -148,7 +150,10 @@ fi set_perm_recursive "$MODPATH" 0 0 0755 0644 set_perm_recursive "$MODPATH/bin" 0 2000 0755 0755 u:object_r:xposed_file:s0 +set_perm_recursive "/data/adb/lspd/" 0 0 0755 0644 +set_perm_recursive "/data/adb/lspd/bin" 0 0 0755 0755 u:object_r:xposed_file:s0 chmod 0744 "$MODPATH/daemon" +chmod 0700 "/data/adb/lspd/bin/cli" if [ "$(grep_prop ro.maple.enable)" == "1" ] && [ "$FLAVOR" == "zygisk" ]; then ui_print "- Add ro.maple.enable=0" diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl index b85b6ed21..34e78f2f8 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl @@ -12,4 +12,6 @@ interface ILSPApplicationService { String getPrefsPath(String packageName); ParcelFileDescriptor requestInjectedManagerBinder(out List binder); + + void requestCLIBinder(String pin, out List binder); } diff --git a/services/manager-service/src/main/aidl/org/lsposed/lspd/ICLIService.aidl b/services/manager-service/src/main/aidl/org/lsposed/lspd/ICLIService.aidl new file mode 100644 index 000000000..a55d63dea --- /dev/null +++ b/services/manager-service/src/main/aidl/org/lsposed/lspd/ICLIService.aidl @@ -0,0 +1,46 @@ +package org.lsposed.lspd; + +import rikka.parcelablelist.ParcelableListSlice; +import org.lsposed.lspd.models.Application; + +interface ICLIService { + String getApi() = 1; + + ParcelableListSlice getInstalledPackagesFromAllUsers(int flags, boolean filterNoProcess) = 2; + + String[] enabledModules() = 3; + + boolean enableModule(String packageName) = 4; + + boolean disableModule(String packageName) = 5; + + boolean setModuleScope(String packageName, in List scope) = 6; + + List getModuleScope(String packageName) = 7; + + boolean isVerboseLog() = 8; + + void setVerboseLog(boolean enabled) = 9; + + ParcelFileDescriptor getVerboseLog() = 10; + + ParcelFileDescriptor getModulesLog() = 11; + + int getXposedVersionCode() = 12; + + String getXposedVersionName() = 13; + + int getXposedApiVersion() = 14; + + boolean clearLogs(boolean verbose) = 15; + + void getLogs(in ParcelFileDescriptor zipFd) = 16; + + String getLastErrorMsg() = 17; + + boolean getAutoInclude(String packageName) = 18; + + void setAutoInclude(String packageName, boolean add) = 19; + + void revokeCurrentPin() = 20; +} diff --git a/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl b/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl index aeded3c62..dca4480e1 100644 --- a/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl +++ b/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl @@ -95,4 +95,10 @@ interface ILSPManagerService { boolean getAutoInclude(String packageName) = 51; boolean setAutoInclude(String packageName, boolean enable) = 52; + + String getCurrentCliPin() = 53; + + String resetCliPin() = 54; + + void disableCli() = 55; }