OpenMiInk/extractors/index.ts
Hellings 1e953b8132 ok
2026-10-16 13:05:27 -04:00

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, "&amp;")
.replace(/<(?!\/?[A-Za-z][^>]*>)/g, "&lt;")
.replace(/'/g, "\\'");
}
function escapeXmlAttribute(value: string): string {
return escapeXmlText(value).replaceAll('"', "&quot;");
}
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`);
}