chore: checkpoint web Blender migration source, tools and docs
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-10 11:18:01 +08:00
parent 122142c26c
commit f3e21c6b98
6887 changed files with 257154 additions and 12294 deletions

View File

@@ -0,0 +1,260 @@
import fs from 'node:fs';
import path from 'node:path';
import {reserveNativeSessionWorkspace,prepareNativeSessionWorkspace,markNativeSessionWorkspace} from './native-session-workspace.mjs';
import crypto from 'node:crypto';
import {cancelServerJobProcess} from './server-job-process.mjs';
import {materializeResources,captureResourceBundle} from './native-resources.mjs';
import {persistNativePreferences,persistNativeStartup} from './native-preferences.mjs';
import {startNativeExtensionProxy} from './native-extension-proxy.mjs';
const sessions=new Map();
let startingSessions=0;
const hash=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
function write(file,value){const tmp=file+'.tmp';fs.writeFileSync(tmp,JSON.stringify(value));fs.renameSync(tmp,file);}
function read(file,limit=2*1024*1024){const info=fs.lstatSync(file);if(!info.isFile()||info.size>limit)throw new Error('SESSION_RESULT_INVALID');return JSON.parse(fs.readFileSync(file,'utf8'));}
async function close(entry){
if(entry.closed)return;
entry.closed=true;clearInterval(entry.monitor);sessions.delete(entry.id);
await entry.extensionProxy?.close();
await cancelServerJobProcess(entry.process,async()=>{try{if(!entry.lease.persistent)fs.rmSync(entry.workspace,{recursive:true,force:true});}finally{entry.lease.release();}},{graceMs:500});
}
function envelope(data,validate){
if(data.length<12||data.subarray(0,4).toString()!=='BWF1')throw new Error('SESSION_ENVELOPE');
const metadata=data.readUInt32LE(4),length=data.readUInt32LE(8);
if(metadata>256*1024||length>512*1024*1024||12+metadata+length!==data.length)throw new Error('SESSION_ENVELOPE_LENGTH');
const request=JSON.parse(data.subarray(12,12+metadata).toString());validate(request);
const bytes=data.subarray(12+metadata);
if(hash(bytes)!==request.sourceBlendSha256||bytes.length!==request.sourceBlendByteLength)throw new Error('SESSION_SOURCE_HASH');
return {request,bytes};
}
function userFile(entry,relative){
if(typeof relative!=='string'||Buffer.byteLength(relative)>2048||relative.includes('\\')||relative.includes('\0')||relative.startsWith('/')||relative.split('/').some(p=>!p||p==='.'||p==='..'||p.length>255))throw new Error('SESSION_FILE_PATH');
let target=path.join(entry.workspace,'resources','user');
for(const segment of relative.split('/')){target=path.join(target,segment);if(fs.existsSync(target)&&fs.lstatSync(target).isSymbolicLink())throw new Error('SESSION_FILE_SYMLINK');}
return target;
}
function userFiles(entry){
const root=path.join(entry.workspace,'resources','user'),files=[];
const visit=folder=>{if(!fs.existsSync(folder))return;for(const item of fs.readdirSync(folder,{withFileTypes:true})){
const absolute=path.join(folder,item.name),info=fs.lstatSync(absolute);
if(info.isSymbolicLink())throw new Error('SESSION_FILE_SYMLINK');
if(info.isDirectory())visit(absolute);
else if(info.isFile()){if(files.length>=4096)throw new Error('SESSION_FILE_COUNT');files.push({path:path.relative(root,absolute).split(path.sep).join('/'),bytes:info.size,modified:info.mtimeMs});}
}};visit(root);return files;
}
function events(items,width=1280,height=900){
if(!Array.isArray(items)||items.length>64)throw new Error('SESSION_INPUT_BUDGET');
for(const e of items){
if(!e||!['move','button','key','wheel','release','pen'].includes(e.type))throw new Error('SESSION_INPUT_TYPE');
if(['move','button','pen'].includes(e.type)&&(!Number.isInteger(e.x)||!Number.isInteger(e.y)||e.x<0||e.x>=width||e.y<0||e.y>=height))throw new Error('SESSION_POINTER_INVALID');
if(e.type==='button'&&(![1,2,3].includes(e.button)||typeof e.down!=='boolean'))throw new Error('SESSION_BUTTON_INVALID');
if(e.type==='key'&&(typeof e.code!=='string'||!/^[A-Za-z0-9]{1,20}$/.test(e.code)||typeof e.down!=='boolean'))throw new Error('SESSION_KEY_INVALID');
if(e.type==='key'&&e.key!==undefined&&(typeof e.key!=='string'||Buffer.byteLength(e.key)>64||e.key.includes('\0')))throw new Error('SESSION_LOGICAL_KEY_INVALID');
if(e.type==='key'&&e.locks!==undefined&&(!e.locks||typeof e.locks.caps!=='boolean'||typeof e.locks.num!=='boolean'))throw new Error('SESSION_KEY_LOCKS_INVALID');
if(e.type==='wheel'&&(!Number.isInteger(e.delta)||Math.abs(e.delta)>5))throw new Error('SESSION_WHEEL_INVALID');
if(e.type==='wheel'&&e.horizontal!==undefined&&typeof e.horizontal!=='boolean')throw new Error('SESSION_WHEEL_AXIS_INVALID');
if(e.type==='pen'&&(!['move','press','release'].includes(e.action)||![1,2,3].includes(e.button)||typeof e.eraser!=='boolean'||!Number.isFinite(e.pressure)||e.pressure<0||e.pressure>1||![e.tiltX,e.tiltY].every(value=>Number.isFinite(value)&&value>=-1&&value<=1)))throw new Error('SESSION_TABLET_INVALID');
}
return items;
}
export async function handleNativeSession(req,res,options,services){
const {localRequest,collect,json,fail,validate,launch,workspaceUsage}=services;
const url=new URL(req.url??'/','http://localhost');if(url.pathname!=='/api/blender-session')return false;
let reserved=false;
try{
localRequest(req);const action=req.headers['x-blender-session-action'];
if(action==='start'){
if(req.method!=='POST')fail('SESSION_METHOD',405);
if(sessions.size+startingSessions>=2)fail('SESSION_CAPACITY',429);
startingSessions++;reserved=true;
const {request,bytes}=envelope(await collect(req,512*1024*1024+256*1024+12),validate);
if(!['PYTHON','DESKTOP_OPERATOR'].includes(request.kind))fail('SESSION_KIND_INVALID');
const lease=reserveNativeSessionWorkspace(request,workspaceUsage),{workspace}=lease;let processHandle,extensionProxy;
try{
prepareNativeSessionWorkspace(lease);
for(const dir of ['commands','responses','events'])fs.mkdirSync(path.join(workspace,dir));
write(path.join(workspace,'request.json'),request);fs.writeFileSync(path.join(workspace,'source.blend'),bytes);
if(lease.retained)write(path.join(workspace,'resources.json'),captureResourceBundle(workspace,true)??{files:[]});else materializeResources(request.resources,workspace);
fs.mkdirSync(path.join(workspace,'resources','user'),{recursive:true});
if(!fs.existsSync(path.join(workspace,'resources.json')))write(path.join(workspace,'resources.json'),{files:[]});
extensionProxy=await startNativeExtensionProxy(workspace);
markNativeSessionWorkspace(lease);processHandle=launch(workspace,request,options,true,lease);
}catch(error){await extensionProxy?.close();if(!lease.persistent)fs.rmSync(workspace,{recursive:true,force:true});lease.release();throw error;}
const entry={id:crypto.randomUUID(),token:crypto.randomBytes(32).toString('hex'),request,workspace,lease,process:processHandle,extensionProxy,
sequence:0,pending:null,eventSequence:0,lastInput:0,lastUse:Date.now(),started:Date.now(),closed:false,stderr:''};
const preferencesFile=path.join(workspace,'config','userpref.blend');
if(fs.existsSync(preferencesFile)){const stat=fs.lstatSync(preferencesFile);entry.preferencePublished=`${stat.mtimeMs}:${stat.size}`;}
const startupFile=path.join(workspace,'config','startup.blend');
if(fs.existsSync(startupFile)){const stat=fs.lstatSync(startupFile);entry.startupPublished=`${stat.mtimeMs}:${stat.size}`;}
sessions.set(entry.id,entry);
processHandle.child.on('error',error=>{entry.stderr=String(error);});
processHandle.child.stderr.on('data',chunk=>{entry.stderr=(entry.stderr+chunk.toString()).slice(-8192);});
processHandle.child.stdout.on('data',chunk=>{entry.stderr=(entry.stderr+chunk.toString()).slice(-8192);});
processHandle.completion.then(()=>{if(!entry.closed)entry.exitError=`SESSION_PROCESS_EXITED (${processHandle.child.exitCode ?? processHandle.child.signalCode}): ${entry.stderr}`;});
entry.monitor=setInterval(()=>{
if(entry.closed)return;
try{
if(Date.now()-entry.lastUse>5*60*1000||Date.now()-entry.started>(entry.request.desktop?.preserveLayout?12:2)*60*60*1000||entry.exitError)throw new Error('SESSION_EXPIRED');
workspaceUsage(workspace);
if(entry.request.desktop?.preserveLayout){
const startup=path.join(workspace,'config','startup.blend');
if(fs.existsSync(startup)){
const stat=fs.lstatSync(startup),stamp=`${stat.mtimeMs}:${stat.size}`;
const ready=path.join(workspace,'startup-ready.json');
if(stamp===entry.startupCandidate&&stamp!==entry.startupPublished&&fs.existsSync(ready)&&read(ready).sha256===hash(fs.readFileSync(startup))){
try{persistNativeStartup(workspace);entry.preferenceError=null;}catch(error){entry.preferenceError=String(error.message);}
entry.startupPublished=stamp;
}
entry.startupCandidate=stamp;
}
const file=path.join(workspace,'config','userpref.blend');
if(fs.existsSync(file)){
const stat=fs.lstatSync(file),stamp=`${stat.mtimeMs}:${stat.size}`;
if(stamp===entry.preferenceCandidate&&stamp!==entry.preferencePublished&&stat.isFile()&&stat.size<=16*1024*1024){
try{persistNativePreferences(workspace,{id:'config/userpref.blend',sha256:hash(fs.readFileSync(file))});entry.preferenceError=null;}
catch(error){entry.preferenceError=String(error.message);}
entry.preferencePublished=stamp;
}
entry.preferenceCandidate=stamp;
}
}
if(entry.pending&&Date.now()-entry.pending.started>120000&&!fs.existsSync(path.join(workspace,'responses',`${String(entry.pending.sequence).padStart(8,'0')}.json`)))throw new Error('SESSION_COMMAND_TIMEOUT');
}catch(error){entry.exitError=String(error.message);void close(entry);}
},1000);entry.monitor.unref();
json(res,202,{id:entry.id,token:entry.token,status:'STARTING',desktopProtocolVersion:2,sourceRevision:request.sourceRevision,sourceBlendSha256:request.sourceBlendSha256,width:request.desktop?.width??1280,height:request.desktop?.height??900});return true;
}
const body=req.method==='POST'&&action!=='upload'?JSON.parse((await collect(req,256*1024)).toString()):{};
const id=body.id??url.searchParams.get('id'),token=body.token??req.headers['x-blender-session-token'];
const entry=sessions.get(id);if(!entry||entry.token!==token||entry.closed)fail('SESSION_NOT_FOUND_OR_EXPIRED',404);
entry.lastUse=Date.now();
if(action==='close'){
if(req.method!=='POST')fail('SESSION_METHOD',405);await close(entry);json(res,200,{status:'CLOSED'});return true;
}
if(entry.exitError)fail(entry.exitError,503);
if(action==='upload'){
if(req.method!=='POST'||entry.pending)fail('SESSION_BUSY',409);
const relative=decodeURIComponent(req.headers['x-blender-file-path']??''),target=userFile(entry,relative);
if(fs.existsSync(target))fail('SESSION_FILE_EXISTS',409);
const bytes=await collect(req,256*1024*1024);
if(workspaceUsage(entry.workspace).bytes+bytes.length>1024*1024*1024)fail('SESSION_WORKSPACE_BYTE_BUDGET');
fs.mkdirSync(path.dirname(target),{recursive:true});fs.writeFileSync(target,bytes,{flag:'wx'});
json(res,200,{status:'UPLOADED',path:relative,bytes:bytes.length});return true;
}
if(action==='files'){
if(req.method!=='GET')fail('SESSION_METHOD',405);
const statusFile=path.join(entry.workspace,'desktop-state.json');
const directory=path.join(entry.workspace,'browser-links'),links=[];
if(fs.existsSync(directory))for(const name of fs.readdirSync(directory).filter(name=>/^[a-f0-9]{32}\.json$/.test(name)).slice(0,32)){
const item=read(path.join(directory,name),16384),target=new URL(item.url);
if(['http:','https:'].includes(target.protocol)&&target.hostname)links.push({id:name.slice(0,-5),url:target.href});
}
json(res,200,{files:userFiles(entry),links,network:entry.extensionProxy?.status,...(fs.existsSync(statusFile)?{status:read(statusFile)}:{}),...(entry.preferenceError?{preferenceError:entry.preferenceError}:{})});return true;
}
if(action==='link-ack'){
if(req.method!=='POST'||!/^[a-f0-9]{32}$/.test(body.linkId??''))fail('SESSION_LINK_ID');
fs.rmSync(path.join(entry.workspace,'browser-links',body.linkId+'.json'),{force:true});json(res,200,{status:'ACKNOWLEDGED'});return true;
}
if(action==='download'){
if(req.method!=='GET')fail('SESSION_METHOD',405);
const file=userFile(entry,url.searchParams.get('file')),before=fs.lstatSync(file);
if(!before.isFile()||before.size>256*1024*1024)fail('SESSION_DOWNLOAD_BUDGET');
const bytes=fs.readFileSync(file),after=fs.lstatSync(file);
if(before.mtimeMs!==after.mtimeMs||bytes.length!==before.size)fail('SESSION_FILE_CHANGING_RETRY',409);
res.setHeader('Content-Type','application/octet-stream');res.setHeader('Cache-Control','no-store');res.setHeader('Content-Length',bytes.length);res.end(bytes);return true;
}
if(action==='screen'){
if(req.method!=='GET')fail('SESSION_METHOD',405);
const readyFile=path.join(entry.workspace,'session-ready.json');
if(fs.existsSync(readyFile)){const state=read(readyFile);if(state.status==='FAILED')fail(state.error??'SESSION_DESKTOP_FAILED',503);}
const file=path.join(entry.workspace,'screen.png');
if(!fs.existsSync(file)){if(Date.now()-entry.started>45000)fail('SESSION_SCREEN_TIMEOUT',503);res.statusCode=204;res.end();return true;}
const size=fs.lstatSync(file);if(!size.isFile()||size.size>24*1024*1024)fail('SESSION_SCREEN_INVALID');
const metadata=path.join(entry.workspace,'screen-meta.json');if(fs.existsSync(metadata)&&read(metadata).cursorEmbedded)res.setHeader('X-Blender-Cursor','embedded');
const etag=`"${size.mtimeMs}-${size.size}"`;res.setHeader('ETag',etag);
if(req.headers['if-none-match']===etag){res.statusCode=304;res.end();return true;}
const bytes=fs.readFileSync(file);res.setHeader('Content-Type','image/png');res.setHeader('Cache-Control','no-store');res.setHeader('Content-Length',bytes.length);res.end(bytes);return true;
}
if(action==='events'){
if(req.method!=='POST')fail('SESSION_METHOD',405);
if(entry.pending)fail('SESSION_COMMIT_PENDING',409);
if(Date.now()-entry.lastInput<8)fail('SESSION_INPUT_RATE',429);entry.lastInput=Date.now();
const batch=events(body.events,entry.request.desktop?.width??1280,entry.request.desktop?.height??900);
if(fs.readdirSync(path.join(entry.workspace,'events')).length>128)fail('SESSION_INPUT_BACKLOG',429);
write(path.join(entry.workspace,'events',`${String(++entry.eventSequence).padStart(12,'0')}.json`),batch);json(res,200,{status:'QUEUED'});return true;
}
if(action==='command'){
if(req.method!=='POST'||entry.pending)fail('SESSION_BUSY',409);
const command=body.command;
if(!command||!['read-main','python','undo','redo','invoke','commit','cancel-modal','preferences-save','editor','clipboard','clipboard-read','file-browser','download-project','open-blend'].includes(command.kind))fail('SESSION_COMMAND_INVALID');
if(command.kind==='clipboard'&&(typeof command.text!=='string'||Buffer.byteLength(command.text)>65536||command.text.includes('\0')))fail('SESSION_CLIPBOARD_BUDGET');
if(command.kind==='file-browser'&&command.path!=='')userFile(entry,command.path);
if(command.kind==='file-browser'&&command.filename!==undefined&&(typeof command.filename!=='string'||command.filename.includes('/')||command.filename.includes('\\')||command.filename.includes('\0')||command.filename.length>255))fail('SESSION_FILENAME_INVALID');
if(command.kind==='open-blend'){
if(!entry.request.desktop?.preserveLayout)fail('SESSION_FULL_WORKSPACE_REQUIRED');
const file=userFile(entry,command.path);if(!file.toLowerCase().endsWith('.blend')||!fs.lstatSync(file).isFile())fail('SESSION_BLEND_REQUIRED');
}
if(command.kind==='editor'){
const request={...entry.request,context:{...entry.request.context,areaType:command.areaType}};
if(typeof command.areaType!=='string')fail('SESSION_EDITOR_REQUIRED');
const {requestSha256,...unsigned}=request;
request.requestSha256=hash(Buffer.from(services.canonical(unsigned)));validate(request);
}
if(command.kind==='python'&&(typeof command.source!=='string'||Buffer.byteLength(command.source)>65536||command.source.includes('\0')))fail('SESSION_PYTHON_BUDGET');
if(command.kind==='invoke'){
const request={...entry.request,kind:'DESKTOP_OPERATOR',operator:{id:command.operator,properties:command.properties??{}}};
const {requestSha256,...unsigned}=request;
// Reuse the native operator validator using a freshly computed canonical request binding.
request.requestSha256=hash(Buffer.from(services.canonical(unsigned)));validate(request);
}
if(!fs.existsSync(path.join(entry.workspace,'session-ready.json'))||read(path.join(entry.workspace,'session-ready.json')).status!=='READY')fail('SESSION_NOT_READY',409);
const sequence=++entry.sequence;
entry.pending={sequence,kind:command.kind,started:Date.now(),response:null};
write(path.join(entry.workspace,'commands',`${String(sequence).padStart(8,'0')}.json`),{...command,sequence,afterInput:entry.eventSequence});
json(res,202,{sequence,status:'RUNNING'});return true;
}
if(action==='ack'){
if(req.method!=='POST'||!entry.pending||entry.pending.sequence!==body.sequence||!entry.pending.response)fail('SESSION_ACK_INVALID',409);
for(const item of entry.pending.response.artifacts??[])fs.rmSync(path.join(entry.workspace,item.id),{force:true});
fs.rmSync(path.join(entry.workspace,'responses',`${String(body.sequence).padStart(8,'0')}.json`),{force:true});entry.pending=null;
json(res,200,{status:'READY'});return true;
}
if(action==='artifact'){
if(req.method!=='GET')fail('SESSION_METHOD',405);
const item=entry.pending?.response?.artifacts?.find(a=>a.id===url.searchParams.get('artifact'));
if(!item)fail('SESSION_ARTIFACT_NOT_FOUND',404);
const file=path.join(entry.workspace,item.id),stat=fs.lstatSync(file);if(!stat.isFile()||stat.size!==item.bytes)fail('SESSION_ARTIFACT_CHANGED');
res.setHeader('Content-Type',item.mime);res.setHeader('Content-Length',item.bytes);res.setHeader('Cache-Control','no-store');
fs.createReadStream(file).on('error',()=>res.destroy()).pipe(res);return true;
}
if(action!=='read'||req.method!=='GET')fail('SESSION_ACTION_INVALID');
const readyFile=path.join(entry.workspace,'session-ready.json');
const ready=fs.existsSync(readyFile)?read(readyFile):{status:'STARTING'};
if(ready.status==='READY'&&!fs.existsSync(path.join(entry.workspace,'screen.png'))&&Date.now()-entry.started>45000){
fail('SESSION_SCREEN_HELPER_NOT_READY',503);
}
if(entry.pending){
const file=path.join(entry.workspace,'responses',`${String(entry.pending.sequence).padStart(8,'0')}.json`);
if(fs.existsSync(file)&&!entry.pending.response){
const result=read(file);
if(result.sequence!==entry.pending.sequence||result.sessionId!==entry.request.jobId||result.requestSha256!==entry.request.requestSha256)fail('SESSION_RESPONSE_BINDING');
let bytes=0;const ids=new Set();
for(const item of result.artifacts??[]){
if(!/^(result|resources|preferences|project)-[0-9]{8}\.(blend|zip)$/.test(item.id)||ids.has(item.id))fail('SESSION_ARTIFACT_INVALID');ids.add(item.id);
const p=path.join(entry.workspace,item.id),stat=fs.lstatSync(p);
if(!stat.isFile()||stat.size!==item.bytes||(bytes+=stat.size)>512*1024*1024||hash(fs.readFileSync(p))!==item.sha256)fail('SESSION_ARTIFACT_BINDING');
}
if(entry.pending.kind==='preferences-save'&&result.status==='SUCCEEDED'){
const preference=result.artifacts?.find(a=>a.id===`preferences-${String(entry.pending.sequence).padStart(8,'0')}.blend`&&a.mime==='application/x-blender-preferences');
if(!preference)fail('PREFERENCES_ARTIFACT_MISSING');
try{persistNativePreferences(entry.workspace,preference);}
catch(error){result.status='FAILED';result.error=String(error.message);result.output='';}
}
if(['SUCCEEDED','PYTHON_ERROR'].includes(result.status)&&result.artifacts?.some(a=>a.mime==='application/x-blender'))result.resources=captureResourceBundle(entry.workspace);
entry.pending.response=result;
}
json(res,200,{...ready,command:entry.pending.response??{sequence:entry.pending.sequence,status:'RUNNING'}});return true;
}
json(res,200,ready);return true;
}catch(error){json(res,error.statusCode??500,{status:'FAILED',error:String(error.message??error)});return true;}
finally{if(reserved)startingSessions--;}
}