140 行
10 KiB
JavaScript
140 行
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))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')
|
|
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)))
|
|
}
|