531 lines
16 KiB
TypeScript
531 lines
16 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
export interface SmaliPatch {
|
|
targetFile: string;
|
|
search?: string;
|
|
replace?: string;
|
|
injectFile?: string;
|
|
}
|
|
|
|
interface TranslationConfig {
|
|
backend?: "argos" | "libre";
|
|
source?: string;
|
|
target?: string;
|
|
url?: string;
|
|
apiKeyEnv?: string;
|
|
}
|
|
|
|
interface XmlPatch {
|
|
targetFile: string;
|
|
search: string;
|
|
replace: string;
|
|
}
|
|
|
|
interface ResourceConfig {
|
|
translation?: TranslationConfig;
|
|
overlay?: string;
|
|
assetOverlay?: string;
|
|
xmlPatches?: XmlPatch[];
|
|
}
|
|
|
|
interface GeneratedSmaliConfig {
|
|
sourceDir: string;
|
|
}
|
|
|
|
export interface PatchConfig {
|
|
manifest: {
|
|
packagePrefix: string;
|
|
ordinarySignature?: boolean;
|
|
};
|
|
resources?: ResourceConfig;
|
|
generatedSmali?: GeneratedSmaliConfig;
|
|
smaliPatches?: SmaliPatch[];
|
|
}
|
|
|
|
const cjk = /[\u3400-\u9fff]/;
|
|
const preferredTranslations: Readonly<Record<string, string>> = {
|
|
" 搜索...": "Search",
|
|
按修改时间: "Modified",
|
|
按名称: "Name",
|
|
"背景:": "Background",
|
|
全部: "All",
|
|
书架: "Library",
|
|
关闭: "Close",
|
|
取消: "Cancel",
|
|
"加载中...": "Loading",
|
|
去设置WLAN: "Wi-Fi settings",
|
|
同步: "Sync",
|
|
删除: "Delete",
|
|
更新: "Update",
|
|
清空: "Clear",
|
|
测试: "Test",
|
|
标题: "Title",
|
|
搜索历史: "Search history",
|
|
"搜索...": "Search",
|
|
暂无数据: "No items",
|
|
暂无小部件: "No widgets",
|
|
暂无文件: "No files",
|
|
暂无搜索结果: "No results",
|
|
没有网络: "No network",
|
|
新增首页小部件: "Add home widget",
|
|
编辑首页小部件: "Edit home widget",
|
|
添加小部件: "Add widget",
|
|
点击添加小部件: "Add widget",
|
|
点击新建笔记: "New note",
|
|
笔记: "Notes",
|
|
笔记名称: "Note name",
|
|
笔记更新: "Notes update",
|
|
修改名称: "Rename",
|
|
重命名: "Rename",
|
|
知道了: "Done",
|
|
所有笔记加密密码相同: "Use one password for all notes",
|
|
关闭密码: "Disable password",
|
|
继续: "Continue",
|
|
"请输入4~8位数字": "Enter 4 to 8 digits",
|
|
"共 8 项": "8 items",
|
|
"录音文件名称录...": "Recording name",
|
|
"请按名称或主题关键词搜索书籍,笔记": "Search books and notes",
|
|
支持搜索多看的在线书籍: "Search online books",
|
|
请输入书名或作者名称关键词: "Search by title or author",
|
|
阅读数据: "Reading data",
|
|
首页: "Home",
|
|
优化配置: "Optimize",
|
|
应用: "Apps",
|
|
卸载: "Uninstall",
|
|
请使用手机扫描查看: "Scan with your phone",
|
|
阅读分享: "Share reading",
|
|
打开多看: "Open Duokan",
|
|
"将PDF文档放至Books或者Download文件夹内\n就能在此显示哦~":
|
|
"Put PDF files in Books or Download to see them here",
|
|
最近浏览: "Recent",
|
|
本地PDF: "Local PDFs",
|
|
多看: "Duokan",
|
|
今日阅读: "Reading today",
|
|
全网排行: "Rankings",
|
|
全网阅读数据: "Reading statistics",
|
|
分钟: "minutes",
|
|
名: "rank",
|
|
"点击登录系统账号,开始统计": "Sign in to track reading",
|
|
目标完成: "Goal reached",
|
|
近七天阅读: "Last 7 days",
|
|
查看最近文档: "Recent documents",
|
|
"作者:": "Author",
|
|
加入书架: "Add to library",
|
|
已在书架: "In library",
|
|
文件夹: "Folder",
|
|
"移动到…./文件夹1/文件夹2/文件夹3/文件夹4/文件夹5/文件夹6/文件夹7/文件夹8/文件夹9/文件夹10":
|
|
"Move to folder",
|
|
};
|
|
const signaturePermissions = new Set([
|
|
"android.permission.CHANGE_CONFIGURATION",
|
|
"android.permission.DELETE_PACKAGES",
|
|
"android.permission.FORCE_STOP_PACKAGES",
|
|
"android.permission.INTERACT_ACROSS_USERS",
|
|
"android.permission.MOUNT_UNMOUNT_FILESYSTEM",
|
|
"android.permission.READ_LOGS",
|
|
"android.permission.WRITE_SECURE_SETTINGS",
|
|
]);
|
|
|
|
export function applyConfiguredTransforms(
|
|
workDir: string,
|
|
config: PatchConfig,
|
|
projectRoot: string,
|
|
): void {
|
|
const allowed = new Set([
|
|
"manifest",
|
|
"resources",
|
|
"generatedSmali",
|
|
"smaliPatches",
|
|
]);
|
|
const unsupported = Object.keys(config).filter((key) => !allowed.has(key));
|
|
if (unsupported.length)
|
|
throw new Error(
|
|
`Unsupported patch sections: ${unsupported.join(", ")}. Code patches must be Smali patches.`,
|
|
);
|
|
if (!config.manifest?.packagePrefix)
|
|
throw new Error("manifest.packagePrefix is required");
|
|
renameManifestPackage(
|
|
workDir,
|
|
config.manifest.packagePrefix,
|
|
Boolean(config.manifest.ordinarySignature),
|
|
);
|
|
if (config.resources?.translation)
|
|
createTranslatedStringOverlay(
|
|
workDir,
|
|
config.resources.translation,
|
|
projectRoot,
|
|
);
|
|
if (config.resources?.overlay)
|
|
applyResourceOverlay(workDir, config.resources.overlay, projectRoot);
|
|
if (config.resources?.assetOverlay)
|
|
applyAssetOverlay(workDir, config.resources.assetOverlay, projectRoot);
|
|
applyXmlPatches(workDir, config.resources?.xmlPatches ?? []);
|
|
applyGeneratedSmali(workDir, config.generatedSmali, projectRoot);
|
|
applySmaliPatches(workDir, config.smaliPatches ?? [], projectRoot);
|
|
}
|
|
|
|
function applyGeneratedSmali(
|
|
workDir: string,
|
|
config: GeneratedSmaliConfig | undefined,
|
|
projectRoot: string,
|
|
): void {
|
|
if (!config) return;
|
|
if (
|
|
path.isAbsolute(config.sourceDir) ||
|
|
config.sourceDir.split(/[\\/]/).includes("..")
|
|
)
|
|
throw new Error("Generated Smali source must remain inside the repository");
|
|
const source = path.join(projectRoot, config.sourceDir);
|
|
if (!fs.existsSync(source))
|
|
throw new Error(`Generated Smali source not found: ${config.sourceDir}`);
|
|
const destination = path.join(
|
|
workDir,
|
|
"smali",
|
|
"rip",
|
|
"crit",
|
|
"ms",
|
|
"moannotes",
|
|
"nativebridge",
|
|
);
|
|
fs.mkdirSync(destination, { recursive: true });
|
|
fs.cpSync(source, destination, { recursive: true, force: true });
|
|
console.log(` -> Applied generated Smali ${config.sourceDir}`);
|
|
}
|
|
|
|
function applyAssetOverlay(
|
|
workDir: string,
|
|
overlay: string,
|
|
projectRoot: string,
|
|
): void {
|
|
if (path.isAbsolute(overlay) || overlay.split(/[\\/]/).includes(".."))
|
|
throw new Error("Asset overlay path must remain inside the repository");
|
|
const source = path.join(projectRoot, overlay);
|
|
if (!fs.existsSync(source))
|
|
throw new Error(`Asset overlay not found: ${overlay}`);
|
|
fs.cpSync(source, path.join(workDir, "assets"), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
console.log(` -> Applied asset overlay ${overlay}`);
|
|
}
|
|
|
|
function applyResourceOverlay(
|
|
workDir: string,
|
|
overlay: string,
|
|
projectRoot: string,
|
|
): void {
|
|
if (path.isAbsolute(overlay) || overlay.split(/[\\/]/).includes(".."))
|
|
throw new Error("Resource overlay path must remain inside the repository");
|
|
const source = path.join(projectRoot, overlay);
|
|
if (!fs.existsSync(source))
|
|
throw new Error(`Resource overlay not found: ${overlay}`);
|
|
fs.cpSync(source, path.join(workDir, "res"), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
console.log(` -> Applied resource overlay ${overlay}`);
|
|
}
|
|
|
|
function applyXmlPatches(workDir: string, patches: XmlPatch[]): void {
|
|
for (const [index, patch] of patches.entries()) {
|
|
if (
|
|
path.isAbsolute(patch.targetFile) ||
|
|
patch.targetFile.split(/[\\/]/).includes("..") ||
|
|
!patch.targetFile.startsWith("res/")
|
|
)
|
|
throw new Error(`XML patch ${index + 1} has an unsafe target path`);
|
|
const target = path.join(workDir, patch.targetFile);
|
|
if (!fs.existsSync(target))
|
|
throw new Error(
|
|
`XML patch ${index + 1} target is missing: ${patch.targetFile}`,
|
|
);
|
|
const source = fs.readFileSync(target, "utf8");
|
|
const count = source.split(patch.search).length - 1;
|
|
if (count !== 1)
|
|
throw new Error(
|
|
`XML patch ${index + 1} expected one match, found ${count}`,
|
|
);
|
|
fs.writeFileSync(target, source.replace(patch.search, patch.replace));
|
|
}
|
|
if (patches.length) console.log(` -> Applied ${patches.length} XML patches`);
|
|
}
|
|
|
|
function renameManifestPackage(
|
|
workDir: string,
|
|
prefix: string,
|
|
ordinarySignature: boolean,
|
|
): void {
|
|
const manifestPath = path.join(workDir, "AndroidManifest.xml");
|
|
let xml = fs.readFileSync(manifestPath, "utf8");
|
|
const match = xml.match(/\bpackage="([^"]+)"/);
|
|
if (!match) throw new Error("Manifest package attribute was not found");
|
|
const original = match[1]!;
|
|
// A prefix identical to (or already containing) the original package
|
|
// means "keep the stock package": the tablet breaks when the installer
|
|
// namespace changes, so never double-prefix (com.moan.notes must stay
|
|
// com.moan.notes).
|
|
const renamed =
|
|
original === prefix || original.startsWith(`${prefix}.`)
|
|
? original
|
|
: `${prefix}.${original}`;
|
|
xml = xml.replace(`package="${original}"`, `package="${renamed}"`);
|
|
// Provider authorities belong to the installation namespace, not the
|
|
// signing namespace. A renamed package must always receive a renamed
|
|
// authority or Android rejects it while the OEM package is still present.
|
|
xml = xml.replaceAll(
|
|
`android:authorities="${original}`,
|
|
`android:authorities="${renamed}`,
|
|
);
|
|
if (ordinarySignature) {
|
|
xml = xml.replace(/\s+android:sharedUserId="[^"]+"/, "");
|
|
xml = xml.replace(
|
|
/<uses-permission android:name="([^"]+)"\s*\/>/g,
|
|
(element, permission: string) =>
|
|
signaturePermissions.has(permission) ? "" : element,
|
|
);
|
|
console.log(" -> Removed shared system UID and signature-only permissions");
|
|
}
|
|
fs.writeFileSync(manifestPath, xml);
|
|
const apktoolPath = path.join(workDir, "apktool.yml");
|
|
if (fs.existsSync(apktoolPath)) {
|
|
const yaml = fs
|
|
.readFileSync(apktoolPath, "utf8")
|
|
.replace(
|
|
/renameManifestPackage:.*$/m,
|
|
`renameManifestPackage: ${renamed}`,
|
|
);
|
|
fs.writeFileSync(apktoolPath, yaml);
|
|
}
|
|
console.log(` -> Manifest package ${original} renamed to ${renamed}`);
|
|
}
|
|
|
|
function createTranslatedStringOverlay(
|
|
workDir: string,
|
|
config: TranslationConfig,
|
|
projectRoot: string,
|
|
): void {
|
|
const sourcePath = path.join(workDir, "res", "values", "strings.xml");
|
|
if (!fs.existsSync(sourcePath)) return;
|
|
const source = fs.readFileSync(sourcePath, "utf8");
|
|
const entries: Array<{ name: string; attributes: string; value: string }> =
|
|
[];
|
|
const pattern = /<string\s+name="([^"]+)"([^>]*)>([\s\S]*?)<\/string>/g;
|
|
for (const match of source.matchAll(pattern)) {
|
|
if (cjk.test(match[3]!))
|
|
entries.push({
|
|
name: match[1]!,
|
|
attributes: match[2]!,
|
|
value: match[3]!,
|
|
});
|
|
}
|
|
if (entries.length) {
|
|
const translations = translateTexts(
|
|
entries.map((entry) => entry.value),
|
|
config,
|
|
projectRoot,
|
|
);
|
|
const strings = entries.map(
|
|
(entry, index) =>
|
|
` <string name="${entry.name}"${entry.attributes}>${escapeXmlText(translations[index]!)}</string>`,
|
|
);
|
|
const target = config.target ?? "en";
|
|
const destination = path.join(
|
|
workDir,
|
|
"res",
|
|
`values-${target}`,
|
|
"strings.xml",
|
|
);
|
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
fs.writeFileSync(
|
|
destination,
|
|
`<?xml version="1.0" encoding="utf-8"?>\n<resources>\n${strings.join("\n")}\n</resources>\n`,
|
|
);
|
|
console.log(
|
|
` -> Added ${strings.length} ${target} translations through ${config.backend ?? "argos"}`,
|
|
);
|
|
}
|
|
createTranslatedLayoutOverlays(workDir, config, projectRoot);
|
|
}
|
|
|
|
function translateTexts(
|
|
texts: string[],
|
|
config: TranslationConfig,
|
|
projectRoot: string,
|
|
): string[] {
|
|
if (!texts.length) return [];
|
|
const translations = texts.map((text) => preferredTranslations[text]);
|
|
const unresolved = texts
|
|
.map((text, index) => ({ text, index }))
|
|
.filter(({ index }) => translations[index] === undefined);
|
|
if (!unresolved.length) return translations as string[];
|
|
const apiKey = config.apiKeyEnv ? process.env[config.apiKeyEnv] : undefined;
|
|
const request = JSON.stringify({
|
|
texts: unresolved.map(({ text }) => text),
|
|
backend: config.backend ?? "argos",
|
|
source: config.source ?? "zh",
|
|
target: config.target ?? "en",
|
|
url: config.url,
|
|
apiKey,
|
|
});
|
|
const bridge = path.join(projectRoot, "translator", "bridge.py");
|
|
const localPython = path.join(
|
|
projectRoot,
|
|
".venv-translator",
|
|
"bin",
|
|
"python",
|
|
);
|
|
const python =
|
|
process.env.PYTHON ??
|
|
(fs.existsSync(localPython) ? localPython : "python3");
|
|
const result = spawnSync(python, [bridge], {
|
|
input: request,
|
|
encoding: "utf8",
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
env: {
|
|
...process.env,
|
|
ARGOS_PACKAGES_DIR:
|
|
process.env.ARGOS_PACKAGES_DIR ??
|
|
path.join(projectRoot, ".argos-packages"),
|
|
XDG_DATA_HOME: path.join(projectRoot, ".argos-data"),
|
|
XDG_CONFIG_HOME: path.join(projectRoot, ".argos-config"),
|
|
XDG_CACHE_HOME: path.join(projectRoot, ".argos-cache"),
|
|
},
|
|
});
|
|
if (result.status !== 0)
|
|
throw new Error(`Translation bridge failed: ${result.stderr.trim()}`);
|
|
const response = JSON.parse(result.stdout) as { translations: string[] };
|
|
if (
|
|
!Array.isArray(response.translations) ||
|
|
response.translations.length !== unresolved.length
|
|
)
|
|
throw new Error("Translation bridge returned an invalid result count");
|
|
for (const [index, translation] of response.translations.entries())
|
|
translations[unresolved[index]!.index] = translation;
|
|
return translations as string[];
|
|
}
|
|
|
|
function createTranslatedLayoutOverlays(
|
|
workDir: string,
|
|
config: TranslationConfig,
|
|
projectRoot: string,
|
|
): void {
|
|
const resourceRoot = path.join(workDir, "res");
|
|
const target = config.target ?? "en";
|
|
const attributePattern =
|
|
/\bandroid:(text|hint|contentDescription)="([^"]*)"/g;
|
|
const records: Array<{
|
|
destination: string;
|
|
content: string;
|
|
values: string[];
|
|
}> = [];
|
|
for (const directory of fs
|
|
.readdirSync(resourceRoot)
|
|
.filter((name) => /^layout(?:-.+)?$/.test(name))) {
|
|
const sourceDirectory = path.join(resourceRoot, directory);
|
|
for (const file of fs
|
|
.readdirSync(sourceDirectory)
|
|
.filter((name) => name.endsWith(".xml"))) {
|
|
const source = path.join(sourceDirectory, file);
|
|
const content = fs.readFileSync(source, "utf8");
|
|
const values = Array.from(content.matchAll(attributePattern))
|
|
.map((match) => match[2]!)
|
|
.filter((value) => cjk.test(value));
|
|
if (!values.length) continue;
|
|
const qualifier = directory.slice("layout".length);
|
|
records.push({
|
|
destination: path.join(
|
|
resourceRoot,
|
|
`layout-${target}${qualifier}`,
|
|
file,
|
|
),
|
|
content,
|
|
values,
|
|
});
|
|
}
|
|
}
|
|
if (!records.length) return;
|
|
const translations = translateTexts(
|
|
records.flatMap((record) => record.values),
|
|
config,
|
|
projectRoot,
|
|
);
|
|
let translationIndex = 0;
|
|
for (const record of records) {
|
|
const translated = record.content.replace(
|
|
attributePattern,
|
|
(match, attribute: string, value: string) => {
|
|
if (!cjk.test(value)) return match;
|
|
const text = translations[translationIndex++]!;
|
|
return `android:${attribute}="${escapeXmlAttribute(text)}"`;
|
|
},
|
|
);
|
|
fs.mkdirSync(path.dirname(record.destination), { recursive: true });
|
|
fs.writeFileSync(record.destination, translated);
|
|
}
|
|
console.log(` -> Added ${translationIndex} ${target} layout translations`);
|
|
}
|
|
|
|
function escapeXmlText(value: string): string {
|
|
return value
|
|
.replace(/&(?!#\d+;|#x[0-9A-Fa-f]+;|\w+;)/g, "&")
|
|
.replace(/<(?!\/?[A-Za-z][^>]*>)/g, "<")
|
|
.replace(/'/g, "\\'");
|
|
}
|
|
|
|
function escapeXmlAttribute(value: string): string {
|
|
return escapeXmlText(value).replaceAll('"', """);
|
|
}
|
|
|
|
function applySmaliPatches(
|
|
workDir: string,
|
|
patches: SmaliPatch[],
|
|
projectRoot: string,
|
|
): void {
|
|
const smaliDirs = fs
|
|
.readdirSync(workDir)
|
|
.filter((name) => /^smali(?:_classes\d+)?$/.test(name))
|
|
.sort();
|
|
for (const [index, patch] of patches.entries()) {
|
|
if (
|
|
path.isAbsolute(patch.targetFile) ||
|
|
patch.targetFile.split(/[\\/]/).includes("..")
|
|
)
|
|
throw new Error(`Smali patch ${index + 1} has an unsafe target path`);
|
|
if (!patch.targetFile.endsWith(".smali"))
|
|
throw new Error(`Smali patch ${index + 1} targets a non-Smali file`);
|
|
const existing = smaliDirs
|
|
.map((dir) => path.join(workDir, dir, patch.targetFile))
|
|
.find(fs.existsSync);
|
|
const target =
|
|
existing ?? path.join(workDir, smaliDirs[0] ?? "smali", patch.targetFile);
|
|
if (patch.injectFile) {
|
|
const source = path.resolve(projectRoot, patch.injectFile);
|
|
if (!source.endsWith(".smali") || !fs.existsSync(source))
|
|
throw new Error(
|
|
`Smali injection source not found for patch ${index + 1}`,
|
|
);
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.copyFileSync(source, target);
|
|
continue;
|
|
}
|
|
if (
|
|
patch.search === undefined ||
|
|
patch.replace === undefined ||
|
|
!fs.existsSync(target)
|
|
)
|
|
throw new Error(
|
|
`Smali patch ${index + 1} is incomplete or its target is missing`,
|
|
);
|
|
const content = fs.readFileSync(target, "utf8");
|
|
const occurrences = content.split(patch.search).length - 1;
|
|
if (occurrences !== 1)
|
|
throw new Error(
|
|
`Smali patch ${index + 1} expected one match in ${patch.targetFile}, found ${occurrences}`,
|
|
);
|
|
fs.writeFileSync(target, content.replace(patch.search, patch.replace));
|
|
}
|
|
if (patches.length)
|
|
console.log(` -> Applied ${patches.length} Smali patches`);
|
|
}
|