Files
openless/openless-all/app/scripts/collect-android-split-apks.mjs
06db7d2978 chore(release): integrate reviewed changes for Beta 3 build 20260925
Integrate Android CI acceleration, modifier-only dictation chords,
Requesty, and remote-input generation guards over the verified Beta 2
baseline and macOS CI improvements. Fix review findings in signing
configuration caching, APK validation, and modifier arbitration/recording.
Keep Linux egui and its release workflows identical to Beta 2.

Co-authored-by: HKLHaoBin <we3q@qq.com>
Co-authored-by: leolulu <35873316+leolulu@users.noreply.github.com>
Co-authored-by: Thibault Jaigu <thibault.jaigu@gmail.com>
Co-authored-by: Ethan <257484394+EthanYoQ@users.noreply.github.com>
2026-09-25 23:52:47 +08:00

203 lines
6.3 KiB
JavaScript

/**
* Collect split-per-ABI APKs from gen/android outputs.
*
* Env:
* OPENLESS_APK_MODE debug|release
* OPENLESS_APK_LABEL artifact label suffix
* OPENLESS_EXPECTED_GRADLE_ABIS comma-separated Gradle ABI folders (required)
* RUNNER_TEMP output parent (required in CI)
* GITHUB_OUTPUT optional; writes paths when set
*/
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join, relative } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { entryForGradleAbi } from './android-abi-matrix.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const KNOWN_ABIS = new Set(['arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86']);
function walkApks(root) {
const out = [];
if (!existsSync(root)) return out;
const stack = [root];
while (stack.length) {
const dir = stack.pop();
for (const name of readdirSync(dir)) {
const path = join(dir, name);
const st = statSync(path);
if (st.isDirectory()) {
stack.push(path);
} else if (name.endsWith('.apk')) {
out.push(path);
}
}
}
return out.sort();
}
function listApkAbis(apkPath) {
// Use the same standard ZIP reader as the original workflow. Scanning for
// central-directory magic accepts truncated archives and bytes inside entries.
const script = `
import json, sys, zipfile
with zipfile.ZipFile(sys.argv[1]) as apk:
bad = apk.testzip()
if bad is not None:
raise ValueError("CRC failure in " + bad)
print(json.dumps(sorted({name.split('/')[1] for name in apk.namelist()
if name.startswith('lib/') and len(name.split('/')) >= 3})))
`;
const commands = process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python'];
for (const command of commands) {
const result = spawnSync(command, ['-c', script, apkPath], {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
});
if (result.error?.code === 'ENOENT') continue;
if (result.error || result.status !== 0) {
throw new Error(`Invalid APK ZIP ${apkPath}: ${result.error?.message || result.stderr}`);
}
const abis = JSON.parse(result.stdout);
for (const abi of abis) {
if (!KNOWN_ABIS.has(abi)) throw new Error(`Unknown ABI ${abi} in ${apkPath}`);
}
return abis;
}
throw new Error('Python 3 is required to validate APK ZIP contents');
}
export function collectSplitApks({
mode,
label,
expectedGradleAbis,
androidRoot = join(appRoot, 'src-tauri/gen/android'),
outDir,
version,
} = {}) {
if (!mode || !label) {
throw new Error('mode and label are required');
}
if (!expectedGradleAbis?.length) {
throw new Error('expectedGradleAbis must be a non-empty array');
}
if (!outDir) {
throw new Error('outDir is required');
}
if (!version) {
throw new Error('version is required');
}
mkdirSync(outDir, { recursive: true });
const candidates = walkApks(androidRoot).filter((apk) =>
apk.replace(/\\/g, '/').includes('/outputs/'),
);
if (candidates.length === 0) {
const all = walkApks(androidRoot);
const hint = all.length ? all.map((p) => relative(androidRoot, p)).join('\n') : '(none)';
throw new Error(`No APK found under ${androidRoot}/**/outputs/\nOther APKs:\n${hint}`);
}
const expected = new Set(expectedGradleAbis);
const found = new Map();
for (const apk of candidates) {
const abis = listApkAbis(apk);
if (abis.length !== 1) {
throw new Error(
`${apk} contains ABI directories [${abis.join(', ')}]; expected exactly one ABI per APK`,
);
}
const abi = abis[0];
if (!expected.has(abi)) {
// Ignore extras from previous local builds; only enforce expected set.
console.warn(`Skipping unexpected ABI ${abi} from ${apk}`);
continue;
}
if (found.has(abi)) {
throw new Error(`Duplicate APKs for ABI ${abi}: ${found.get(abi)} and ${apk}`);
}
const destName =
mode === 'release'
? `OpenLess_${version}_${abi}.apk`
: `OpenLess-android-debug-${abi}-${label}.apk`;
const dest = join(outDir, destName);
copyFileSync(apk, dest);
found.set(abi, dest);
console.log(`Collected ${abi}: ${apk} -> ${dest}`);
}
const missing = [...expected].filter((abi) => !found.has(abi)).sort();
if (missing.length) {
throw new Error(`Missing split APKs for ABI(s): ${missing.join(', ')}`);
}
const releaseFiles = [...expected].map((abi) => found.get(abi));
const outputs = {};
for (const abi of expected) {
const entry = entryForGradleAbi(abi);
outputs[`${entry.outputKey}_path`] = found.get(abi);
outputs[`${entry.outputKey}_arch`] = entry.abi;
}
outputs.out_dir = outDir;
outputs.release_files = releaseFiles.join('\n');
// Single-ABI CI jobs consume these stable keys.
if (expected.size === 1) {
const onlyAbi = [...expected][0];
const entry = entryForGradleAbi(onlyAbi);
outputs.apk_path = found.get(onlyAbi);
outputs.gradle_abi = onlyAbi;
outputs.cli_abi = entry.abi;
}
return { found, outputs, releaseFiles };
}
function main() {
const mode = process.env.OPENLESS_APK_MODE;
const label = process.env.OPENLESS_APK_LABEL;
const expectedRaw = process.env.OPENLESS_EXPECTED_GRADLE_ABIS || '';
const expectedGradleAbis = expectedRaw
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
const runnerTemp = process.env.RUNNER_TEMP;
if (!runnerTemp) {
throw new Error('RUNNER_TEMP is required');
}
const version = JSON.parse(readFileSync(join(appRoot, 'package.json'), 'utf8')).version;
const outDir = join(runnerTemp, `openless-android-${mode}-split`);
const { outputs } = collectSplitApks({
mode,
label,
expectedGradleAbis,
outDir,
version,
});
const githubOutput = process.env.GITHUB_OUTPUT;
if (githubOutput) {
const lines = [];
for (const [key, value] of Object.entries(outputs)) {
if (key === 'release_files') {
lines.push(`${key}<<EOF`, value, 'EOF');
} else {
lines.push(`${key}=${value}`);
}
}
writeFileSync(githubOutput, `${lines.join('\n')}\n`, { flag: 'a' });
}
}
if (process.argv[1]?.replace(/\\/g, '/').endsWith('collect-android-split-apks.mjs')) {
main();
}