import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import {fileURLToPath} from 'node:url'; import {workspace,run,sdk,sdkTool,javaEnv,writeJSON,skillDir} from './lib.mjs'; import {connect} from './device.mjs'; import {createWebProject} from './web-project.mjs'; import {music} from './music.mjs'; export const targets={companion:'dev.chloe.os',lockers:'dev.chloe.lincolngirlies.dungeon','after-hours':'dev.chloe.lincolngirlies.gravity'}; export function assertUpdate(expected, actualPackage, installedCert, newCert) { if(actualPackage!==expected)throw new Error(`APK package ${actualPackage} does not match ${expected}.`); if(installedCert&&installedCert!==newCert)throw new Error('Signing key differs from the installed app. Import the original owner keys; keep the installed app and its save.'); } function cert(root,file){ const out=run(sdkTool(root,'apksigner'),['verify','--print-certs',file],{env:javaEnv(root)}); const value=out.match(/Signer #1 certificate SHA-256 digest: ([a-fA-F0-9]+)/)?.[1]?.toLowerCase(); if(!value)throw new Error('Could not verify APK signing certificate.');return value; } export function validateTar(bytes){ if(!Buffer.isBuffer(bytes)||bytes.length<1024||bytes.length%512!==0||bytes.subarray(257,262).toString('ascii')!=='ustar')throw new Error('Android did not return a valid save archive; no save backup was recorded.'); return bytes; } export function backup(root,d=connect(root),packages=targets){ const dest=path.join(root,'backups',new Date().toISOString().replace(/[:.]/g,'-')); fs.mkdirSync(dest,{recursive:true});const record={at:new Date().toISOString(),items:[]}; for(const [name,pkg]of Object.entries(packages)){ try{const p=d.adb(['shell','pm','path',pkg]).trim().split('\n').find(s=>s.startsWith('package:'))?.slice(8);if(!p)throw new Error('Not installed');d.adb(['pull',p,path.join(dest,name+'.apk')]);record.items.push({name,apk:true});}catch(e){record.items.push({name,apk:false,detail:e.message});} try{ const available=d.adb(['shell','run-as',pkg,'ls']).split(/\s+/); const dirs=['files','shared_prefs','app_webview','databases','no_backup'].filter(p=>available.includes(p)); if(!dirs.length)throw new Error('No readable save folders.'); const bytes=validateTar(d.adb(['exec-out','run-as',pkg,'tar','-cf','-',...dirs],{encoding:null,stdio:['ignore','pipe','pipe']})); fs.writeFileSync(path.join(dest,name+'-data.tar'),bytes);record.items.push({name,privateSave:true,scope:dirs}); }catch(e){record.items.push({name,privateSave:false,detail:'Android run-as/save archive unavailable; private data was not copied.'});} } writeJSON(path.join(dest,'backup.json'),record);console.log(JSON.stringify({backup:dest,...record},null,2));return dest; } export function install(root,target,file){ const expected=targets[target]||(/^dev\.chloe\.creations\.[a-z][a-z0-9]*$/.test(target)?target:null); if(!expected)throw new Error('Unknown target. Use companion, lockers, after-hours or dev.chloe.creations.NAME.'); const apk=fs.realpathSync(file); const actual=run(sdkTool(root,'aapt'),['dump','badging',apk]).match(/package: name='([^']+)'/)?.[1]; const nextCert=cert(root,apk);assertUpdate(expected,actual,null,nextCert); const d=connect(root),exists=d.adb(['shell','pm','list','packages',expected]).split(/\r?\n/).includes('package:'+expected); const installed=exists?d.adb(['shell','pm','path',expected]).trim():''; if(installed.startsWith('package:')){ const dir=fs.mkdtempSync(path.join(os.tmpdir(),'chloe-sign-')); try{const prior=path.join(dir,'installed.apk');d.adb(['pull',installed.split('\n')[0].slice(8),prior]);assertUpdate(expected,actual,cert(root,prior),nextCert);}finally{fs.rmSync(dir,{recursive:true,force:true});} backup(root,d,{[target]:expected}); } const result=d.adb(['install','--no-incremental','-r',apk]); if(!result.includes('Success'))throw new Error('Android did not confirm install success: '+result); const receipt={target:expected,apk,sha256Certificate:nextCert,installed:true,at:new Date().toISOString()}; writeJSON(path.join(root,'last-install.json'),receipt);console.log(JSON.stringify(receipt,null,2)); } export function applyPalette(root,apply=false){ const file=path.join(root,'theme/palette.json'),p=JSON.parse(fs.readFileSync(file,'utf8')).shared; const fields={ink:'ink',panel:'panel',purple:'lavender',pink:'pink',white:'text'}; for(const key of ['ink','panel','lavender','pink','text','mutedText','border'])if(!/^#[a-fA-F0-9]{6}$/.test(p?.[key]||''))throw new Error('Invalid palette color: '+key); if(apply){const source=path.join(root,'companion/src/dev/chloe/os/MainActivity.java');let text=fs.readFileSync(source,'utf8'); for(const [field,key]of Object.entries(fields)){ const rgb=p[key].slice(1).match(/../g).map(v=>parseInt(v,16));const re=new RegExp('\\b'+field+'=Color\\.rgb\\([^)]+\\)'); if(!re.test(text))throw new Error('Cannot locate native color '+field);text=text.replace(re,field+'=Color.rgb('+rgb.join(',')+')'); } fs.copyFileSync(source,source+'.before-palette');fs.writeFileSync(source,text); } console.log(JSON.stringify({palette:file,valid:true,applied:apply,scope:'MainActivity named colors; wallpapers, cassette and widget are separate'},null,2)); } export function build(root,target){ const env=javaEnv(root),games=path.join(root,'games/lincoln-girlies'); if(target==='companion')run('bash',['build.sh'],{cwd:path.join(root,'companion'),env,stdio:'inherit'}); else if(target==='lockers')run('./gradlew',['android:assembleDebug','--console=plain'],{cwd:path.join(games,'shattered'),env,stdio:'inherit'}); else if(target==='after-hours'){ const exe=process.env.GODOT3||path.join(games,'tools/godot3/Godot.app/Contents/MacOS/Godot'); if(!fs.existsSync(exe))throw new Error('Set GODOT3 to Godot 3.6.1; install matching Android export templates and configure its SDK/debug key.'); const project=path.join(games,'rota'),preset=path.join(project,'export_presets.cfg'),original=fs.readFileSync(preset,'utf8'); const templates=process.env.GODOT3_TEMPLATES||path.join(games,'tools/templates'); let portable=original.replace(/custom_template\/(debug|release)="[^"]*"/g,(_,kind)=>`custom_template/${kind}="${fs.existsSync(path.join(templates,'android_'+kind+'.apk'))?path.join(templates,'android_'+kind+'.apk'):''}"`); portable=portable.replace('one_click_deploy/clear_previous_install=true','one_click_deploy/clear_previous_install=false'); fs.mkdirSync(path.join(games,'dist'),{recursive:true}); try{fs.writeFileSync(preset,portable);run(exe,['--no-window','--audio-driver','Dummy','--editor','--path',project,'--export-debug','Android',path.join(games,'dist/LincolnGirlies-AfterHours.apk')],{env,stdio:'inherit'});}finally{fs.writeFileSync(preset,original);} }else throw new Error('Choose companion, lockers or after-hours.'); if(target==='lockers'){fs.mkdirSync(path.join(games,'dist'),{recursive:true});fs.copyFileSync(path.join(games,'shattered/android/build/outputs/apk/debug/android-debug.apk'),path.join(games,'dist/LincolnGirlies-BelowTheLockers-1.0.apk'));} } export async function main(args){ const [command,...rest]=args; if(!command||command==='help'){console.log('CHLOE//OS workshop\n doctor\n backup\n build companion|lockers|after-hours\n check lockers\n install TARGET APK\n palette [--apply]\n new-game SLUG\n import-web SLUG STATIC_FOLDER\n playlist preview INPUT OUTPUT | accounts | export JSPF --owner ID');return;} const root=workspace(); if(command==='doctor'){ const report={workspace:root,node:process.versions.node,sdk:sdk(root),tools:{},device:null}; for(const [name,args]of [['node',['--version']],['git',['--version']]])try{report.tools[name]=run(name,args,{stdio:['ignore','pipe','pipe']}).trim()||'available';}catch{report.tools[name]='missing / not on PATH';} try{report.tools.java=run(path.join(javaEnv(root).JAVA_HOME,'bin/java'),['--version']).trim();}catch(e){report.tools.java=e.message;} for(const version of ['35.0.0','36.0.0'])report.tools['build-tools '+version]=fs.existsSync(path.join(sdk(root),'build-tools',version)); for(const api of ['android-33','android-36'])report.tools[api]=fs.existsSync(path.join(sdk(root),'platforms',api,'android.jar')); try{report.device={model:'FiiO JM21',transport:connect(root).transport,authorized:true};}catch(e){report.device={authorized:false,detail:e.message};} console.log(JSON.stringify(report,null,2)); }else if(command==='backup')backup(root); else if(command==='build')build(root,rest[0]); else if(command==='install')install(root,rest[0],rest[1]); else if(command==='palette')applyPalette(root,rest.includes('--apply')); else if(command==='new-game'||command==='import-web')createWebProject(root,rest[0],command==='import-web'?rest[1]:null); else if(command==='playlist')await music(root,rest); else if(command==='check'&&rest[0]==='lockers'){ const game=path.join(root,'games/lincoln-girlies'),base='com/shatteredpixel/shatteredpixeldungeon/lincoln/'; const classes=path.join(game,'tests/classes');fs.mkdirSync(classes,{recursive:true}); run(path.join(javaEnv(root).JAVA_HOME,'bin/javac'),['-d',classes,path.join(game,'shattered/core/src/main/java',base,'CampaignState.java'),path.join(game,'shattered/core/src/main/java',base,'SchoolBlueprint.java'),path.join(game,'tests/CampaignRulesTest.java')],{stdio:'inherit'}); run(path.join(javaEnv(root).JAVA_HOME,'bin/java'),['-cp',classes,'CampaignRulesTest'],{stdio:'inherit'}); run('./gradlew',['core:verifyLincolnCampaign','--console=plain'],{cwd:path.join(game,'shattered'),env:javaEnv(root),stdio:'inherit'}); }else throw new Error('Unknown command. Run chloe.mjs help.'); } if(process.argv[1]&&fs.realpathSync(process.argv[1])===fileURLToPath(import.meta.url)){main(process.argv.slice(2)).catch(e=>{console.error(e.message);process.exitCode=1;});}