导出与插件
导出与插件
这一页讲的是 JSXHook 里最容易被低估的一条链路:把项目从工作区脚本,变成可交付的独立模块或 APK。
很多人第一次看到“导出”两个字,会以为只是把项目目录压个包。
但 JSXHook 实际做的事情要多得多:
- 复制项目到临时目录
- 解析
init.js - 提取 scope / launcher / buildConfig / 远程更新配置
- 重写模板工程或基础 APK
- 加密
.js脚本 - 自动扫描并打包插件 APK
- 写入图标
- 改 packageName / version / 文案 / xposed scope
- 最后签名输出
所以这页最适合解决下面这类问题:
- 为什么本地项目能跑,导出后行为不一样
- 为什么某个插件在本地
plugins.load()能用,导出后没带进去 scope = ["all"]为什么导出后scope.list不是字面上的all- 为什么导出 APK 时还能选默认签名或自定义 keystore
- 为什么导出物里看不到明文
.js
目录
- 先记住这 12 条
- 关键源码
- 导出链到底分成哪两条
- 项目模板导出:StandaloneModuleProjectExporter
- APK 导出:StandaloneModuleApkExporter
- scope 与 launcher 会怎样影响导出
- 脚本为什么会被加密
- 插件是怎么被扫描并打包进去的
- 构建配置里最值得注意的字段
- APK 定制与签名
- 导出后和本地调试不一致时怎么查
先记住这 12 条
- JSXHook 现在至少有两条独立导出链:
- 项目模板导出
- APK 导出
- 两条链都会先把原项目复制到临时目录,而不是直接在工作区原地改。
- 两条链都会先读
init.js,再从中解析项目元信息。 - 导出时不是只看项目文件名,还会生成:
displayNameapplicationId / packageNamescopelauncher- 远程更新配置
.js文件不会原样塞进导出物,而是会经过StandaloneProjectScriptCipher加密。- 非
.js文件会原样复制。 - 插件打包来源不是只有“手动勾选”,还会自动扫描项目里字面量形式的
plugins.load("包名")。 - 这个自动扫描只认字面量字符串,不会执行你的 JS 逻辑去推导动态包名。
- 导出时写进
META-INF/xposed/scope.list的不是原始scope原样,而是recommendedScopes。 recommendedScopes的规则是:- 先用明确列出的非
allscope - 否则退到
launcher - 再否则为空
- 先用明确列出的非
- APK 导出里如果
includeAppUi = false,那么includeRuntimeLogs最终也会被压成false。 - APK 导出最后一定会走签名;签名模式至少分默认签名和自定义签名两类。
关键源码
core/project/StandaloneModuleProjectExporter.ktcore/project/StandaloneModuleApkExporter.ktcore/project/StandaloneModuleApkCustomizer.ktcore/project/StandaloneModuleApkSigner.ktcore/project/StandalonePackagedPluginCatalog.ktcore/project/StandaloneProjectScriptCipher.ktcore/project/StandaloneModuleBuildConfig.ktcore/project/StandaloneRecommendedScope.kthook/javascript/ScriptPluginsApi.kt
导出链到底分成哪两条
先把这件事分清楚,后面看源码就不容易乱。
| 导出类型 | 主要产物 | 核心入口 |
|---|---|---|
| 项目模板导出 | 一个可继续开发、可交付的模板项目压缩包 | StandaloneModuleProjectExporter.export(...) |
| APK 导出 | 一个直接可安装的独立 APK | StandaloneModuleApkExporter.export(...) / exportToDefaultLocation(...) |
它们的共同点
- 都会先复制项目
- 都会解析
init.js - 都会构建导出配置
- 都会加密
.js - 都会带入选中的插件 APK
- 都会写图标
它们的不同点
| 维度 | 项目模板导出 | APK 导出 |
|---|---|---|
| 基础载体 | 模板工程 zip | 基础 APK |
| 修改内容 | build.gradle.kts、strings.xml、README、资源与 assets | Manifest、资源表、assets、scope.list、签名 |
| 最终是否签名 | 不涉及 APK 签名 | 必须签名 |
| 产物定位 | 偏“可继续构建的工程壳” | 偏“最终可安装交付物” |
项目模板导出:StandaloneModuleProjectExporter
这一条链更像“把当前项目灌进一个独立模块模板工程”。
大致步骤
源码里的真实顺序可以概括成:
- 校验项目目录存在
- 清理并创建临时缓存目录
- 解压模板
standalone_module_template.zip - 把当前项目复制到临时目录
- 读取并解析
init.js - 生成模板信息
- 修补模板文件
- 复制项目资源并加密
.js - 拷贝选中的插件 APK
- 生成并写入图标
- 把整个模板目录重新打成 zip 输出
它会修补哪些模板文件
| 文件 | 作用 |
|---|---|
app/build.gradle.kts | 替换 applicationId |
app/src/main/res/values/strings.xml | 替换应用名、Xposed 描述 |
app/src/main/res/values-en/strings.xml | 同上,英文资源 |
app/src/main/resources/META-INF/xposed/scope.list | 写推荐 scope |
README.md | 写入项目说明、作者、launcher 等 |
app/src/main/assets/daowuya_yyds/daowuya.json | 写导出配置包络 |
项目资源复制时会怎样处理
规则非常简单但很关键:
| 文件类型 | 处理方式 |
|---|---|
.js | 读文本后加密,再写入目标 |
| 其他文件 | 原样复制 |
图标怎么来
它会按这个顺序找图标来源:
- 项目里配置的图标路径文件
- symbol icon 渲染
- 默认项目图标
最后会同时写到:
- 模板工程资源位置
- 模板工程 assets 图标位置
项目模板导出更适合什么场景
- 你想把项目交给别人继续构建
- 你需要的是“工程壳”,不是立即安装 APK
- 你想看导出物内部结构而不是直接成品
APK 导出:StandaloneModuleApkExporter
这一条链更像“把项目灌进一个基础壳 APK,然后定制、签名、输出安装包”。
APK 导出的核心阶段
源码里还给这条链定义了明确的进度阶段:
| 阶段 | 进度值 |
|---|---|
PREPARING_OUTPUT | 6 |
PREPARING_WORKSPACE | 14 |
COPYING_PROJECT | 26 |
PARSING_PROJECT | 38 |
PREPARING_BASE_APK | 52 |
EMBEDDING_PROJECT | 68 |
CUSTOMIZING_APK | 82 |
SIGNING_APK | 92 |
WRITING_OUTPUT | 97 |
FINISHING | 100 |
这条链的大致步骤
- 准备输出位置
- 复制项目到临时目录
- 解析
init.js - 生成
ResolvedStandaloneModuleBuildConfig - 复制基础 APK
- 把配置、scope、插件、项目资源、图标写进 APK
- 做 APK 级别定制
- 对 APK 重新签名
- 输出到目标 URI
基础 APK 里会被重写什么
重写时至少会改这些内容:
assets/daowuya_yyds/daowuya.jsonassets/daowuya_yyds/daowuya/...下的项目文件assets/daowuya_yyds/plugins/...下的插件 APKassets/daowuya_yyds/icon.pngres/drawable/exported_project_icon.pngMETA-INF/xposed/scope.list
APK 默认输出到哪里
exportToDefaultLocation(...) 生成默认产物时:
- Android 10 及以上优先写到共享下载目录下的
JSXHook/ - 低版本走应用外部文件目录下的下载目录
文件名规则大致是:
<AppName或项目名>_app.apk
什么时候它会删掉旧目标
如果默认输出位置已有同名目标,它会先删旧文件,再写新文件。
导出失败时也会尝试清理这次创建但未完成的目标。
scope 与 launcher 会怎样影响导出
这个点特别容易误解。
运行时 scope 和导出 scope.list 不是一回事
项目原始配置里你可能写的是:
scope: ["all"]
但导出时写入 META-INF/xposed/scope.list 的,并不是简单把 "all" 原样写进去。
源码会先走:
StandaloneRecommendedScope.resolve(scope, launcher)
recommendedScopes 的真实规则
规则非常明确:
- 先取
scope里所有非空、非all的条目 - 如果这些显式 scope 不为空,就直接用它们
- 如果没有显式 scope,再看
launcher - 如果
launcher非空,就用[launcher] - 否则返回空列表
这意味着什么
| 项目配置 | 导出写入的推荐 scope |
|---|---|
scope = ["com.demo.app"] | ["com.demo.app"] |
scope = ["all"], launcher = "com.demo.app" | ["com.demo.app"] |
scope = ["all"], launcher = "" | [] |
为什么要这么做
因为导出模块时,scope.list 更像“建议安装作用域”,而不是项目运行时的宽泛逻辑配置。
尤其当运行时配置是 all 时,导出侧更倾向于落到一个更具体、更稳妥的推荐包名。
脚本为什么会被加密
这个问题很多人都会问。
StandaloneProjectScriptCipher 做了什么
它负责把 .js 文件在导出时包一层加密外壳。
大致流程是:
- 生成随机 secret
- 对 secret 做 SHA-256,得到 AES key
- 每个脚本单独生成随机 IV
- 用
AES/GCM/NoPadding加密脚本文本 - 拼成带前后缀的加密文本写回导出物
加密只针对哪些文件
只针对:
.js
不针对:
- 图片
- 文本资源
- JSON 之外的普通文件
- 插件 APK
为什么这样做
它至少带来两个效果:
- 导出物里不会直接裸露项目原始脚本
- 运行时仍然能凭 secret blob 重新读取脚本内容
secret 存在哪
导出配置里会写入一个 scriptSecretBlob,不是把明文 secret 散落在项目文件树里。
插件是怎么被扫描并打包进去的
这是导出链里最值得详细写清的地方之一。
插件来源有两类
导出时会收集插件包名,来源于:
- 手动选择的
selectedPluginPackages - 自动扫描项目 JS 文件里出现的字面量
plugins.load("包名")
自动扫描到底认什么
源码用的是一个正则去找:
plugins.load("com.example.plugin")
也就是说,它认的是:
plugins.load("...")plugins.load('...')
而且要求包名是字面量字符串。
它不认什么
下面这种动态写法,导出扫描通常认不出来:
const pkg = "com.example.plugin";
plugins.load(pkg);
或者:
plugins.load(getPluginPackageName());
所以如果你导出后发现插件没被带进去,第一件事就该检查这里。
插件必须满足什么条件才会被认成“可打包插件”
安装在设备上的插件 app 至少要满足:
- App metadata 里有
org.autojs.plugin.sdk.registry sourceDir指向的 APK 文件真实存在
否则就算你写了包名,也不会被解析成可打包插件。
最终插件会被放到哪里
导出物里,插件 APK 会被放进:
assets/daowuya_yyds/plugins/<packageName>.apk
一句话总结这块最常见的坑
导出扫描只会静态扫描字面量 plugins.load("包名"),不会执行你的脚本逻辑来猜包名。
构建配置里最值得注意的字段
StandaloneModuleBuildConfig 里有几个字段特别值得文档里写清楚。
1. includeAppUi
- 类型:
boolean - 默认:
true - 作用:是否保留独立 APK 的应用界面壳
如果它是 false,APK 定制阶段会把主 Activity 移除掉。
2. includeRuntimeLogs
- 类型:
boolean - 默认:
true - 作用:是否包含运行时日志相关能力
但有一个真实规则一定要写清:
resolvedIncludeRuntimeLogs = resolvedIncludeAppUi && includeRuntimeLogs
也就是说:
| 配置 | 最终结果 |
|---|---|
includeAppUi = true, includeRuntimeLogs = true | true |
includeAppUi = false, includeRuntimeLogs = true | 仍然会被压成 false |
3. signatureMode
可选值来自枚举:
| 值 | 含义 |
|---|---|
default | 使用内置默认签名材料 |
custom | 使用自定义 keystore |
旧值如 "shared"、"project" 最终也会回落到 default。
4. customKeystoreUri
- 类型:
string - 仅在
signatureMode = custom时有意义
5. customKeystoreAlias
- 类型:
string - 仅在
signatureMode = custom时有意义
6. selectedPluginPackages
- 类型:
string[] - 默认:空列表
保存时会:
trim()- 去重
- 去空字符串
7. customIconUri / projectCustomIconPath
这两个都和图标来源有关:
| 字段 | 含义 |
|---|---|
customIconUri | 当前设备侧选中的临时自定义图标 URI |
projectCustomIconPath | 持久化进项目目录的图标路径 |
8. packageName
如果没填,会回退到默认生成逻辑;
但是否是合法包名,还要过 isValidPackageName(...) 这类校验规则。
APK 定制与签名
StandaloneModuleApkCustomizer 做了什么
它会在基础 APK 上做 APK 级资源与 Manifest 定制,例如:
- 写应用名
- 写 Xposed 描述
- 写图标资源 ID
- 写
versionName - 写
versionCode - 按需移除主 Activity
- 替换 packageName
- 重写 Manifest 里相关包名字符串
签名模式有哪些
源码里至少有两种:
| 模式 | 含义 |
|---|---|
DEFAULT | 使用内置签名材料 |
CUSTOM | 使用用户提供的 keystore |
自定义签名支持哪些 keystore 类型
根据文件后缀和加载逻辑,至少会尝试:
PKCS12JKS
大致规则:
| 文件后缀 | 优先尝试顺序 |
|---|---|
.p12 / .pfx | PKCS12 -> JKS |
.jks | JKS -> PKCS12 |
| 其他 | PKCS12 -> JKS |
自定义签名最少要准备什么
- keystore URI
- alias
- store password
- key password
如果 key password 留空,会回退用 store password
导出配置里还会写什么签名相关信息
APK 导出配置里还会写:
expectedSignerSha256
也就是最终签名证书的 SHA-256,用来让运行时或更新逻辑有机会校验签名来源。
导出后和本地调试不一致时怎么查
这一节非常重要。
1. 本地脚本能跑,导出后某些 JS 行为变了
先查:
- 导出物里
.js是不是已经被加密包装 - 运行时读脚本 secret 是否正确
- 有没有依赖了工作区特有路径
2. 本地能 plugins.load(),导出后插件没带进去
先查:
- 是否用了字面量
plugins.load("包名") - 包名是不是动态拼接出来的
- 对应插件 app 是否真的安装
- 插件 app metadata 是否包含注册类标记
3. 导出后作用域不对
先查:
- 原始
scope launcherrecommendedScopes最终会推导成什么
尤其是 scope = ["all"] 这种场景,不要以为导出后就一定会得到字面量 all。
4. 导出 APK 没界面或日志能力不见了
先查:
includeAppUiincludeRuntimeLogs
记住真实规则:includeAppUi = false 时,includeRuntimeLogs 也会一起被压成 false。
5. 安装包签名相关问题
先查:
- 现在走的是默认签名还是自定义签名
- 自定义 keystore URI、alias、密码是否一致
- keystore 类型是不是被正确识别
6. 导出后 packageName / 版本号 / 文案不符合预期
先查:
ResolvedStandaloneModuleBuildConfigStandaloneModuleApkCustomizer- 模板工程里
build.gradle.kts/strings.xml是否被正确 patch
最后压成一句话
- JSXHook 的导出不是“打个包”这么简单,而是一条完整的重写链。
- 这条链里最容易误解的 3 件事是:
scope.list写的是推荐 scope,不是原始 scope 原样- 插件扫描只认字面量
plugins.load("包名") .js会被加密,不会明文裸放
- 真遇到“本地能跑、导出后不一致”,就按“配置解析 -> scope/launcher -> 插件扫描 -> 脚本加密 -> APK 定制/签名”这条线往下查。
