362 lines
11 KiB
TypeScript
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, "&")
|
|
.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`);
|
|
}
|