OpenMiInk/extractors/index.ts
Hellings b8c8ff98e2 Hi
2026-10-16 09:05:56 -04:00

362 lines
11 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 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]!;
const renamed = original.startsWith(`${prefix}.`)
? original
: `${prefix}.${original}`;
xml = xml.replace(`package="${original}"`, `package="${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,
);
xml = xml.replaceAll(
`android:authorities="${original}`,
`android:authorities="${renamed}`,
);
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) return;
const apiKey = config.apiKeyEnv ? process.env[config.apiKeyEnv] : undefined;
const request = JSON.stringify({
texts: entries.map((entry) => entry.value),
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 !== entries.length
)
throw new Error("Translation bridge returned an invalid result count");
const strings = entries.map(
(entry, index) =>
` <string name="${entry.name}"${entry.attributes}>${escapeXmlText(response.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"}`,
);
}
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 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`);
}