Files
note-gen/scripts/deploy-android.mjs

256 lines
9.2 KiB
JavaScript

import { spawn } from 'node:child_process'
import { existsSync, readdirSync, statSync } from 'node:fs'
import { resolve } from 'node:path'
const projectRoot = process.cwd()
const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const packageIdentifier = 'com.codexu.NoteGen'
const mainActivity = `${packageIdentifier}/.MainActivity`
const requestedDevice = process.env.ANDROID_DEVICE?.trim() || ''
const androidMinSdk = '24'
function resolveAdbCommand() {
if (process.env.ANDROID_ADB?.trim()) return process.env.ANDROID_ADB.trim()
const executable = process.platform === 'win32' ? 'adb.exe' : 'adb'
for (const sdkRoot of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT]) {
if (!sdkRoot?.trim()) continue
const candidate = resolve(sdkRoot, 'platform-tools', executable)
if (existsSync(candidate)) return candidate
}
return executable
}
const adbCommand = resolveAdbCommand()
function run(command, args, options = {}) {
return new Promise((resolvePromise, reject) => {
const child = spawn(command, args, {
cwd: projectRoot,
stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
env: options.env || process.env,
})
let stdout = ''
let stderr = ''
if (options.capture) {
child.stdout.on('data', chunk => { stdout += chunk })
child.stderr.on('data', chunk => { stderr += chunk })
}
child.on('error', error => {
const pathHint = command === adbCommand
? ' Ensure Android SDK platform-tools is on PATH, or set ANDROID_ADB to the adb executable.'
: ''
reject(new Error(`Failed to start ${command}.${pathHint} ${error.message}`))
})
child.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`${command} was stopped by signal ${signal}.`))
return
}
if (code !== 0) {
reject(new Error(`${command} exited with code ${code ?? 1}.${stderr.trim() ? `\n${stderr.trim()}` : ''}`))
return
}
resolvePromise(stdout.trim())
})
})
}
function resolveNdkRoot() {
const configuredRoots = [
process.env.ANDROID_NDK_ROOT,
process.env.ANDROID_NDK,
process.env.ANDROID_NDK_HOME,
process.env.NDK_HOME,
].map(path => path?.trim()).filter(Boolean)
for (const path of configuredRoots) {
if (existsSync(resolve(path, 'toolchains', 'llvm', 'prebuilt'))) return path
}
for (const sdkRoot of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT]) {
if (!sdkRoot?.trim()) continue
const ndkDirectory = resolve(sdkRoot, 'ndk')
if (!existsSync(ndkDirectory)) continue
const installed = readdirSync(ndkDirectory)
.map(name => resolve(ndkDirectory, name))
.filter(path => statSync(path).isDirectory())
.sort((left, right) => right.localeCompare(left, undefined, { numeric: true }))
const detected = installed.find(path => existsSync(resolve(path, 'toolchains', 'llvm', 'prebuilt')))
if (detected) return detected
}
throw new Error('Android NDK was not found. Install it with Android Studio, or set ANDROID_NDK_ROOT.')
}
function createAndroidBuildEnv(target) {
const ndkRoot = resolveNdkRoot()
const prebuiltRoot = resolve(ndkRoot, 'toolchains', 'llvm', 'prebuilt')
const hostDirectory = readdirSync(prebuiltRoot)
.map(name => resolve(prebuiltRoot, name))
.find(path => statSync(path).isDirectory())
if (!hostDirectory) throw new Error(`No NDK LLVM toolchain was found under ${prebuiltRoot}.`)
const binDirectory = resolve(hostDirectory, 'bin')
const targetTools = {
aarch64: {
cargoTriple: 'aarch64_linux_android',
compiler: `aarch64-linux-android${androidMinSdk}-clang`,
},
armv7: {
cargoTriple: 'armv7_linux_androideabi',
compiler: `armv7a-linux-androideabi${androidMinSdk}-clang`,
},
i686: {
cargoTriple: 'i686_linux_android',
compiler: `i686-linux-android${androidMinSdk}-clang`,
},
x86_64: {
cargoTriple: 'x86_64_linux_android',
compiler: `x86_64-linux-android${androidMinSdk}-clang`,
},
}
const tools = targetTools[target]
const compiler = resolve(binDirectory, tools.compiler)
const archiver = resolve(binDirectory, 'llvm-ar')
const ranlib = resolve(binDirectory, 'llvm-ranlib')
for (const path of [compiler, archiver, ranlib]) {
if (!existsSync(path)) throw new Error(`Required Android NDK tool was not found: ${path}`)
}
return {
...process.env,
ANDROID_NDK_ROOT: ndkRoot,
ANDROID_NDK: ndkRoot,
ANDROID_NDK_HOME: ndkRoot,
NDK_HOME: ndkRoot,
PATH: `${binDirectory}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH || ''}`,
[`CC_${tools.cargoTriple}`]: compiler,
[`AR_${tools.cargoTriple}`]: archiver,
[`RANLIB_${tools.cargoTriple}`]: ranlib,
}
}
async function ensureAndroidProject(buildEnv) {
const wrapperName = process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'
const wrapperPath = resolve(projectRoot, 'src-tauri', 'gen', 'android', wrapperName)
if (existsSync(wrapperPath)) return
console.log('[deploy:android] Android project is incomplete; initializing the Gradle wrapper...')
await run(pnpmCommand, [
'tauri',
'android',
'init',
'--ci',
'--skip-targets-install',
], { env: buildEnv })
if (!existsSync(wrapperPath)) {
throw new Error(`Tauri Android initialization completed, but ${wrapperPath} was not created.`)
}
}
async function adb(args, options) {
return await run(adbCommand, args, options)
}
async function selectDevice() {
const output = await adb(['devices', '-l'], { capture: true })
const devices = output.split(/\r?\n/).slice(1).map(line => line.trim()).filter(Boolean).map(line => {
const [serial, state = 'unknown'] = line.split(/\s+/, 3)
return { serial, state, line }
})
if (requestedDevice) {
const selected = devices.find(device => device.serial === requestedDevice)
if (!selected) throw new Error(`Android device "${requestedDevice}" was not found by adb.`)
if (selected.state !== 'device') {
throw new Error(`Android device "${requestedDevice}" is ${selected.state}; unlock it and authorize USB debugging.`)
}
return selected.serial
}
const ready = devices.filter(device => device.state === 'device')
if (ready.length === 0) {
const unauthorized = devices.find(device => device.state === 'unauthorized')
if (unauthorized) {
throw new Error(`Android device "${unauthorized.serial}" is unauthorized; unlock it and accept the USB debugging prompt.`)
}
throw new Error('No authorized Android device was found. Connect the phone and enable USB debugging.')
}
if (ready.length > 1) {
throw new Error(`Multiple Android devices are connected (${ready.map(device => device.serial).join(', ')}). Set ANDROID_DEVICE to the target serial.`)
}
return ready[0].serial
}
function tauriTargetForAbi(abi) {
const targets = {
'arm64-v8a': 'aarch64',
'armeabi-v7a': 'armv7',
x86: 'i686',
x86_64: 'x86_64',
}
const target = targets[abi]
if (!target) throw new Error(`Unsupported Android ABI "${abi}".`)
return target
}
function collectApks(directory) {
if (!existsSync(directory)) return []
return readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
const path = resolve(directory, entry.name)
if (entry.isDirectory()) return collectApks(path)
return entry.isFile() && entry.name.endsWith('.apk') ? [path] : []
})
}
function findBuiltApk(target) {
const outputRoot = resolve(projectRoot, 'src-tauri/gen/android/app/build/outputs/apk')
const candidates = collectApks(outputRoot)
.filter(path => path.toLowerCase().includes('debug'))
.filter(path => !path.toLowerCase().includes('androidtest'))
.sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs)
return candidates.find(path => path.includes(target)) || candidates[0]
}
try {
console.log('[deploy:android] Looking for an authorized Android phone...')
const serial = await selectDevice()
const [model, abi] = await Promise.all([
adb(['-s', serial, 'shell', 'getprop', 'ro.product.model'], { capture: true }),
adb(['-s', serial, 'shell', 'getprop', 'ro.product.cpu.abi'], { capture: true }),
])
const target = tauriTargetForAbi(abi)
const buildEnv = createAndroidBuildEnv(target)
console.log(`[deploy:android] Target: ${model || 'Android device'} (${serial}, ${abi}).`)
console.log(`[deploy:android] Android NDK: ${buildEnv.ANDROID_NDK_ROOT}.`)
await ensureAndroidProject(buildEnv)
console.log(`[deploy:android] Building the ${target} debug APK...`)
await run(pnpmCommand, [
'tauri',
'android',
'build',
'--debug',
'--apk',
'--target',
target,
], { env: buildEnv })
const apkPath = findBuiltApk(target)
if (!apkPath) throw new Error('The Android build completed, but a debug APK was not found.')
console.log(`[deploy:android] Installing ${apkPath}...`)
await adb(['-s', serial, 'install', '-r', '-d', apkPath])
console.log(`[deploy:android] Launching NoteGen on ${model || serial}...`)
await adb(['-s', serial, 'shell', 'am', 'start', '-S', '-n', mainActivity])
console.log(`[deploy:android] NoteGen was deployed to ${model || serial}.`)
} catch (error) {
console.error(`[deploy:android] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
}