OpenMiInk/scripts/krita-unified-build.ts
Hellings b8c8ff98e2 Hi
2026-10-16 09:05:56 -04:00

355 lines
10 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
const root = path.resolve(import.meta.dir, "..");
const krita = path.join(root, "krita");
const deps = path.join(root, "third_party", "krita-deps-management");
const ciUtilities = path.join(root, "third_party", "krita-ci-utilities");
const kdeCiUtilities = path.join(root, "third_party", "kde-ci-utilities");
const notaryService = path.join(root, "third_party", "ci-notary-service");
const venv = path.join(root, ".venv-krita-build");
const python =
process.platform === "win32"
? path.join(venv, "Scripts", "python.exe")
: path.join(venv, "bin", "python");
function run(
command: string,
args: string[],
cwd = root,
env = process.env,
): void {
const result = spawnSync(command, args, {
cwd,
env,
stdio: "inherit",
});
if (result.error) throw result.error;
if (result.status !== 0)
throw new Error(`${command} ${args.join(" ")} failed`);
}
function requirePath(value: string | undefined, name: string): string {
if (!value)
throw new Error(`Set ${name} to the installed Android toolchain path`);
if (!fs.existsSync(value))
throw new Error(`${name} does not exist: ${value}`);
return path.resolve(value);
}
function resolveAndroidNdk(value: string | undefined, sdk: string): string {
const roots = [value, path.join(sdk, "ndk")].filter((item): item is string =>
Boolean(item),
);
const candidates = new Set<string>();
for (const root of roots) {
if (!fs.existsSync(root)) continue;
candidates.add(path.resolve(root));
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (entry.isDirectory())
candidates.add(path.join(path.resolve(root), entry.name));
}
}
for (const candidate of candidates) {
const toolchain = path.join(
candidate,
"build",
"cmake",
"android.toolchain.cmake",
);
const cpuFeatures = path.join(
candidate,
"sources",
"android",
"cpufeatures",
);
if (fs.existsSync(toolchain) && fs.existsSync(cpuFeatures))
return candidate;
}
throw new Error(
"Set ANDROID_NDK_ROOT to an NDK directory, or set ANDROID_SDK_ROOT to an SDK containing ndk/<version>",
);
}
function readJavaMajor(javaHome: string): number | undefined {
const release = path.join(javaHome, "release");
if (!fs.existsSync(release)) return undefined;
const match = fs.readFileSync(release, "utf8").match(/^JAVA_VERSION="(\d+)/m);
return match ? Number(match[1]) : undefined;
}
function resolveBuildJavaHome(): string {
const requested = process.env.KRITA_JAVA_HOME;
const roots = requested
? [requested]
: [
process.env.JAVA_HOME ?? "",
"/usr/lib/jvm/java-21-openjdk",
"/usr/lib/jvm/java-17-openjdk",
"/usr/lib/jvm",
];
const candidates = new Set<string>();
for (const root of roots) {
if (!root || !fs.existsSync(root)) continue;
candidates.add(path.resolve(root));
if (fs.statSync(root).isDirectory()) {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (entry.isDirectory())
candidates.add(path.join(path.resolve(root), entry.name));
}
}
}
for (const candidate of candidates) {
const major = readJavaMajor(candidate);
if (
major !== undefined &&
major >= 17 &&
major <= 24 &&
fs.existsSync(path.join(candidate, "bin", "java"))
) {
return candidate;
}
}
throw new Error(
"Install JDK 17 through 24, or set KRITA_JAVA_HOME to a supported JDK",
);
}
function readEnvironmentFile(file: string): Record<string, string> {
return Object.fromEntries(
fs
.readFileSync(file, "utf8")
.split("\n")
.filter(Boolean)
.map((line) => line.split("=", 2) as [string, string]),
);
}
function releaseSigning(javaHome: string): Record<string, string> {
const signingDir = path.join(root, "build", "signing");
const keystore = path.join(signingDir, "krita-eink-release.jks");
const environmentFile = path.join(signingDir, "krita-eink-release.env");
fs.mkdirSync(signingDir, { recursive: true, mode: 0o700 });
if (!fs.existsSync(keystore)) {
const password = crypto.randomBytes(32).toString("base64url");
const values = {
MISSTRING_RELEASE_STORE_FILE: keystore,
MISSTRING_RELEASE_STORE_PASSWORD: password,
MISSTRING_RELEASE_KEY_ALIAS: "krita-eink",
MISSTRING_RELEASE_KEY_PASSWORD: password,
};
run(path.join(javaHome, "bin", "keytool"), [
"-genkeypair",
"-keystore",
keystore,
"-storetype",
"PKCS12",
"-storepass",
password,
"-alias",
values.MISSTRING_RELEASE_KEY_ALIAS,
"-keypass",
password,
"-keyalg",
"RSA",
"-keysize",
"4096",
"-validity",
"36500",
"-dname",
"CN=MiString Krita E Ink,O=rip.crit.ms,C=US",
]);
fs.writeFileSync(
environmentFile,
`${Object.entries(values)
.map(([key, value]) => `${key}=${value}`)
.join("\n")}\n`,
{ mode: 0o600 },
);
console.log(`[ok] Created local release keystore at ${keystore}`);
}
if (!fs.existsSync(environmentFile))
throw new Error(`Missing signing credentials: ${environmentFile}`);
return readEnvironmentFile(environmentFile);
}
function removeManagedLink(
directory: string,
name: string,
target: string,
): void {
const link = path.join(directory, name);
if (!fs.existsSync(link) && !fs.lstatSync(link, { throwIfNoEntry: false }))
return;
const metadata = fs.lstatSync(link);
if (
!metadata.isSymbolicLink() ||
fs.realpathSync(link) !== fs.realpathSync(target)
) {
throw new Error(`${link} exists and is not a generated compatibility link`);
}
fs.unlinkSync(link);
}
function sync(): void {
run("git", ["submodule", "update", "--init", "--recursive"]);
for (const source of [
krita,
deps,
ciUtilities,
kdeCiUtilities,
notaryService,
]) {
if (!fs.existsSync(path.join(source, ".git"))) {
throw new Error(`Missing submodule checkout: ${source}`);
}
}
removeManagedLink(krita, "krita-deps-management", deps);
removeManagedLink(deps, "ci-utilities", ciUtilities);
removeManagedLink(krita, "upstream-ci-utilities", kdeCiUtilities);
removeManagedLink(krita, "ci-notary-service", notaryService);
run("bun", ["run", "kr"]);
console.log(
"[ok] Source submodules are initialized and the Xiaomi E Ink patch is applied",
);
}
function bootstrap(): void {
sync();
if (!fs.existsSync(python))
run("python3", ["-m", "venv", venv, "--upgrade-deps"]);
run(python, [
"-m",
"pip",
"install",
"-r",
path.join(deps, "requirements.txt"),
]);
console.log("[ok] Krita dependency manager Python environment is ready");
}
function generateDeps(): void {
if (!fs.existsSync(python)) {
throw new Error(
"Run `bun run krita:bootstrap` before generating the dependency graph",
);
}
const output = path.join(root, "build", "krita", ".kde-ci.generated.yml");
fs.mkdirSync(path.dirname(output), { recursive: true });
run(
python,
[
path.join(deps, "tools", "generate-deps-file.py"),
"-s",
path.join(deps, "latest", "krita-deps.yml"),
"-o",
output,
],
krita,
{ ...process.env, PYTHONDONTWRITEBYTECODE: "1" },
);
console.log(`[ok] Generated ${output}`);
}
function build(release = false): void {
sync();
generateDeps();
const sdk = requirePath(process.env.ANDROID_SDK_ROOT, "ANDROID_SDK_ROOT");
const ndk = resolveAndroidNdk(process.env.ANDROID_NDK_ROOT, sdk);
const javaHome = resolveBuildJavaHome();
const signing = release ? releaseSigning(javaHome) : {};
const localBuildRoot = path.join(root, "build", "krita");
const localCache =
process.env.KDECI_CACHE_PATH ?? path.join(localBuildRoot, "cache");
const localWork =
process.env.KDECI_WORKDIR_PATH ?? path.join(localBuildRoot, "work");
const localCcache =
process.env.KDECI_CC_CACHE ?? path.join(localBuildRoot, "ccache");
const xiaomiEink = process.env.KRITA_XIAOMI_EINK ?? "1";
const xiaomiEinkEnabled = ["1", "ON", "true", "TRUE"].includes(xiaomiEink);
for (const directory of [localCache, localWork, localCcache]) {
fs.mkdirSync(directory, { recursive: true });
}
const environment = {
...process.env,
KRITA_EINK_SLIM: "1",
KRITA_XIAOMI_EINK: xiaomiEink,
UNOTE_SOURCE_DIR: path.join(root, "unote", "src", "main", "java"),
UNOTE_PLUS_SOURCE_DIR: path.join(root, "unote-plus", "src", "main", "java"),
KRITACI_ANDROID_PACKAGE_TYPE: release ? "release" : "debug",
KRITACI_ANDROID_RELEASE_MODE: release ? "1" : "",
PYTHONDONTWRITEBYTECODE: "1",
JAVA_HOME: javaHome,
PATH: `${path.join(javaHome, "bin")}:${process.env.PATH ?? ""}`,
KDECI_REPO_METADATA_PATH: path.join(deps, "repo-metadata"),
KDECI_ANDROID_SDK_ROOT: sdk,
KDECI_ANDROID_NDK_ROOT: ndk,
KDECI_ANDROID_ABI: "arm64-v8a",
KDECI_BUILD_TYPE: "Release",
KDECI_SKIP_ECM_ANDROID_TOOLCHAIN: "True",
KDECI_CACHE_PATH: localCache,
KDECI_WORKDIR_PATH: localWork,
KDECI_CC_CACHE: localCcache,
KDECI_GITLAB_SERVER:
process.env.KDECI_GITLAB_SERVER ?? "https://invent.kde.org/",
KDECI_PACKAGE_PROJECT:
process.env.KDECI_PACKAGE_PROJECT ??
"teams/ci-artifacts/krita-android-arm64-v8a",
KDECI_COMPRESS_PACKAGES_ON_DOWNLOAD: "False",
...signing,
CMAKE_TOOLCHAIN_FILE: path.join(
deps,
"tools",
"android-toolchain-krita.cmake",
),
KDECI_EXTRA_CMAKE_ARGS: `-DKRITA_EINK_SLIM=ON -DKRITA_XIAOMI_EINK=${xiaomiEinkEnabled ? "ON" : "OFF"} -DBUILD_TESTING=OFF -DENABLE_UPDATERS=OFF`,
};
const sourceConfig = path.join(krita, ".kde-ci.yml");
const generatedConfig = path.join(
root,
"build",
"krita",
".kde-ci.generated.yml",
);
const originalConfig = fs.readFileSync(sourceConfig);
fs.copyFileSync(generatedConfig, sourceConfig);
try {
run(
python,
[
path.join(ciUtilities, "run-ci-build.py"),
"--project",
"krita",
"--branch",
"master",
"--platform",
"Android/arm64-v8a/Qt5/Shared",
"--only-build",
"--skip-publishing",
],
krita,
environment,
);
run(
python,
["build-tools/ci-scripts/build-android-package.py"],
krita,
environment,
);
} finally {
fs.writeFileSync(sourceConfig, originalConfig);
}
}
const command = process.argv[2] ?? "sync";
if (command === "sync") sync();
else if (command === "bootstrap") bootstrap();
else if (command === "deps") generateDeps();
else if (command === "build") build();
else if (command === "release") build(true);
else
throw new Error(
"Usage: krita-unified-build.ts [sync|bootstrap|deps|build|release]",
);