项目系统
项目系统
项目系统是 JSXHook 把“零散脚本”变成“可维护项目”的那一层。
如果你是第一次接触它,先记住一句最重要的话:
一个 JSXHook 项目,在源码层面的最小要求就是同时存在 init.js 和 main.js。
如果你想配合应用里的示例一起看,可以先打开:内置手册全量示例 - 项目系统与设置脚本。
先建立一个正确心智模型
把 JSXHook 项目先理解成下面这 4 个部分:
init.js:项目声明脚本,负责告诉 JSXHook“这个项目叫什么、打到哪些包、入口文件是谁、是否带独立模块构建配置”等。main.js:真正的运行入口,Hook、日志、require(...)、业务逻辑通常都从这里开始。Project/info.json:项目启用状态表,源码会用它决定某个项目是否启用。- 项目目录本身:实际放在
/data/local/tmp/JSXHook/Project/<项目名>。
很多新手最开始会把 init.js 当成“备注文件”。这会直接把自己带偏。
在 JSXHook 里,init.js 不是备注,它是真正会被解析和执行的声明脚本。
JSXHook 项目的最小结构
源码 ProjectManager.checkFiles(...) 的硬条件非常简单,只检查两个文件:
init.jsmain.js
也就是说,下面这个目录就已经是“合法项目”了:
MyFirstProject/
init.js
main.js
你当然可以继续拆分更多目录:
MyFirstProject/
init.js
main.js
lib/
helper.js
hooks/
login.js
data/
config.json
但这些都是“项目组织方式”,不是“项目合法性的硬门槛”。
工作区里它实际放在哪里
JSXHook 工作区的根目录常量在 WorkspaceFileManager 里写死为:
/data/local/tmp/JSXHook
项目系统相关的几个真实目录是:
| 路径常量 | 真实目录 | 用途 |
|---|---|---|
WorkspaceFileManager.Project | /data/local/tmp/JSXHook/Project | 存项目目录 |
WorkspaceFileManager.AppConf | /data/local/tmp/JSXHook/AppConf | 宿主应用配置 |
WorkspaceFileManager.AppScript | /data/local/tmp/JSXHook/AppScript | App Script |
WorkspaceFileManager.Plugin | /data/local/tmp/JSXHook/Plugin | 插件目录 |
所以一个项目 Demo 的实际位置通常就是:
/data/local/tmp/JSXHook/Project/Demo
这点看起来底层,但对排错很有帮助。你一旦知道项目真正在这个目录,就能更容易理解“为什么导入成功了”“为什么导出打包的是这个目录”“为什么 getProjectDir(...) 能拿到这些文件”。
init.js 是怎么被解析的
ProjectScriptParser.parse(...) 的真实流程是:
- 如果脚本文本为空,直接返回
null。 - 用 Rhino ES6 执行整段
init.js。 - 执行成功后,从全局作用域里取配置。
- 解析异常时直接返回
null,不会把异常继续往外抛。
这带来两个很实际的结论:
init.js里不是“只允许写 JSON”,它本质上是会执行的脚本。- 只要
init.js写到 Rhino 直接报错,项目解析结果就会变成null。
project = { ... } 是首选写法
解析器会优先读全局变量 project,常量名就是:
project
也就是 ScriptFiles.PROJECT_CONFIG_NAME = "project"。
最推荐的新手写法:
project = {
name: "示例项目",
description: "给第一次接触 JSXHook 的人准备的项目",
author: "you",
icon: "rocket",
launcher: "main.js",
scope: ["com.example.app"]
};
这种写法的优点很直接:
- 所有项目字段都集中在一起。
- 后续新增字段时不容易写乱。
- 更接近 JSXHook 自己生成
init.js的方式。
直接写全局字段也兼容
如果 project 不存在,或者它不是一个对象,解析器会退回去直接从全局作用域拿字段。
所以这类写法同样有效:
name = "示例项目";
description = "JSXHook sample project";
author = "you";
icon = "rocket";
launcher = "main.js";
scope = ["com.example.app"];
这不是“文档猜测”,而是解析器 fromScope(scope) 的真实回退逻辑。
一个非常重要的特殊点:standaloneUpdate 不是 project 里的字段
这点很容易写错。
源码里:
standaloneBuild是从project对象里读的。standaloneUpdate却是从全局作用域直接读的。
所以正确写法是:
project = {
name: "示例项目",
launcher: "main.js",
scope: ["com.example.app"],
standaloneBuild: {
appName: "Demo Module"
}
};
standaloneUpdate = {
currentVersion: "1.0.0",
targetApp: "com.example.app",
device: "office-phone",
ipPort: "192.168.1.10:8080",
blockHookOnCheckFailure: false
};
不要写成这样:
project = {
name: "错误示例",
launcher: "main.js",
scope: ["com.example.app"],
standaloneUpdate: {
currentVersion: "1.0.0"
}
};
上面这种写法在源码当前实现下,standaloneUpdate 很可能根本不会按你想的那样被解析到。
init.js 字段总表
下面这张表,尽量把“能填什么、默认是什么、写错会怎样”讲清楚。
| 字段 | 位置 | 源码接受值 | 默认值 | 最常见写法 | 写错或不写时会怎样 |
|---|---|---|---|---|---|
name | project 内或全局 | 任何可转成字符串的值 | "" | "QQ Hook" | 不写时解析结果为空串;项目列表展示时会退回目录名;导入 zip 时如果最终项目名为空会失败 |
description | project 内或全局 | 任何可转成字符串的值 | "" | "演示项目" | 不写只是不显示描述,不会阻止项目存在 |
author | project 内或全局 | 任何可转成字符串的值 | "" | "you" | 不写就是空作者 |
icon | project 内或全局 | 任何可转成字符串的值 | "icon.png" | "icon.png"、"rocket" | 不写时默认就是 "icon.png";具体显示成文件图标还是符号图标,取决于后续图标逻辑 |
launcher | project 内或全局 | 任何可转成字符串的值 | "" | "main.js" | 不写时不会自动补成 main.js;写错路径时,项目能被列出来,但运行入口可能找不到 |
scope | project 内或全局 | 非空字符串、数组、带 length 的脚本对象 | [] | "com.example.app" 或 ["com.example.app"] | 不写时解析为 [];对新手来说可以把它理解成“项目通常不会命中任何目标应用” |
standaloneBuild | project 内 | 对象 | 空配置对象 | { appName: "...", packageName: "..." } | 不写就按默认导出配置处理 |
standaloneUpdate | 全局变量 | 对象 | 空配置对象 | standaloneUpdate = { ... } | 不写就表示未配置远程更新;写进 project 内部时当前实现不按该位置读取 |
scope 到底可以填哪些值
这是最值得展开说清楚的字段之一。
1. 单个包名字符串
project = {
name: "QQ Demo",
launcher: "main.js",
scope: "com.tencent.mobileqq"
};
源码会把它解析成单元素列表:
["com.tencent.mobileqq"]
2. 包名数组
project = {
name: "Multi Scope Demo",
launcher: "main.js",
scope: [
"com.example.app",
"com.example.app.debug"
]
};
这是最稳、最清晰、最推荐的写法。
3. 其他“类数组对象”
解析器除了认普通数组,还会认带 length 的脚本对象。
这个更偏框架兼容细节,正常项目作者几乎不用主动这么写,但源码确实支持。
4. 特殊值 "all"
如果你的 scope 里包含 "all",项目生成器在写回 init.js 时会直接输出成:
scope: "all"
而不是:
scope: ["all"]
这意味着你在文档、示例、项目导出结果里,看到这两种写法都不奇怪,但 JSXHook 自己生成时更偏向字符串形式。
5. 空值、空串、空数组
这些情况在源码里都会被归一成“空作用域列表”:
scope = "";
scope = [];
这类写法不会让项目解析崩掉,但对实际使用来说通常没有意义。
新手如果发现“项目明明启用了,却完全不进目标 App”,第一优先级就该查 scope。
launcher 真实规则,比“通常写 main.js”更细一点
很多文档会说“入口一般写 main.js”。这句话没错,但只说这一句不够。
源码层面的真实规则是:
launcher的默认值是空字符串""。- 解析器不会自动帮你把空字符串改成
main.js。 launcher在解析阶段不会检查目标文件是否真的存在。
所以你要分清楚两件事:
- 推荐入口名:通常写
main.js。 - 源码默认值:实际上默认是
"",不是自动补main.js。
自定义入口可以怎么写
project = {
name: "Alt Launcher Demo",
launcher: "entry.js",
scope: ["com.example.app"]
};
这在解析阶段是完全合法的。
但你必须自己确保项目目录里真的有 entry.js。
一个很容易忽略的坑:创建项目时只会自动生成 main.js
ProjectManager.createProject(...) 当前会固定生成:
init.jsmain.js
它不会因为你把 launcher 写成 entry.js,就自动帮你创建 entry.js。
所以如果你自己把入口改了,记得同步创建那个实际入口文件。
icon 真实规则
icon 这个字段源码只要求它能转成字符串,常见情况有两种理解方式:
- 它是一个图标文件名,比如默认的
"icon.png"。 - 它是一个 Unicode / symbol 图标值,比如
"rocket"这类符号名或字符值。
默认值
如果你完全不写 icon,解析器默认值就是:
"icon.png"
创建项目时图标优先级
ProjectManager.createProject(...) 的真实行为是:
- 默认先把图标值设成
icon.png。 - 如果你传了外部
iconPath,它会把那个文件复制到项目目录里的默认图标文件名。 - 如果你同时又传了有效的
iconUnicode,那么最终写进init.js的icon值会优先用 Unicode / symbol 图标。
也就是说:
- “复制了图标文件” 和 “最终
icon字段写成什么” 是两件相关但不完全相同的事。
standaloneBuild:项目内的独立模块构建配置
standaloneBuild 放在 project = { ... } 里面。
如果你只是普通写 Hook 项目,它可以先不写;但你想让项目后续导出成独立模块时,它就很重要了。
支持哪些字段
源码 propertyAsBuildConfig(...) 当前实际支持这些字段:
| 字段 | 源码接受值 | 默认值 | 说明 |
|---|---|---|---|
appName | 可转字符串 | "" | 导出模块显示名 |
packageName | 可转字符串 | "" | 导出模块包名 |
versionName | 可转字符串 | "" | 版本名 |
versionCode | 可转字符串或数字 | "" | 版本号,后续解析成正整数,不合法时会回退 |
includeAppUi | 布尔、数字、字符串 "true" / "false" | true | 是否带应用 UI |
includeRuntimeLogs | 布尔、数字、字符串 "true" / "false" | true | 是否带运行日志页 |
projectCustomIconPath | 可转字符串 | "" | 项目内自定义图标路径 |
signatureMode | 字符串 | "default" | 签名模式 |
customKeystoreAlias | 可转字符串 | "" | 自定义签名别名 |
selectedPluginPackages | 单字符串、数组、类数组对象 | [] | 需要一起带上的插件包名列表 |
signatureMode 可以填什么
当前源码枚举 StandaloneSignatureMode 只有两个正式值:
| 值 | 含义 |
|---|---|
"default" | 默认签名模式 |
"custom" | 自定义签名模式 |
另外,解析器对下面这些旧值也做了兼容:
"shared""project"
但它们最终都会被当成:
"default"
所以新文档和新项目里,建议你只写:
signatureMode: "default"
或者:
signatureMode: "custom"
includeAppUi 和 includeRuntimeLogs 的真实行为
表面上这是两个独立布尔值,但最终解析结果里还有一个联动:
includeAppUi = false时,最终导出配置里的includeRuntimeLogs也会被压成false。
你可以把它理解成:
- 没有 App UI 外壳时,运行日志页自然也无从展示。
versionCode 可以写数字还是字符串
源码读取时统一按字符串存。
所以这些写法都能被接受:
versionCode: 1
versionCode: "1"
但真正导出时,它会被进一步解析成正整数:
- 能转成正整数,就用该值。
- 不能转成正整数,或者小于等于
0,就会回退到默认版本号。
当前默认版本号常量是:
DEFAULT_STANDALONE_VERSION_NAME = "1.0.0"DEFAULT_STANDALONE_VERSION_CODE = 1
哪些字段不会从 init.js 长期保存
StandaloneModuleBuildConfig 运行时模型里还有这些字段:
customIconUricustomKeystoreUricustomSigningSecrets
但源码 toProjectPersistedConfig() 会把它们清掉。
也就是说,这些值不是设计成长期写在项目 init.js 里的持久字段。
如果你看到 UI 里有这些项,但 init.js 里没有,不是漏保存,而是设计上就不准备把它们持久化到项目脚本。
为什么有时 standaloneBuild 整段都不见了
buildProjectInitScript(...) 只有在以下任一条件成立时,才会把 standaloneBuild 写回 init.js:
appName非空packageName非空versionName非空versionCode非空includeAppUi == falseincludeRuntimeLogs == falseprojectCustomIconPath非空signatureMode != defaultcustomKeystoreAlias非空selectedPluginPackages非空
如果这些都没有命中,生成的 init.js 就根本不会出现 standaloneBuild 段。
这不是丢数据,而是源码刻意做的“只在有意义时才输出”。
standaloneUpdate:顶层远程更新配置
standaloneUpdate 不是放在 project 对象里的,它应该单独写成顶层变量。
标准形态:
standaloneUpdate = {
currentVersion: "1.0.0",
targetApp: "com.example.app",
device: "office-phone",
ipPort: "192.168.1.10:8080",
blockHookOnCheckFailure: false
};
支持哪些字段
源码 propertyAsRemoteUpdateConfig(...) 当前支持:
| 字段 | 源码接受值 | 默认值 | 说明 |
|---|---|---|---|
currentVersion | 可转字符串 | "" | 当前版本 |
targetApp | 可转字符串 | "" | 目标应用标识 |
device | 可转字符串 | "" | 设备标识 |
ipPort | 可转字符串 | "" | IP:端口 之类的连接地址 |
blockHookOnCheckFailure | 布尔、数字、字符串 "true" / "false" | false | 校验失败时是否阻止 Hook |
这些值到底“只能填哪些”
这里要分两层理解:
- 解析器层面:前四个字段源码基本都接受任意字符串,
blockHookOnCheckFailure接受能转成布尔语义的值。 - 业务语义层面:这些字符串能不能真正用,要看远程更新模块后续怎么解释。
换句话说,像 device、targetApp 这类字段,源码层面没有做“固定枚举值校验”。
如果你问“它只能填哪几个常量”,当前源码答案是:没有写死枚举,至少在解析阶段没有。
什么时候算“已配置”
StandaloneRemoteUpdateConfig.isConfigured() 要求下面 4 个字段都非空:
currentVersiontargetAppdeviceipPort
只要缺一个,整体就不算完整配置。
currentVersion 为什么值得单独提
解析器对 standaloneUpdate.currentVersion 还有一层额外处理:
它会尽量保留你在脚本里的原始字面量语义,而不是简单粗暴地只保留转换后的结果。
对普通项目作者来说,你不用天天惦记这个细节;
但如果你后面发现“为什么这个版本字段在重写脚本后还能保持接近原始写法”,这就是原因。
一个足够稳的新手 init.js 示例
下面这个例子基本涵盖了项目声明里最常用、最不容易出错的部分:
project = {
name: "示例项目",
description: "给第一次接触 JSXHook 的人准备的完整示例",
author: "you",
icon: "icon.png",
launcher: "main.js",
scope: [
"com.example.app",
"com.example.app.debug"
],
standaloneBuild: {
appName: "Example Module",
packageName: "com.example.jsxhook.module",
versionName: "1.0.0",
versionCode: 1,
includeAppUi: true,
includeRuntimeLogs: true,
signatureMode: "default",
selectedPluginPackages: [
"com.example.plugin.demo"
]
}
};
standaloneUpdate = {
currentVersion: "1.0.0",
targetApp: "com.example.app",
device: "office-phone",
ipPort: "192.168.1.10:8080",
blockHookOnCheckFailure: false
};
main.js 才是运行入口
main.js 负责真正执行逻辑。
你可以先从这种结构开始:
log(`project=${project.name}`);
log(`package=${lpparam.packageName}`);
log(`process=${lpparam.processName}`);
const helper = require("lib/helper.js");
helper.run?.();
再配一个最小模块:
exports.run = function() {
log("helper running");
};
这类结构比把所有逻辑都塞进一个大脚本,后期可维护性要高很多。
getProjectDir(...) 为什么比自己拼路径稳
项目里读取文件时,最稳妥的写法通常还是:
const configPath = getProjectDir("data/config.json");
你可以把几个常用路径基准这样记:
| 调用 | 适合理解成什么 |
|---|---|
getProjectDir() | 当前项目目录绝对路径 |
getProjectDir("相对路径") | 当前项目目录下某个文件或子目录 |
files.cwd() | 当前脚本运行时工作目录语义 |
如果你只是想稳稳拿到项目里的资源路径,优先用 getProjectDir(...)。
ProjectManager.createProject(...) 创建项目时到底做了什么
这是源码层面最容易被“脑补错”的一段,所以单独拆开说。
成功前提
创建项目时,以下任一条件不满足都会直接返回 false:
name为空白- 项目目录已经存在
/Project根目录创建失败/Project/<name>目录创建失败init.js写入失败main.js写入失败info.json保存失败
创建成功后会生成什么
它会确保下面这些东西存在:
/data/local/tmp/JSXHook/Project/<项目名>/
init.js
main.js
默认生成的 main.js 内容就是:
// Main entry for <项目名>
log("Hello from <项目名>");
创建成功后项目会不会自动启用
会。
源码会往:
/Project/info.json
里写入:
项目名 = true
也就是说,项目刚创建出来时默认就是启用状态。
Project/info.json 是干什么的
ProjectManager.getProjects() 会先读 Project/info.json,把它当成“项目名 -> 是否启用”的映射表。
它的作用主要是:
- 决定项目是否启用
- 决定 JSXHook 要列出哪些项目
如果 init.js 解析失败会怎样
这里还有一个很实用的容错行为:
getProjects()在解析init.js失败时,不会直接把整个项目丢掉。- 它会退回到
ProjectScriptConfig(name = 目录名)这样的默认配置。
所以你会看到一种情况:
- 项目目录还在
- 项目仍然能显示在列表里
- 但显示信息不完整,或者配置字段像是没生效
这种时候,优先检查 init.js 有没有语法错误或字段写法错误。
ensureScopePackagesVisible(scope) 为什么值得知道
创建项目并成功保存后,源码还会做一件新手很少注意、但体验上很有帮助的事:
- 把
scope里的包名自动补进/apps.txt
不过它会先做过滤,只保留:
- 非空
- 不等于
"all" - 去重后的包名
这意味着:
- 你项目里写到的包名,通常会自动出现在作用域相关的 App 列表里。
- 特殊值
"all"不会被写进/apps.txt。
导入项目 zip:到底接受什么结构
ProjectManager.importProject(context, uri) 的流程很明确:
- 先把用户选的 zip 拷到缓存。
- 再拷到工作区临时 zip。
- 解压到临时目录。
- 检查解压结构是否合法。
- 解析
init.js拿项目名。 - 没问题就搬进正式项目目录。
zip 支持的两种结构
源码当前允许这两种:
结构一:zip 根目录就是项目文件
init.js
main.js
lib/helper.js
结构二:zip 根目录下只有一个子目录,项目在子目录里
MyProject/
init.js
main.js
导入成功的硬条件
下面这些条件少一个都可能失败:
- 最终项目目录里必须同时存在
init.js和main.js init.js能解析出非空项目名- 目标项目目录不能已存在
导入失败时最常见的原因
| 场景 | 结果 |
|---|---|
zip 里缺 init.js 或 main.js | 直接失败 |
init.js 能执行但 name 最终为空 | 失败 |
| 导入出的项目名和现有项目重名 | 失败 |
| zip 结构多套了一层又不止一个根子目录 | 失败 |
导入成功后会发生什么
成功后它会:
- 把项目目录移动到
/data/local/tmp/JSXHook/Project/<name>。 - 在
Project/info.json里写入<name> = true。
也就是“导入完自动启用”。
导出项目 zip:它不是“独立模块导出”
ProjectManager.exportProject(context, name, uri) 做的事情非常朴素:
- 找到
/Project/<name>目录。 - 复制整个目录到临时目录。
- 把这个目录原样打成 zip。
它会做什么
- 复制整个项目目录
- 连同
init.js、main.js、lib/、data/一起打包
它不会做什么
- 不会做脚本加密
- 不会做插件合包
- 不会做独立模块 APK 构建
- 不会做 APK 签名
- 不会替你生成运行时壳
所以一定要把这两件事分开:
- 项目 zip 导出:只是把项目目录打包带走。
- 独立模块 / APK 导出:是另一条更复杂的导出链。
后者建议配合 导出与插件 一起看。
一个完整的新手项目示例
init.js
project = {
name: "示例项目",
description: "给第一次接触 JSXHook 的人做的最小工程",
author: "you",
icon: "icon.png",
launcher: "main.js",
scope: ["com.example.app"]
};
main.js
log(`project=${project.name}`);
log(`package=${lpparam.packageName}`);
const helper = require("lib/helper.js");
helper.run?.();
lib/helper.js
exports.run = function() {
log("helper running");
};
设置脚本 //@set
项目系统里还有一类很实用的脚本是设置页脚本,也就是在脚本顶部写:
//@set
function buildSettings() {
log("settings page script");
print("print works here too");
}
它适合做:
- 项目自己的设置页逻辑
- 开关、输入框、状态说明
- 和当前项目强相关的配置面板
而且它不是“静态说明文本”,你仍然可以在里面调 JSXHook 运行时能力:
//@set
function buildSettings() {
enableEdgeToEdge();
const Build = imports("android.os.Build");
log(`model=${Build.MODEL}`);
}
新手最容易踩的坑
1. 以为 launcher 不写就会自动变成 main.js
源码默认值其实是空字符串。
“通常写 main.js”是约定,不是解析器自动补值。
2. 把 standaloneUpdate 写进 project 对象里
当前实现读的是顶层变量,不是 project.standaloneUpdate。
3. 自定义了 launcher: "entry.js",却没真的创建 entry.js
创建器只会自动生成 main.js。
4. scope 写成空数组或者写错包名
这种情况下最常见的表象就是:
- 项目显示正常
- 也能启用
- 但就是不进目标 App
5. 以为“项目导出 zip”就是“导出独立模块”
前者只是目录打包,后者才涉及构建、加密、插件、签名等链路。
6. init.js 里写了语法错误,却只看见项目显示不正常
因为解析失败时,项目列表有回退逻辑,不一定会整个消失。
你看到“项目还在,但字段像没生效”,往往就该去查 init.js。
建议的阅读顺序
如果你现在是第一次系统学 JSXHook 项目,建议按这个顺序看:
