文件
crab-input-rtos/com.sydxky.rtos.inputmethod/scripts/install.mjs
T

142 行
10 KiB
JavaScript

// The canonical component is maintained here; hosts consume generated copies.
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import {spawnSync} from 'node:child_process'
import {fileURLToPath} from 'node:url'
const APP=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..')
const RECEIPT='src/assets/crab-input-source.json'
const ROOTS=['keyboard','components/CrabInput','assets/ime-icons']
const NOTICES={'crab-keyboard-license.txt':'third_party/crabKeyboard/LICENSE','jieba-license.txt':'third_party/jieba/LICENSE','crab-input-license.txt':'LICENSE','crab-input-notice.txt':'NOTICE.md','remix-icon-license.txt':'third_party/remix-icon/LICENSE'}
const digest=bytes=>crypto.createHash('sha256').update(bytes).digest('hex')
const readJson=file=>JSON.parse(fs.readFileSync(file,'utf8'))
function dictionaryReady(source){
if(fs.existsSync(path.join(source,'src/assets/.ime-dict.generating')))throw Error('Dictionary generation is running; retry after it completes')
const converter=path.join(source,'scripts/convert-dictionary.py'),metaFile=path.join(source,'src/assets/ime-dict/meta.json')
if(!fs.existsSync(converter))return fs.existsSync(metaFile)
if(!fs.existsSync(metaFile)||!fs.existsSync(path.join(source,'src/assets/ime-dict/runtime.json')))return false
const meta=readJson(metaFile)
const seeds=path.join(source,'src/common/association-seeds.json')
if(fs.existsSync(seeds)&&meta.association_seed_sha256!==digest(fs.readFileSync(seeds)))return false
if(meta.generator_sha256!==digest(fs.readFileSync(converter)))return false
const rawDirectory=path.join(source,'third_party/crabKeyboard/pinyinDictV5_20260416')
if(JSON.stringify(fs.readdirSync(rawDirectory).filter(name=>name.endsWith('.dat')).sort())!==JSON.stringify(Object.keys(meta.source_files||{}).sort()))return false
for(const [name,hash]of Object.entries(meta.source_files||{})){
if(!/^key[0-9]+\.dat$|^value[0-9]+\.dat$/.test(name))throw Error('Invalid dictionary input filename')
const file=path.join(source,'third_party/crabKeyboard/pinyinDictV5_20260416',name)
if(!fs.existsSync(file)||digest(fs.readFileSync(file))!==hash)return false
}
const frequency=path.join(source,'third_party/jieba/dict.txt')
return fs.existsSync(frequency)&&digest(fs.readFileSync(frequency))===meta.t9_frequency_source?.sha256
}
function prepareDictionary(source){
if(dictionaryReady(source))return
const result=spawnSync('python',[path.join(source,'scripts/convert-dictionary.py')],{stdio:'inherit',windowsHide:true})
if(result.status!==0||!dictionaryReady(source))throw Error('Generate the canonical dictionary before synchronizing')
}
const managed=name=>/^src\/(keyboard\/|components\/CrabInput\/|common\/ime-[a-z0-9-]+\.js$|assets\/ime-(dict|icons)\/|assets\/(crab-keyboard|jieba|crab-input|remix-icon)-(license|notice)\.txt$)/.test(name)&&!name.split('/').some(part=>part==='..'||part==='.')&&!name.includes('\\')
function walk(directory){return fs.readdirSync(directory,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name)).flatMap(entry=>{
if(entry.isSymbolicLink())throw Error('Input source must not contain symbolic links')
return entry.isDirectory()?walk(path.join(directory,entry.name)).map(name=>entry.name+'/'+name):[entry.name]
})}
function includesRoute(target){
const file=path.join(target,'input-component.json')
if(!fs.existsSync(file))return true
const config=readJson(file)
if(config.schema!==1||!['component','route'].includes(config.integration))throw Error('Invalid input-component.json')
return config.integration==='route'
}
export function snapshot(source=APP,includeRoute=true){
if(!dictionaryReady(source))throw Error('Dictionary inputs changed; regenerate before checking')
const files={}
const add=(name,from)=>{const bytes=fs.readFileSync(from);files[name]={size:bytes.length,sha256:digest(bytes)}}
for(const root of ROOTS.filter(root=>includeRoute||root!=='keyboard'))for(const file of walk(path.join(source,'src',root)))add('src/'+root+'/'+file,path.join(source,'src',root,file))
for(const file of fs.readdirSync(path.join(source,'src/common')).filter(name=>/^ime-[a-z0-9-]+\.js$/.test(name)))add('src/common/'+file,path.join(source,'src/common',file))
const meta=readJson(path.join(source,'src/assets/ime-dict/meta.json'))
if(!Array.isArray(meta.shards)||!meta.shards.length||new Set(meta.shards).size!==meta.shards.length)throw Error('Dictionary is not generated or has invalid metadata')
const runtime=path.join(source,'src/assets/ime-dict/runtime.json')
if(fs.existsSync(runtime))add('src/assets/ime-dict/runtime.json',runtime)
for(const name of ['meta',...meta.shards,...(meta.association_shards||[]),...(meta.completion_shards||[])]){
if(name!=='meta'&&!/^(short|s?[0-9]+|ds?[0-9a-d]+|[ac][0-9a-f]{2})$/.test(name))throw Error('Invalid generated shard name')
add('src/assets/ime-dict/'+name+'.json',path.join(source,'src/assets/ime-dict',name+'.json'))
}
for(const [name,file]of Object.entries(NOTICES))add('src/assets/'+name,path.join(source,file))
const sorted=Object.fromEntries(Object.entries(files).sort(([a],[b])=>a.localeCompare(b)))
const body={schema:1,component:'CrabInput',apiVersion:1,integration:includeRoute?'route':'component',inputVersion:readJson(path.join(source,'src/manifest.json')).versionName,files:sorted}
if(!dictionaryReady(source))throw Error('Dictionary changed during snapshot')
return {...body,sourceHash:digest(JSON.stringify(body))}
}
function sourceFile(source,name){
if(NOTICES[path.posix.basename(name)])return path.join(source,NOTICES[path.posix.basename(name)])
return path.join(source,name)
}
function targetProject(target,source){
if(path.resolve(target)===path.resolve(source)||!fs.existsSync(path.join(target,'src/manifest.json')))throw Error('Target must be a separate Glyphix host project')
}
export function checkInput(target,source=APP){
targetProject(target,source)
const includeRoute=includesRoute(target),current=snapshot(source,includeRoute),receipt=path.join(target,RECEIPT)
if(!fs.existsSync(receipt)||JSON.stringify(readJson(receipt))!==JSON.stringify(current))throw Error('Input source changed; run install.mjs to synchronize before building')
for(const [name,info]of Object.entries(current.files)){
const file=path.join(target,name)
if(!fs.existsSync(file)||digest(fs.readFileSync(file))!==info.sha256)throw Error('Input host copy differs: '+name)
}
if(snapshot(source,includeRoute).sourceHash!==current.sourceHash)throw Error('Input source changed during verification; retry after edits settle')
return current
}
export function syncInput(target,source=APP){
targetProject(target,source)
prepareDictionary(source)
const includeRoute=includesRoute(target),current=snapshot(source,includeRoute),receipt=path.join(target,RECEIPT)
let previous=fs.existsSync(receipt)?readJson(receipt):null
const oldMeta=path.join(target,'src/assets/ime-dict/meta.json')
if(!previous&&fs.existsSync(oldMeta)){
const oldDictionary=readJson(oldMeta),oldShards=[...oldDictionary.shards,...(oldDictionary.association_shards||[]),...(oldDictionary.completion_shards||[])]
if(!Array.isArray(oldShards)||oldShards.some(name=>!/^(short|s?[0-9]+|ds?[0-9a-d]+|[ac][0-9a-f]{2})$/.test(name)))throw Error('Invalid previous dictionary metadata')
previous={files:Object.fromEntries(oldShards.map(name=>['src/assets/ime-dict/'+name+'.json',{}]))}
}
let copied=0,removed=0
// An interrupted copy must fail the build gate.
if(fs.existsSync(receipt))fs.unlinkSync(receipt)
for(const [name,info]of Object.entries(current.files)){
const destination=path.join(target,name)
if(fs.existsSync(destination)&&digest(fs.readFileSync(destination))===info.sha256)continue
const bytes=fs.readFileSync(sourceFile(source,name))
if(digest(bytes)!==info.sha256)throw Error('Input source changed during synchronization; retry')
fs.mkdirSync(path.dirname(destination),{recursive:true});fs.writeFileSync(destination,bytes);copied++
}
// Only delete recorded, owned files; never touch host assets or business data.
for(const name of Object.keys(previous?.files||{}))if(!current.files[name]){
if(!managed(name))throw Error('Invalid managed input path')
const destination=path.join(target,name);if(fs.existsSync(destination)){fs.unlinkSync(destination);removed++}
}
const manifestFile=path.join(target,'src/manifest.json'),manifest=readJson(manifestFile)
const before=JSON.stringify(manifest)
if(includeRoute)manifest.router.pages.keyboard={component:'index'}
else delete manifest.router.pages.keyboard
if(JSON.stringify(manifest)!==before)fs.writeFileSync(manifestFile,JSON.stringify(manifest,null,2)+'\n')
if(snapshot(source,includeRoute).sourceHash!==current.sourceHash)throw Error('Input source changed during synchronization; retry after edits settle')
fs.writeFileSync(receipt,JSON.stringify(current,null,2)+'\n')
return {sourceHash:current.sourceHash,files:Object.keys(current.files).length,copied,removed}
}
export function watchInput(target,source=APP,onSync=console.log){
let timer=null,stopped=false,lastHash=''
const synchronize=()=>{
timer=null;if(stopped)return
try{prepareDictionary(source);const hash=snapshot(source,includesRoute(target)).sourceHash;if(hash!==lastHash){const result=syncInput(target,source);lastHash=result.sourceHash;onSync({status:'synchronized',...result})}}
catch(error){lastHash='';onSync({status:'retrying',error:error.message})}
}
const schedule=()=>{if(timer)clearTimeout(timer);timer=setTimeout(synchronize,200)}
const watchers=['src','third_party','LICENSE','NOTICE.md'].map(root=>fs.watch(path.join(source,root),{recursive:true},schedule))
const poll=setInterval(synchronize,2000);synchronize()
return ()=>{stopped=true;if(timer)clearTimeout(timer);clearInterval(poll);for(const watcher of watchers)watcher.close()}
}
if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
const [target,...flags]=process.argv.slice(2)
if(!target||flags.some(flag=>!['--check','--watch'].includes(flag))||flags.length>1)throw Error('Usage: node scripts/install.mjs <Glyphix host project> [--check | --watch]')
if(flags.includes('--watch')){
const stop=watchInput(path.resolve(target));process.once('SIGINT',()=>{stop();process.exit(0)});process.once('SIGTERM',()=>{stop();process.exit(0)})
}else if(flags.includes('--check')){const result=checkInput(path.resolve(target));console.log('Input synchronized:',result.sourceHash)}
else console.log('Input synchronized:',syncInput(path.resolve(target)))
}