在 Android 手机上运行自定义脚本,很多人第一反应是 root。其实很多工具并不需要 root 权限,DSHA 这类免 root 插件化工具就是典型方向:它把运行环境收拢在普通应用沙箱内,通过统一的插件协议加载用户自定义模块。这里以 DSHA 为代号,拆解一套可以在手机上运行的插件系统,从概念、环境、代码、安装验证到排错,覆盖一条完整可复现的技术链路。这套做法可以直接用到你的本地工具里,也可以作为个人脚本管理方案长期维护。
这类工具对普通用户的吸引力在于三点:不需要 root,不破坏系统安全边界;插件可以独立开发、独立升级,宿主程序保持稳定;所有功能都控制在应用私有目录内,清理或卸载时不会留下系统级残留。下文不会依赖任何特定产品信息,只从工程实现角度说明“免 root 插件系统”应该怎么搭。
1. 先建立概念:免 root 工具和插件化到底解决什么问题
1.1 root 权限为什么不是必需品
root 是 Linux 系统里的超级用户身份。Android 基于 Linux,获取 root 后可以修改系统分区、读写其他应用的数据、加载内核模块,但代价是安全边界被打破。系统不再认为每个应用都是互相隔离的独立沙箱,一个应用出错可能影响整个设备。
DSHA 这类免 root 工具默认不获取 root,也不尝试绕过沙箱。它运行在 Android 为普通应用划定的私有目录中,能读写的对象主要是自身目录、用户明确授权的存储区域,以及系统允许普通应用访问的资源。普通应用本身就可以执行代码、写入文件、创建子进程、访问网络,这些能力已经足够支撑一个脚本插件系统。
注意,这里说的免 root 是指不申请也不使用超级用户权限,不是“隐藏 root”或“绕过检测”。整个设计前提是:所有插件都运行在应用自己的沙箱里,宿主只做加载和管理,不做任何系统级修改。
1.2 插件系统包含三个核心角色
一个完整的插件系统至少有三个角色:宿主程序、插件协议、插件实现。
- 宿主程序:负责扫描插件目录、读取插件清单、执行插件入口、收集输出和日志。DSHA 里的
dsha.py就是这个角色。 - 插件协议:定义“一个合法插件长什么样”。比如强制要求每个插件目录下必须有
plugin.json,入口文件必须叫main.py。 - 插件实现:用户编写的具体功能代码。一个插件可以只做一件事,比如输出设备信息、批量重命名文件、生成 Markdown 报告。
这个分层带来的直接好处是宿主和插件解耦。新增功能不需要修改宿主代码,只需要新增一个插件目录;升级插件也不需要重装宿主程序。插件写坏了,宿主还可以继续管理其他插件。
1.3 这种模式适合哪些场景
免 root 插件系统适合不需要系统权限的自动化场景。常见的有:
- 设备信息采集:输出型号、架构、Python 版本、运行时长。
- 本地文件处理:批量重命名、按日期整理、格式转换。
- 文本与报告生成:读取 JSON,生成 Markdown 或 CSV。
- 定时提醒:配合终端环境的定时任务,在系统允许范围内执行。
对比 root 方案和免 root 方案的差异可以这样看:
| 对比项 | root 方案 | DSHA 免 root 方案 |
|---|---|---|
| 权限 | 需要获取超级用户权限 | 应用沙箱 + 用户授权存储 |
| 系统风险 | 一个脚本可能影响整个系统 | 只影响应用私有目录 |
| 安装难度 | 需要刷机或安装授权程序 | 安装终端环境后放脚本即可 |
| 插件存放位置 | 可以写入系统目录 | 限制在应用私有目录 |
| 适合用户 | 喜欢折腾系统的玩家 | 普通用户也可以接受 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:手机端最小运行条件与项目目录
2.1 设备与软件要求
在正式写插件之前,先确认运行环境。DSHA 类工具在手机上常见的载体是 Termux 这类终端模拟器。它不需要 root,安装后可以直接执行 Python、Shell 和 Git 命令。
| 组成 | 要求 | 说明 |
|---|---|---|
| 手机系统 | Android 7.0 及以上 | 较低版本也能跑,但新版本兼容性更好 |
| 终端环境 | Termux 或等效终端 | 不需要 root,安装 Python 即可 |
| 存储空间 | 建议预留 200MB 以上 | Python 和依赖会占用一定空间 |
| 开发机 | 可选 | 可以直接在手机上编写脚本 |
| 网络 | 安装依赖时需要 | 插件运行阶段可以完全离线 |
Termux 安装完成后,先更新包管理器并安装 Python 和 Git:
bash复制pkg update
pkg install python git -y
python --version
这里 pkg 是 Termux 对包管理器的封装命令,不需要 root 权限。如果第一条命令执行较慢或者提示源连接失败,通常是因为默认软件源不稳定,可以先检查网络,再尝试切换 Termux 维护的备用软件源。
2.2 目录结构先设计好,避免后面越写越乱
DSHA 的所有数据都建议放在用户主目录下的私有文件夹里。下面是一个最小布局:
text复制~/.dsha/
├── plugins/
│ └── hello_dsha/
│ ├── plugin.json
│ └── main.py
├── logs/
│ └── dsha.log
└── dsha.py
为什么要放在 ~/.dsha 而不是 /sdcard?因为 Android 10 及以上版本对公共存储目录的访问限制越来越严格。脚本放在 /sdcard/Download 下运行时,经常遇到权限被拒的问题;放在用户主目录下的私有目录里,则几乎不会受到这类限制。
2.3 目录职责说明
| 路径 | 作用 | 说明 |
|---|---|---|
~/.dsha |
DSHA 主目录 | 存放宿主、插件、日志 |
~/.dsha/plugins |
插件根目录 | 每个子目录对应一个插件 |
~/.dsha/logs |
日志目录 | 记录插件运行情况和异常 |
~/.dsha/dsha.py |
宿主入口 | 提供 list、run、add 等命令 |
目录设计完成后,插件协议也就有了物理载体:扫描 plugins 目录下的每个子目录,读取其中的 plugin.json,就能识别出全部插件。
3. 从零实现一个最小插件系统
3.1 用 plugin.json 描述插件元信息
每个插件目录下必须有一个 plugin.json,它相当于插件的身份证。宿主程序通过这个文件判断插件名称、版本和入口脚本位置。
json复制{
"name": "hello_dsha",
"version": "1.0.0",
"entry": "main.py",
"description": "输出设备基本信息",
"author": "your_name",
"api_version": 1
}
字段含义如下:
| 字段 | 含义 | 是否必填 | 说明 |
|---|---|---|---|
name |
插件名称 | 是 | 用于 run <name> 定位插件 |
version |
插件版本 | 是 | 升级插件时可以比较版本 |
entry |
入口脚本路径 | 是 | 相对插件目录的路径 |
description |
插件描述 | 否 | 在 list 命令中展示 |
author |
作者 | 否 | 信息展示,不影响运行 |
api_version |
插件协议版本 | 建议填写 | 宿主未来升级协议时可以兼容旧插件 |
这里最关键的是 name 和 entry。name 决定用户运行哪个插件,entry 决定执行哪个文件。如果 entry 写成绝对路径,或者包含 ..,都会给后面带来安全和兼容问题。
3.2 编写插件入口 main.py
插件入口也是一个普通 Python 文件。它不需要继承任何类,也不需要依赖特定框架。DSHA 约定入口可以接收参数并通过标准输出返回结果。
python复制import platform
import json
import sys
def run(context, params):
info = {
"plugin": context.get("plugin_name"),
"python_version": sys.version.split()[0],
"machine": platform.machine(),
"system": platform.system(),
"params": params,
"message": "hello from dsha"
}
print(json.dumps(info, ensure_ascii=False, indent=2))
if __name__ == "__main__":
run({"plugin_name": "hello_dsha"}, {})
这里保留 context 和 params 两个参数,是为了让后续宿主可以传入插件名、配置参数或用户输入。ensure_ascii=False 用来避免中文输出被转义成一串 \u 字符,这在 Termux 终端里会出现可读性很差的情况。
3.3 宿主加载器 dsha.py
宿主程序负责三件事:列出插件、运行插件、添加插件。下面是一个最小实现。
python复制import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
DSHA_ROOT = Path.home() / ".dsha"
PLUGINS_DIR = DSHA_ROOT / "plugins"
def read_manifest(plugin_dir: Path) -> dict:
mf = plugin_dir / "plugin.json"
if not mf.exists():
raise FileNotFoundError(f"missing plugin.json: {mf}")
manifest = json.loads(mf.read_text(encoding="utf-8"))
if "name" not in manifest or "entry" not in manifest:
raise ValueError(f"invalid manifest: {mf}")
return manifest
def list_plugins():
result = []
if not PLUGINS_DIR.exists():
return result
for child in sorted(PLUGINS_DIR.iterdir()):
if not child.is_dir():
continue
try:
manifest = read_manifest(child)
manifest["_dir"] = str(child)
result.append(manifest)
except Exception as exc:
result.append({"name": child.name, "error": str(exc)})
return result
def run_plugin(plugin_name: str, params: dict):
plugin_dir = PLUGINS_DIR / plugin_name
manifest = read_manifest(plugin_dir)
entry = plugin_dir / manifest["entry"]
if not entry.exists():
raise FileNotFoundError(f"missing entry: {entry}")
env = os.environ.copy()
env["DSHA_PLUGIN_CONTEXT"] = json.dumps({"plugin_name": plugin_name})
proc = subprocess.run(
[sys.executable, str(entry)],
cwd=str(plugin_dir),
env=env,
capture_output=True,
text=True,
timeout=30,
)
print(proc.stdout)
if proc.returncode != 0:
print(proc.stderr, file=sys.stderr)
sys.exit(proc.returncode)
def cmd_add(source: Path):
if not source.exists():
raise FileNotFoundError(source)
target = PLUGINS_DIR / source.name
if target.exists():
shutil.rmtree(target)
shutil.copytree(source, target)
print("plugin added:", source.name)
def main():
args = sys.argv[1:]
if not args:
print("usage: dsha.py [list|add|run] ...")
return
if args[0] == "list":
for p in list_plugins():
if "error" in p:
print(p["name"], "error:", p["error"])
else:
print(p["name"], p["version"], p.get("description", ""))
elif args[0] == "run":
if len(args) < 2:
print("usage: dsha.py run <plugin_name>")
sys.exit(1)
run_plugin(args[1], {})
elif args[0] == "add":
cmd_add(Path(args[1]))
else:
print("unknown command:", args[0])
if __name__ == "__main__":
main()
这段代码故意保持简单,方便读者理解宿主和插件的关系。每个插件都在独立子进程中运行,这就是下一节要解释的取舍。
3.4 为什么用子进程而不是 import 导入
还有另一种常见的插件加载方式是使用 Python 的 importlib 动态导入插件模块。两种方案各有优缺点,DSHA 默认使用子进程方式。
| 方案 | 优点 | 风险 |
|---|---|---|
importlib 动态导入 |
启动快,共享内存 | 插件异常可能污染宿主进程;模块名冲突会互相覆盖 |
subprocess 子进程 |
隔离彻底,插件崩溃不影响宿主 | 启动略慢,参数需要序列化传递 |
插件通常来自用户自己或其他开发者,默认应该被视为不可信代码。子进程方案可以先隔离错误,再通过标准输出、标准错误和返回码把结果传回宿主。即使某个插件启动后立刻退出,宿主也还能继续管理其他插件。
4. 一键安装与运行验证
4.1 把安装过程收敛成一条命令
为了让手机端安装更省事,可以把宿主代码和示例插件写进一个安装脚本。用户只需要执行这个脚本,DSHA 的目录结构和基础插件就全部就位。
bash复制#!/usr/bin/env bash
set -euo pipefail
BASE="$HOME/.dsha"
mkdir -p "$BASE/plugins" "$BASE/logs"
cat > "$BASE/dsha.py" <<'PY'
# 这里粘贴上一节的宿主代码
PY
mkdir -p "$BASE/plugins/hello_dsha"
cat > "$BASE/plugins/hello_dsha/plugin.json" <<'JSON'
{
"name": "hello_dsha",
"version": "1.0.0",
"entry": "main.py",
"description": "输出设备基本信息",
"author": "your_name",
"api_version": 1
}
JSON
cat > "$BASE/plugins/hello_dsha/main.py" <<'PY'
import platform
import json
import sys
def run(context, params):
info = {
"plugin": context.get("plugin_name"),
"python_version": sys.version.split()[0],
"machine": platform.machine(),
"system": platform.system(),
"params": params,
"message": "hello from dsha"
}
print(json.dumps(info, ensure_ascii=False, indent=2))
if __name__ == "__main__":
run({"plugin_name": "hello_dsha"}, {})
PY
chmod +x "$BASE/dsha.py"
echo "DSHA installed to $BASE"
把脚本上传到自己的发布地址后,可以生成一条安装命令:
bash复制curl -fsSL https://example.invalid/dsha/install.sh | bash
提醒:生产环境中不要直接执行未经审计的
curl | bash安装命令。自己维护小工具时可以使用,但面向团队分发时,应该先下载脚本检查内容,再手动执行。示例 URL 只是占位,实际使用时换成自己的可信发布地址。
4.2 执行 list 和 run 验证插件链路
安装完成后,先检查宿主能否识别示例插件:
bash复制python ~/.dsha/dsha.py list
预期输出类似:
text复制hello_dsha 1.0.0 输出设备基本信息
接着运行插件:
bash复制python ~/.dsha/dsha.py run hello_dsha
预期输出是一个 JSON:
json复制{
"plugin": "hello_dsha",
"python_version": "3.11.8",
"machine": "aarch64",
"system": "Linux",
"params": {},
"message": "hello from dsha"
}
这里能看到 aarch64 说明设备是 64 位 ARM 架构,system 是 Linux。不要被“手机”的固有印象限制,Android 系统底层就是 Linux,普通应用沙箱内运行 Python 脚本是合法且常见的使用方式。
4.3 验证清单
| 检查项 | 执行方式 | 预期结果 |
|---|---|---|
| Python 可用 | python --version |
输出 Python 版本号 |
| 插件目录存在 | ls ~/.dsha/plugins |
看到 hello_dsha |
| 宿主能识别插件 | python ~/.dsha/dsha.py list |
显示插件名称、版本和描述 |
| 插件能正常运行 | python ~/.dsha/dsha.py run hello_dsha |
输出 JSON 信息 |
| 异常能被发现 | 修改 plugin.json 中 entry 为一个不存在文件 |
宿主报 FileNotFoundError |
5. 关键参数与配置说明
5.1 plugin.json 字段的选填策略
在实际项目中,插件清单字段会越加越多。建议保持“必填字段最少,可选字段按需扩展”的策略。最少只需要 name 和 entry,其他字段都属于辅助信息。协议版本 api_version 建议从一开始就保留,否则未来协议变化时,旧插件很难自动识别。
如果插件需要接收用户参数,可以在 plugin.json 中增加一个 params 数组:
json复制{
"name": "rename_files",
"version": "1.0.0",
"entry": "main.py",
"description": "批量重命名文件",
"author": "your_name",
"api_version": 1,
"params": [
{
"name": "prefix",
"type": "string",
"description": "新文件名前缀"
}
]
}
宿主读取到 params 后,可以在运行时把用户输入传给插件。这个字段虽然简单,但已经足够承担插件命令行的参数声明。
5.2 运行策略参数的影响
宿主程序里对插件运行有四个关键控制点:timeout、cwd、text 和 env。它们直接影响插件能否稳定运行。
| 参数 | 默认值 | 作用 | 错误配置的表现 |
|---|---|---|---|
timeout |
30 秒 | 防止插件死循环 | 设太小会误杀正常插件;设太大无法发现死循环 |
cwd |
插件目录 | 让脚本内部的相对路径生效 | 配置成别处会导致找不到文件 |
text |
True |
以文本模式读取输出 | 关闭后二进制取模会出现解码错误 |
env |
继承宿主环境 | 传递上下文变量 | 不传环境变量时插件拿不到外部参数 |
每次运行插件时,至少要把返回码、标准输出和标准错误记录到日志文件。不要只在终端打印出来,因为手机上的终端可能被清理,日志被丢后排查问题没有任何线索。
5.3 学习环境和生产环境的差异
同一个插件系统,在个人验证和生产分发时要求完全不同。
| 维度 | 学习环境 | 生产环境 |
|---|---|---|
| 安装方式 | 手动复制 | 签名安装包或可信源 |
| 日志 | 屏幕输出 | 落盘、大小轮转 |
| 权限 | 最小权限 | 进一步限制网络和能力 |
| 回滚方案 | 删除目录 | 版本目录 + 软链接切换 |
| 监控 | 无 | 心跳、失败告警 |
| 更新 | 覆盖文件 | 校验签名后升级 |
个人学习时,目录结构可以随意调整。但一旦分发给其他人,就必须考虑目标设备上 Python 版本是否一致、脚本是否有依赖、异常日志能否回传等问题。
6. 常见问题与排查链路
6.1 问题现象速查表
| 问题现象 | 可能原因 | 处理方向 |
|---|---|---|
| 插件没有出现在 list | 目录放错,或 plugin.json 格式错误 |
检查插件路径和 JSON 语法 |
运行时提示 FileNotFoundError |
entry 路径不对 |
查看 manifest 中 entry 字段 |
| 中文输出乱码 | 脚本未指定 UTF-8 编码 | 打印和读取都显式使用 encoding="utf-8" |
| 读取共享文件权限被拒 | 把文件放在未授权的公共目录 | 移到 ~/.dsha 私有目录 |
| 插件运行超时 | 插件内部死循环或等待输入 | 增加 timeout,并检查脚本逻辑 |
| 命令找不到 | PATH 环境变量被覆盖 | 使用 python 的完整路径执行宿主 |
6.2 从现象倒推检查顺序
排错时不要直接改代码。按下面的顺序定位,能省掉大部分无效尝试:
- 确认输入正确:插件名有没有打错。
- 检查路径:
ls -l ~/.dsha/plugins/hello_dsha。 - 校验
plugin.json:运行python -m json.tool看 JSON 是否合法。 - 手动执行插件入口:直接运行
python main.py,看能否复现。 - 观察宿主抛出的异常信息。
- 检查文件编码和换行符是否正常。
常用的排查命令:
bash复制find ~/.dsha -maxdepth 3 -type f
python -m json.tool ~/.dsha/plugins/hello_dsha/plugin.json
cd ~/.dsha/plugins/hello_dsha && python main.py
6.3 三个特别容易踩的坑
坑 1:插件目录名和 manifest 中的 name 不一致。
现象是 list 命令显示正常,但 run <插件名> 时报路径不存在。原因是宿主多数情况下直接用插件名拼目录,如果目录名是 hello,名字却叫 hello_dsha,就会找不到。解决方法是保持两者完全一致,或者在 run 时先扫描 manifest 拿到真实名称,再决定运行哪个目录。
坑 2:在 Android 10 以上的设备操作共享目录。
现象是脚本运行到一半报 PermissionError。原因是系统对公共存储访问做了严格限制,不是 root 就能简单越过,而且 DSHA 根本不获取 root。解决方法是在项目一开始就把数据路径设计在 ~/.dsha/data 内,所有插件都从私有目录读取和写入文件。
坑 3:宿主捕获了 stdout 却没展示 stderr。
现象是插件报错后,终端仍然像是“成功”了。原因是 subprocess.run 的 capture_output=True 会把 stdout 和 stderr 都捕获进内存,但宿主只打印了 stdout。解决方法是判断 returncode 是否为 0,不为 0 时必须把 stderr 打印出来并写入日志。
7. 生产化建议与最佳实践
7.1 插件不可信,默认要设置安全边界
DSHA 这类工具本质上是一个可执行用户任意脚本的平台。插件一旦被加载,就拥有当前宿主进程的用户权限。因此宿主程序必须把插件当作不可信代码处理,至少做到以下几点:
- 校验
entry必须是相对路径,不能包含..,不能是绝对路径。 - 限制插件只能在自身目录内捆绑资源文件。
- 对插件包做哈希或签名校验,防止传输过程被篡改。
- 不申请 root 权限,不主动请求敏感权限。
- 默认禁止插件访问网络,除非插件声明确实需要。
路径校验代码可以这样写:
python复制def safe_entry(plugin_dir: Path, entry: str) -> Path:
raw = Path(entry)
if raw.is_absolute():
raise ValueError("entry must be relative")
resolved = (plugin_dir / raw).resolve()
root = plugin_dir.resolve()
if not resolved.is_relative_to(root):
raise ValueError("entry escapes plugin dir")
return resolved
注意:插件系统最危险的地方不是插件写错代码,而是宿主无条件信任插件。任何从目录名、文件名、入口路径拼接出来的路径,都要先校验再使用。
7.2 日志要落盘,不能只靠终端输出
手机上终端活动容易被清理,一旦发生问题,历史输出就找不回来。建议宿主每次运行插件时都追加一条日志:
bash复制mkdir -p ~/.dsha/logs
echo "$(date +%F_%T) run hello_dsha success" >> ~/.dsha/logs/dsha.log
如果日志量变大,可以使用 Python 的 RotatingFileHandler 做大小轮转,或者用系统的 logrotate 配置。日志内容至少包含:运行时间、插件名、返回码、异常堆栈。
7.3 发布前检查清单
| 类别 | 检查项 |
|---|---|
| 功能 | list 和 run 命令在目标设备可用 |
| 兼容 | 插件入口与目标 Python 版本兼容 |
| 安全 | manifest 路径校验生效 |
| 权限 | 插件不申请 root,不请求多余权限 |
| 日志 | 宿主能记录运行异常 |
| 回滚 | 插件版本目录可以切换 |
| 资源 | 设置了超时和输出大小限制 |
| 分发 | 安装脚本经过审计,发布地址可信 |
这个清单可以作为插件正式发布前的最终检查步骤,也可以用于自建工具仓库的自动化校验脚本。
8. 扩展方向
8.1 增加本地管理面板
命令行足够个人使用,但如果你想分发给不太熟悉命令行的用户,可以增加一个本地管理页面。用 Python 的 HTTP 服务监听 127.0.0.1 回环地址,只允许本机访问,不开放局域网端口。页面提供上传插件压缩包、查看插件列表、查看日志三个功能,就能覆盖大部分使用需求。
8.2 与系统定时任务结合
Termux 环境支持定时执行脚本。可以把 dsha.py run <plugin_name> 放进定时任务,实现每天定时生成报告或清理临时文件。生产环境需要额外注意电池优化白名单,否则任务可能被系统挂起。
8.3 设计离线插件包和插件源
分发插件时,不一定要把源码直接复制到每台手机。可以把插件目录打成 zip,压缩包内附带 plugin.json 和签名文件,宿主加载时先解压、校验、再执行。这样既方便分发,也方便版本管理。插件源可以是一个自建静态文件服务,手机端通过宿主命令同步插件列表和版本。
8.4 给新手的练习建议
如果你第一次接触插件系统,建议按下面的顺序练习:
- 先写一个不访问网络、只打印文本的插件,跑通
list和run。 - 增加参数传递,让插件接受用户输入并影响输出。
- 增加日志落盘和异常捕获。
- 增加路径校验和超时控制。
- 最后再做签名校验和插件分发。
对新手最有价值的练习,不是一开始就设计复杂的插件市场,而是把一个本地小工具完整跑通:从安装、加载、执行到日志回看。只有这条主链路稳定了,后续的扩展才有可靠基础。
