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(); 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/", ); } 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(); 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 { return Object.fromEntries( fs .readFileSync(file, "utf8") .split("\n") .filter(Boolean) .map((line) => line.split("=", 2) as [string, string]), ); } function releaseSigning(javaHome: string): Record { 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]", );