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( //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 = /]*)>([\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) => ` ${escapeXmlText(response.translations[index]!)}`, ); 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, `\n\n${strings.join("\n")}\n\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, "&") .replace(/<(?!\/?[A-Za-z][^>]*>)/g, "<") .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`); }