Files
ShortX_ToolHub/ToolHub.js
2026-05-23 01:15:56 +08:00

437 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ToolHub - 入口文件 (加载子模块并执行)
// 安全更新机制:入口内置 RSA 公钥,先验证 manifest.json/manifest.sig再按 SHA256 下载子模块。
// Gitea 只负责分发;未通过签名/哈希/防回滚校验时,不覆盖本地模块。
var UPDATE_SOURCE = 0; // 0: Gitea, 1: GitHub
var UPDATE_ROOTS = [
"https://git.xin-blog.com/linshenjianlu/ShortX_ToolHub/raw/branch/main/",
"https://raw.githubusercontent.com/7015725/Toolhub-FloatBall/main/"
];
if (UPDATE_SOURCE !== 1) UPDATE_SOURCE = 0;
var GIT_ROOT = UPDATE_ROOTS[UPDATE_SOURCE];
var GIT_BASE = GIT_ROOT + "code/";
var TRUSTED_PUBLIC_KEYS = {
"toolhub-targets-2026-rsa3072": "MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEApiyhtMDJce7dVCxH1/oDu8kbiECYoT5XXmXvR/XNYuJ/5FuL83SbpCQ3QmUnqkbfNyOFqnxac/qlbXJtx6eeSotLP1HmrKI0LGymgxG6b1FfGHBfIKNZfBLIvzVDQob+HJfshlsS1JRlW5Jhm25TMh8dJCQQQZWW/ZItbtOvPYbLwG8cnqEdX8gqyB304+r2l35GPTfxZIGEK/9PcE3AMuqwTolMJsBHtG61hmMdz3dzTTEZQoOcciGWuwr2ZW8XkF6f5SgWkC29ZxZqAxceK4FJ8BsYirpFQxVKyZ6eiYlpNiYz+pHLP2U7JTO6ImmT1rlYSS6xw2tlWf0xq72nuOPC+VzEivuEhnC4y9WBSvauRa/ViIDgQ3yXl2MajuAvGSVWRfZ5Gz5Up8PQD7vxmHT2r0fA4xq4GIvUvGCqOG/d1FRrlVyEuNhCZ7KgpEKPno7fLnC6/ftnYcN5ZNOSWwjWH/e4fBxM5s6RRIYzIY2N0f/fqsRH42lWAhX5stujAgMBAAE="
};
var DEFAULT_TRUSTED_KEY_ID = "toolhub-targets-2026-rsa3072";
var MIN_TRUSTED_MANIFEST_VERSION = 20260507152251;
var __dirChecked = false;
var __trustedManifest = null;
var __securityStatus = { ok: false, msg: "安全清单尚未校验" };
function buildNoCacheUrl(urlStr) {
var sep = String(urlStr).indexOf("?") >= 0 ? "&" : "?";
return String(urlStr) + sep + "_toolhub_ts=" + java.lang.System.currentTimeMillis();
}
function getLogPath() { return shortx.getShortXDir() + "/ToolHub/logs/init.log"; }
function getCodeDirPath() { return shortx.getShortXDir() + "/ToolHub/code/"; }
function getTrustedShaPath(relPath) { return getCodeDirPath() + ".trusted_sha_" + relPath; }
function getTrustedVersionPath() { return getCodeDirPath() + ".trusted_manifest_version"; }
function writeLog(msg) {
try {
var f = new java.io.File(getLogPath());
var dir = f.getParentFile();
if (dir && !dir.exists()) dir.mkdirs();
var sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
var ts = sdf.format(new java.util.Date());
var writer = new java.io.FileWriter(f, true);
writer.write("[" + ts + "] " + String(msg) + "\n");
writer.close();
} catch (e) {}
}
function runShell(cmdArr) {
try {
var proc = java.lang.Runtime.getRuntime().exec(cmdArr);
proc.waitFor();
return proc.exitValue() === 0;
} catch (e) { return false; }
}
function checkDirPerms(path) {
try {
var proc = java.lang.Runtime.getRuntime().exec(["stat", "-c", "%u %g %a", path]);
proc.waitFor();
var reader = new java.io.BufferedReader(new java.io.InputStreamReader(proc.getInputStream()));
var line = reader.readLine();
reader.close();
if (line) {
var parts = String(line).trim().split(/\s+/);
if (parts.length >= 3) return String(parts[0]) === "1000" && String(parts[1]) === "1000" && String(parts[2]) === "700";
}
} catch (e) {}
return false;
}
function setDirPerms(path) {
runShell(["chmod", "700", path]);
runShell(["chown", "1000:1000", path]);
}
function ensureCodeDir() {
var dir = new java.io.File(getCodeDirPath());
if (!__dirChecked) {
if (!dir.exists()) {
dir.mkdirs();
setDirPerms(dir.getAbsolutePath());
writeLog("Created dir: " + dir.getAbsolutePath());
} else if (!checkDirPerms(dir.getAbsolutePath())) {
setDirPerms(dir.getAbsolutePath());
writeLog("Fixed dir perms: " + dir.getAbsolutePath());
}
__dirChecked = true;
}
if (!dir.canWrite()) throw "Dir not writable: " + dir.getAbsolutePath();
return dir;
}
function readTextFile(path) {
try {
var f = new java.io.File(path);
if (!f.exists()) return null;
var r = new java.io.BufferedReader(new java.io.InputStreamReader(new java.io.FileInputStream(f), "UTF-8"));
var sb = new java.lang.StringBuilder();
var line;
while ((line = r.readLine()) != null) sb.append(line).append("\n");
r.close();
return String(sb.toString());
} catch (e) { return null; }
}
function writeTextFile(path, text) {
try {
var f = new java.io.File(path);
var parent = f.getParentFile();
if (parent && !parent.exists()) parent.mkdirs();
var w = new java.io.OutputStreamWriter(new java.io.FileOutputStream(f, false), "UTF-8");
w.write(String(text));
w.close();
return true;
} catch (e) { return false; }
}
function readFirstLine(path) {
var txt = readTextFile(path);
if (!txt) return null;
var parts = String(txt).split(/\r?\n/);
return parts.length > 0 ? String(parts[0]).trim() : null;
}
function sha256File(fileOrPath) {
try {
var path = String(fileOrPath);
var md = java.security.MessageDigest.getInstance("SHA-256");
var fis = new java.io.FileInputStream(path);
var buf = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, 8192);
var n;
while ((n = fis.read(buf)) !== -1) md.update(buf, 0, n);
fis.close();
var digest = md.digest();
var sb = new java.lang.StringBuilder();
for (var i = 0; i < digest.length; i++) {
var hex = java.lang.Integer.toHexString(0xFF & digest[i]);
if (hex.length() === 1) sb.append("0");
sb.append(hex);
}
return String(sb.toString());
} catch (e) { return null; }
}
function saveTrustedSha(relPath, hash) { writeTextFile(getTrustedShaPath(relPath), String(hash || "")); }
function getTrustedSha(relPath) { return readFirstLine(getTrustedShaPath(relPath)); }
function getTrustedVersion() {
var line = readFirstLine(getTrustedVersionPath());
var v = line ? parseInt(String(line), 10) : 0;
return isNaN(v) ? 0 : v;
}
function saveTrustedVersion(v) { writeTextFile(getTrustedVersionPath(), String(v || 0)); }
function downloadText(urlStr) {
var url = new java.net.URL(buildNoCacheUrl(urlStr));
var conn = url.openConnection();
conn.setUseCaches(false);
conn.setConnectTimeout(10000);
conn.setReadTimeout(30000);
conn.setRequestProperty("User-Agent", "ShortX-ToolHub/secure-updater");
conn.setRequestProperty("Cache-Control", "no-cache, no-store, must-revalidate");
conn.setRequestProperty("Pragma", "no-cache");
var code = conn.getResponseCode();
if (code !== 200) throw "HTTP " + code;
var r = new java.io.BufferedReader(new java.io.InputStreamReader(conn.getInputStream(), "UTF-8"));
var sb = new java.lang.StringBuilder();
var line;
while ((line = r.readLine()) != null) sb.append(line).append("\n");
r.close();
var text = String(sb.toString());
var prefix = text.length > 200 ? text.substring(0, 200) : text;
if (prefix.indexOf("<!DOCTYPE") >= 0 || prefix.indexOf("<html") >= 0) throw "Downloaded content is HTML";
return text;
}
function downloadFile(urlStr, destFile) {
var url = new java.net.URL(buildNoCacheUrl(urlStr));
var conn = url.openConnection();
conn.setUseCaches(false);
conn.setConnectTimeout(10000);
conn.setReadTimeout(30000);
conn.setRequestProperty("User-Agent", "ShortX-ToolHub/secure-updater");
conn.setRequestProperty("Cache-Control", "no-cache, no-store, must-revalidate");
conn.setRequestProperty("Pragma", "no-cache");
var code = conn.getResponseCode();
if (code !== 200) throw "HTTP " + code;
var expectedLen = conn.getContentLength();
var inStream = conn.getInputStream();
var outStream = new java.io.FileOutputStream(destFile);
var buf = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, 8192);
var n, total = 0;
while ((n = inStream.read(buf)) !== -1) { outStream.write(buf, 0, n); total += n; }
outStream.close(); inStream.close();
if (expectedLen > 0 && total !== expectedLen) throw "Size mismatch: expected=" + expectedLen + ", got=" + total;
var checkStream = new java.io.FileInputStream(destFile);
var checkBuf = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, 200);
var checkRead = checkStream.read(checkBuf);
checkStream.close();
if (checkRead > 0) {
var prefix = new java.lang.String(checkBuf, 0, checkRead, "UTF-8");
if (prefix.indexOf("<!DOCTYPE") >= 0 || prefix.indexOf("<html") >= 0) throw "Downloaded content is HTML, not JS";
}
return total;
}
function base64Decode(s) {
return android.util.Base64.decode(String(s).replace(/\s+/g, ""), android.util.Base64.DEFAULT);
}
function getTrustedPublicKeyB64(keyId) {
var kid = keyId ? String(keyId) : DEFAULT_TRUSTED_KEY_ID;
if (TRUSTED_PUBLIC_KEYS.hasOwnProperty(kid)) return TRUSTED_PUBLIC_KEYS[kid];
return null;
}
function verifyManifestSignature(manifestText, sigText, keyId) {
try {
var pubB64 = getTrustedPublicKeyB64(keyId);
if (!pubB64) throw "unknown manifest keyId: " + String(keyId);
var pubBytes = base64Decode(pubB64);
var sigBytes = base64Decode(sigText);
var spec = new java.security.spec.X509EncodedKeySpec(pubBytes);
var pubKey = java.security.KeyFactory.getInstance("RSA").generatePublic(spec);
var verifier = java.security.Signature.getInstance("SHA256withRSA");
verifier.initVerify(pubKey);
verifier.update(new java.lang.String(String(manifestText)).getBytes("UTF-8"));
return verifier.verify(sigBytes);
} catch (e) {
writeLog("Manifest signature verify exception: " + String(e));
return false;
}
}
function fetchTrustedManifest() {
try {
ensureCodeDir();
var manifestText = downloadText(GIT_ROOT + "manifest.json");
var sigText = downloadText(GIT_ROOT + "manifest.sig");
var manifest = JSON.parse(String(manifestText));
if (!manifest || !manifest.files) throw "manifest files missing";
if (String(manifest.alg || "") !== "SHA256withRSA") throw "unsupported manifest alg: " + String(manifest.alg);
var keyId = String(manifest.keyId || DEFAULT_TRUSTED_KEY_ID);
if (!getTrustedPublicKeyB64(keyId)) throw "untrusted manifest keyId: " + keyId;
if (!verifyManifestSignature(manifestText, sigText, keyId)) throw "manifest signature invalid";
var version = parseInt(String(manifest.version || "0"), 10);
if (isNaN(version) || version <= 0) throw "invalid manifest version";
if (version < MIN_TRUSTED_MANIFEST_VERSION) throw "manifest below minimum trusted version: remote=" + version + ", min=" + MIN_TRUSTED_MANIFEST_VERSION;
var localVersion = getTrustedVersion();
if (localVersion > 0 && version < localVersion) throw "manifest rollback: remote=" + version + ", local=" + localVersion;
__trustedManifest = manifest;
__securityStatus = { ok: true, msg: "安全清单验签通过version=" + version + ", keyId=" + keyId, version: version, keyId: keyId };
writeLog(__securityStatus.msg);
return manifest;
} catch (e) {
__trustedManifest = null;
__securityStatus = { ok: false, msg: "安全清单校验失败,已停止远程拉取:" + String(e) };
writeLog(__securityStatus.msg);
return null;
}
}
function replaceFile(tmpFile, destFile) {
try {
if (destFile.exists() && !destFile.delete()) throw "delete old file failed: " + destFile.getAbsolutePath();
if (!tmpFile.renameTo(destFile)) throw "rename tmp failed: " + tmpFile.getAbsolutePath();
return true;
} catch (e) { throw String(e); }
}
function getManifestInfo(relPath) {
if (!__trustedManifest || !__trustedManifest.files) return null;
return __trustedManifest.files[relPath] || null;
}
function ensureVerifiedModule(relPath, destFile) {
var info = getManifestInfo(relPath);
if (!info || !info.sha256) throw "module not in trusted manifest: " + relPath;
var expectedHash = String(info.sha256).toLowerCase();
var expectedSize = Number(info.size || 0);
var actualHash = destFile.exists() ? sha256File(destFile.getAbsolutePath()) : null;
if (destFile.exists() && actualHash && String(actualHash).toLowerCase() === expectedHash) {
saveTrustedSha(relPath, expectedHash);
return { updated: false, size: destFile.length(), hash: actualHash };
}
var tmpFile = new java.io.File(destFile.getAbsolutePath() + ".tmp");
try { if (tmpFile.exists()) tmpFile.delete(); } catch (eDelTmp0) {}
var size = downloadFile(GIT_BASE + relPath, tmpFile);
if (expectedSize > 0 && size !== expectedSize) {
try { tmpFile.delete(); } catch (eDelSize) {}
throw "manifest size mismatch for " + relPath + ": expected=" + expectedSize + ", got=" + size;
}
var tmpHash = sha256File(tmpFile.getAbsolutePath());
if (!tmpHash || String(tmpHash).toLowerCase() !== expectedHash) {
try { tmpFile.delete(); } catch (eDelHash) {}
throw "manifest SHA256 mismatch for " + relPath + ": expected=" + expectedHash + ", actual=" + tmpHash;
}
var wasNew = !destFile.exists();
replaceFile(tmpFile, destFile);
saveTrustedSha(relPath, expectedHash);
return { updated: true, isNew: wasNew, size: size, hash: tmpHash };
}
function ensureLocalTrustedModule(relPath, destFile) {
if (!destFile.exists()) throw "安全清单不可用且本地模块不存在: " + relPath;
var trustedHash = getTrustedSha(relPath);
var actualHash = sha256File(destFile.getAbsolutePath());
if (!trustedHash || !actualHash || String(trustedHash).toLowerCase() !== String(actualHash).toLowerCase()) {
throw "安全清单不可用,本地模块也无可信 SHA256: " + relPath;
}
return { updated: false, size: destFile.length(), hash: actualHash };
}
function loadScript(relPath) {
try {
var dir = ensureCodeDir();
var f = new java.io.File(dir, relPath);
var result;
if (__trustedManifest) result = ensureVerifiedModule(relPath, f);
else result = ensureLocalTrustedModule(relPath, f);
if (result.updated) {
__moduleUpdates.push({ module: relPath, isNew: !!result.isNew, size: result.size });
writeLog("Verified update " + relPath + " (" + result.size + " bytes, sha256=" + result.hash + ")");
}
var fileSize = f.length();
if (fileSize > 200 * 1024) writeLog("WARN: " + relPath + " is " + (fileSize / 1024) + "KB, consider splitting");
var code = readTextFile(f.getAbsolutePath());
if (code === null) throw "read failed: " + f.getAbsolutePath();
var geval = eval;
geval(String(code));
} catch(e) {
var errMsg = "loadScript(" + relPath + ") failed: " + e;
try { android.util.Log.e("ToolHub", errMsg); } catch(eLog) {}
throw errMsg;
}
}
var modules = ["th_01_base.js", "th_02_core.js", "th_03_icon.js", "th_04_theme.js", "th_05_persistence.js",
"th_06_icon_parser.js", "th_07_shortcut.js", "th_08_content.js", "th_09_animation.js",
"th_10_shell.js", "th_11_action.js", "th_12_rebuild.js", "th_13_panel_ui.js",
"th_14_panels.js", "th_14_color_picker.js", "th_14_icon_picker.js", "th_15_extra.js", "th_16_entry.js"];
var __moduleUpdates = [];
var loadErrors = [];
var criticalModules = { "th_01_base.js": true, "th_16_entry.js": true };
fetchTrustedManifest();
for (var i = 0; i < modules.length; i++) {
try {
loadScript(modules[i]);
} catch (e) {
var modErr = "Module load failed: " + modules[i] + " -> " + String(e);
writeLog(modErr);
try { android.util.Log.e("ToolHub", modErr); } catch(eLog) {}
loadErrors.push({ module: modules[i], err: String(e) });
if (criticalModules[modules[i]]) throw "Critical module failed: " + modules[i] + " (" + String(e) + ")";
}
}
if (__trustedManifest && loadErrors.length === 0) saveTrustedVersion(__trustedManifest.version);
var __out = (function() {
if (typeof getProcessInfo !== "function") {
return { ok: false, started: false, msg: "ToolHub 启动失败", securityMsg: __securityStatus.msg, err: "核心函数 getProcessInfo 未定义,请检查 th_01_base.js 是否加载成功" };
}
if (typeof ToolHubLogger !== "function") {
return { ok: false, started: false, msg: "ToolHub 启动失败", securityMsg: __securityStatus.msg, err: "核心类 ToolHubLogger 未定义,请检查 th_01_base.js 是否加载成功" };
}
if (typeof FloatBallAppWM !== "function") {
return { ok: false, started: false, msg: "ToolHub 启动失败", securityMsg: __securityStatus.msg, err: "核心类 FloatBallAppWM 未定义,请检查 th_02_core.js / th_16_entry.js 是否加载成功" };
}
function optStr(v) { return (v === undefined || v === null) ? "" : String(v); }
function summarizeModuleUpdates(list) {
var names = [];
var created = 0;
var overwritten = 0;
var i;
for (i = 0; i < list.length; i++) {
var item = list[i] || {};
var name = optStr(item.module);
if (name) names.push(name);
if (item.isNew) created++; else overwritten++;
}
if (names.length === 0) return { count: 0, modules: [], msg: "子模块已是最新,本次未覆盖更新。" };
return { count: names.length, modules: names, msg: "本次已通过签名校验并覆盖更新 " + names.length + " 个子模块(新增 " + created + " / 覆盖 " + overwritten + "" + names.join("、") };
}
function summarizeLoadErrors(list) {
var names = [];
var i;
for (i = 0; i < list.length; i++) {
var item = list[i] || {};
var name = optStr(item.module);
if (name) names.push(name);
}
return { count: names.length, modules: names, msg: names.length ? ("有 " + names.length + " 个子模块加载失败:" + names.join("、")) : "所有子模块加载正常。" };
}
var entryInfo = getProcessInfo("entry");
var logger = new ToolHubLogger(entryInfo);
installCrashHandler(logger);
var app = new FloatBallAppWM(logger);
var closeRule = String(app.config.ACTION_CLOSE_ALL_RULE || "shortx.wm.floatball.CLOSE");
var startRet = null;
try { startRet = app.startAsync(entryInfo, closeRule); }
catch (eTop) {
try { logger.fatal("TOP startAsync crash err=" + String(eTop)); } catch(eLog) {}
startRet = { ok: false, err: String(eTop) };
}
var syncInfo = summarizeModuleUpdates(__moduleUpdates);
var loadInfo = summarizeLoadErrors(loadErrors);
var started = !!(startRet && startRet.ok);
var layoutObj = startRet && startRet.layout || null;
var layoutText = layoutObj ? (String(layoutObj.cols || "?") + "×" + String(layoutObj.rows || "?")) : "未知";
var securityText = __securityStatus.ok
? ("✓ 已验签 v" + String(__securityStatus.version || 0) + " / " + optStr(__securityStatus.keyId))
: ("✗ " + optStr(__securityStatus.msg));
var syncText = syncInfo.count > 0
? ("✓ 已更新 " + syncInfo.count + " 个模块:" + syncInfo.modules.join("、"))
: "✓ 子模块已是最新";
var out = {
ok: started,
状态: started ? "ToolHub 启动成功" : "ToolHub 启动失败",
安全: securityText,
同步: syncText,
布局: layoutText,
关闭广播: optStr(startRet && startRet.closeAction)
};
if (syncInfo.count > 0) out.更新模块 = syncInfo.modules;
if (loadInfo.count > 0) out.加载异常 = loadInfo.modules;
if (!started) out.错误 = optStr(startRet && startRet.err) || (loadInfo.modules && loadInfo.modules.join(", ")) || "未知错误";
return out;
})();
JSON.stringify(__out, null, 2);