Files
workinf_Blender_Wasm/tools/web/native-session-blender.py
mes123456 f3e21c6b98
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
chore: checkpoint web Blender migration source, tools and docs
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>
2026-11-10 11:18:01 +08:00

437 lines
26 KiB
Python

"""Long-lived embedded bpy kernel with sequential, explicit commit commands."""
import ast
import code
import contextlib
import hashlib
import importlib.util
import json
import os
import shutil
from pathlib import Path
import subprocess
import sys
import time
import traceback
import types
import uuid
import webbrowser
from urllib.parse import urlsplit
import zipfile
import bpy
TOOLS=Path(__file__).resolve().parent
sys.path.insert(0,str(TOOLS))
from native_resources import install_resources, save_portable
from server_operator_properties import validated_operator_properties
from native_preferences import load_preferences, save_preferences
ROOT=Path(sys.argv[sys.argv.index('--')+1]).resolve()
REQUEST=json.loads((ROOT/'request.json').read_text())
CONSOLE_MODULE=types.ModuleType('__main__')
NAMESPACE=CONSOLE_MODULE.__dict__
NAMESPACE.update(__builtins__=sys.modules['builtins'],bpy=bpy,C=bpy.context,D=bpy.data)
exec('from mathutils import *\nfrom math import *',NAMESPACE,NAMESPACE)
sequence=0;desktop_io=None;last_input=time.monotonic()
preserve_layout=REQUEST.get('desktop',{}).get('preserveLayout',False)
browser_operators=set();last_desktop_state=0;saving_bridge=False;menu_save_sequence=0;startup_stamp=None;bridge_error=''
extension_catalog_cache={}
def publish(path,data):
tmp=path.with_suffix('.tmp');tmp.write_text(json.dumps(data,ensure_ascii=False));tmp.replace(path)
class BrowserBridge(webbrowser.BaseBrowser):
def open(self,url,new=0,autoraise=True):
parsed=urlsplit(url)
if parsed.scheme not in {'http','https'} or not parsed.netloc or len(url)>8192:
raise RuntimeError('SESSION_BROWSER_URL_INVALID')
directory=ROOT/'browser-links';directory.mkdir(exist_ok=True)
publish(directory/(uuid.uuid4().hex+'.json'),{'url':url})
return True
class OutputBudgetExceeded(RuntimeError):
pass
class Output:
def __init__(self):self.parts=[];self.bytes=0
def write(self,value):
size=len(value.encode());self.bytes+=size
if self.bytes>1024*1024:raise OutputBudgetExceeded('PYTHON_OUTPUT_BUDGET')
self.parts.append(value);return len(value)
def flush(self):pass
class SessionConsole(code.InteractiveConsole):
error=None
def showtraceback(self):
self.error=sys.exc_info()[1]
if isinstance(self.error,OutputBudgetExceeded):raise self.error
super().showtraceback()
def showsyntaxerror(self,filename=None):
self.error=sys.exc_info()[1]
super().showsyntaxerror(filename)
CONSOLE=SessionConsole(locals=NAMESPACE,filename='<blender_console>')
def execute_console(source,output):
# Match _console_python: persistent locals, standard displayhook and recoverable Python errors.
CONSOLE.error=None
bpy.context.preferences.edit.use_global_undo=True
try:bpy.ops.ed.undo_push(message='Web Blender Console')
except RuntimeError:pass
previous_main=sys.modules.get('__main__');previous_stdin=sys.stdin
try:
sys.modules['__main__']=CONSOLE_MODULE;sys.stdin=None
with contextlib.redirect_stdout(output),contextlib.redirect_stderr(output):
try:
tree=ast.parse(source,filename='<blender_console>',mode='exec')
compiled=compile(ast.Interactive(body=tree.body),'<blender_console>','single')
except (SyntaxError,OverflowError,ValueError):
CONSOLE.showsyntaxerror('<blender_console>')
else:
try:CONSOLE.runcode(compiled)
except SystemExit as error:
CONSOLE.error=error;print(repr(error));CONSOLE.resetbuffer()
finally:
sys.stdin=previous_stdin
if previous_main is None:sys.modules.pop('__main__',None)
else:sys.modules['__main__']=previous_main
try:bpy.ops.ed.undo_push(message='Web Blender Console Result')
except RuntimeError:pass
return None if CONSOLE.error is None else type(CONSOLE.error).__name__+': '+str(CONSOLE.error)
def context():
source=REQUEST['context'];window=bpy.context.window or next(iter(bpy.context.window_manager.windows),None)
if window is None:raise RuntimeError('SESSION_WINDOW_REQUIRED')
area_type=source.get('areaType','VIEW_3D')
if preserve_layout:
area=bpy.context.area or max(window.screen.areas,key=lambda a:a.width*a.height)
region=next((r for r in area.regions if r.type=='WINDOW'),None)
return {'window':window,'area':area,**({'region':region} if region else {}),'scene':window.scene,'view_layer':window.view_layer}
area=next((a for a in window.screen.areas if a.type==area_type),None)
if area is None:
area=max(window.screen.areas,key=lambda a:a.width*a.height);area.type=area_type
region=next((r for r in area.regions if r.type=='WINDOW'),None)
if region is None:raise RuntimeError('SESSION_REGION_REQUIRED')
return {'window':window,'area':area,'region':region,'scene':window.scene,'view_layer':window.view_layer}
def modal_running():
# Python Window.modal_operators reflects the actual window-manager modal stack.
return any(len(window.modal_operators)>0 for window in bpy.context.window_manager.windows)
def snapshot_result(command,output):
global saving_bridge
if modal_running():raise RuntimeError('SESSION_MODAL_ACTIVE_FINISH_OR_CANCEL_FIRST')
file=ROOT/f'result-{command["sequence"]:08d}.blend'
with bpy.context.temp_override(**context()):
if bpy.context.object and bpy.context.object.mode=='EDIT':bpy.context.object.update_from_editmode()
saving_bridge=True
try:save_portable(ROOT,file)
finally:saving_bridge=False
size=file.stat().st_size
if size>256*1024*1024:raise RuntimeError('SESSION_BLEND_BUDGET')
artifacts=[{'id':file.name,'name':'session.blend','mime':'application/x-blender','bytes':size,'sha256':hashlib.sha256(file.read_bytes()).hexdigest()}]
resources=ROOT/'resources'
if resources.exists():
archive=ROOT/f'resources-{command["sequence"]:08d}.zip';total=0
with zipfile.ZipFile(archive,'w',zipfile.ZIP_STORED) as bundle:
for src in sorted(resources.rglob('*')):
if src.is_symlink():raise RuntimeError('SESSION_RESOURCE_SYMLINK')
if src.is_file():
total+=src.stat().st_size
if total>256*1024*1024:raise RuntimeError('SESSION_RESOURCE_BUDGET')
bundle.write(src,str(src.relative_to(resources)))
artifacts.append({'id':archive.name,'name':'resources.zip','mime':'application/zip','bytes':archive.stat().st_size,'sha256':hashlib.sha256(archive.read_bytes()).hexdigest()})
return {'status':'SUCCEEDED','output':''.join(output.parts),'artifacts':artifacts,'mainReceipt':{'command':command['kind'],'sequence':command['sequence'],'activeObject':bpy.context.object.name if bpy.context.object else None,'objects':[{'name':obj.name,'location':[float(v) for v in obj.location]} for obj in sorted(bpy.data.objects,key=lambda item:item.name)]}}
def browser_directory(relative='',filename=None):
directory=(ROOT/'resources'/'user'/relative).resolve()
if not directory.is_relative_to((ROOT/'resources'/'user').resolve()) or not directory.is_dir():
raise RuntimeError('SESSION_FILE_DIRECTORY_INVALID')
found=False
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
if area.type=='FILE_BROWSER' and area.spaces.active.params:
area.spaces.active.params.directory=str(directory).encode()
if filename is not None:area.spaces.active.params.filename=filename
area.tag_redraw();found=True
if not found:raise RuntimeError('SESSION_OPEN_FILE_MENU_FIRST')
def extension_hosts():
hosts=set()
settings=getattr(bpy.context.preferences,'extensions',None)
for repo in getattr(settings,'repos',[]):
if not getattr(repo,'enabled',True):continue
remote=getattr(repo,'remote_url','');parsed=urlsplit(remote)
if parsed.scheme!='https' or not parsed.hostname:continue
hosts.add(parsed.hostname.lower())
directory=getattr(repo,'directory','')
if not directory:continue
file=Path(directory)/'.blender_ext'/'index.json'
try:
info=file.stat()
if info.st_size>8*1024*1024:continue
previous=extension_catalog_cache.get(str(file))
if previous and previous[0]==info.st_mtime_ns:hosts.update(previous[1]);continue
catalog=json.loads(file.read_text());archives=set()
for package in catalog.get('data',[]):
target=urlsplit(package.get('archive_url',''))
if target.scheme=='https' and target.hostname:archives.add(target.hostname.lower())
extension_catalog_cache[str(file)]=(info.st_mtime_ns,archives);hosts.update(archives)
except (OSError,ValueError,AttributeError,TypeError):continue
publish(ROOT/'extension-hosts.json',{'hosts':sorted(hosts)[:128]})
def desktop_state():
global browser_operators,last_desktop_state
if not preserve_layout:return
current=set()
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
if area.type=='FILE_BROWSER':
space=area.spaces.active
if space.params and space.active_operator:
key=space.active_operator.as_pointer();current.add(key)
if key not in browser_operators and space.active_operator.bl_idname!='WM_OT_recover_auto_save':
space.params.directory=str(ROOT/'resources'/'user').encode();area.tag_redraw()
browser_operators=current
if time.monotonic()-last_desktop_state>=1:
extension_hosts()
window=bpy.context.window or next(iter(bpy.context.window_manager.windows),None)
publish(ROOT/'desktop-state.json',{'dirty':bpy.data.is_dirty,'filepath':bpy.data.filepath,
'workspace':window.workspace.name if window else '', 'fileBrowser':bool(current),'savedSequence':menu_save_sequence,
**({'error':bridge_error} if bridge_error else {})})
last_desktop_state=time.monotonic()
def prepare_saved_startup():
global startup_stamp,bridge_error
file=ROOT/'config'/'startup.blend'
if not file.is_file() or file.stat().st_mtime_ns==startup_stamp:return
try:
resources=ROOT/'resources'
source_files=list(resources.rglob('*'));total=0
for source in source_files:
if source.is_symlink():raise RuntimeError('STARTUP_RESOURCE_SYMLINK')
if source.is_file():total+=source.stat().st_size
if total>256*1024*1024 or len(source_files)>4096:raise RuntimeError('STARTUP_RESOURCE_BUDGET')
if total:
target=Path(os.environ['BLENDER_USER_DATAFILES'])/'startup-assets'/uuid.uuid4().hex
target.parent.mkdir(parents=True,exist_ok=True);shutil.copytree(resources,target)
with (ROOT/'startup-resources.log').open('w') as log:
result=subprocess.run([bpy.app.binary_path,'--background','--factory-startup','--disable-autoexec',
'--python-exit-code','1','--python',str(TOOLS/'native_startup_resources.py'),'--',str(file),str(ROOT),str(target)],
stdout=log,stderr=subprocess.STDOUT,timeout=120)
if result.returncode:raise RuntimeError('STARTUP_RESOURCE_REWRITE_FAILED')
startup_stamp=file.stat().st_mtime_ns
publish(ROOT/'startup-ready.json',{'sha256':hashlib.sha256(file.read_bytes()).hexdigest()})
bridge_error=''
except BaseException as error:bridge_error=str(error)
@bpy.app.handlers.persistent
def saved_from_menu(*_):
global menu_save_sequence,saving_bridge,bridge_error
if not preserve_layout or saving_bridge:return
prepare_saved_startup()
source=Path(bpy.data.filepath)
if not source.is_file():return
menu_save_sequence+=1
if not source.resolve().is_relative_to((ROOT/'resources'/'user').resolve()):
target=ROOT/'resources'/'user'/'downloads'/source.name
target.parent.mkdir(parents=True,exist_ok=True)
if source.stat().st_size<=256*1024*1024:shutil.copyfile(source,target)
key=REQUEST.get('desktop',{}).get('recoveryKey')
if key:
try:
directory=Path(os.environ['BLENDER_USER_DATAFILES'])/'project-saves'/key
destination=directory/uuid.uuid4().hex;destination.mkdir(parents=True)
files=list((ROOT/'resources').rglob('*'));total=0
for file in files:
if file.is_symlink():raise RuntimeError('DESKTOP_SAVE_RESOURCE_SYMLINK')
if file.is_file():
total+=file.stat().st_size
if total>256*1024*1024 or len(files)>4096:raise RuntimeError('DESKTOP_SAVE_RESOURCE_BUDGET')
target=destination/file.relative_to(ROOT/'resources');target.parent.mkdir(parents=True,exist_ok=True);shutil.copyfile(file,target)
main='project.blend'
while (destination/main).exists():main='_'+main
saving_bridge=True
try:save_portable(ROOT,destination/main)
finally:saving_bridge=False
if (destination/main).stat().st_size>256*1024*1024:raise RuntimeError('DESKTOP_SAVE_MAIN_BUDGET')
publish(directory/'latest.json',{'directory':destination.name,'main':main,'savedAt':int(time.time()*1000),
'sha256':hashlib.sha256((destination/main).read_bytes()).hexdigest()})
old=sorted((item for item in directory.iterdir() if item.is_dir()),key=lambda item:item.stat().st_mtime,reverse=True)
for item in old[3:]:shutil.rmtree(item)
bridge_error=''
except BaseException as error:bridge_error=str(error)
def tick():
global sequence,last_input
if desktop_io is not None and desktop_io.poll() is not None:
publish(ROOT/'session-ready.json',{'status':'FAILED','error':'SESSION_DESKTOP_IO_EXITED'})
return None
try:desktop_state()
except Exception as error:publish(ROOT/'desktop-state.json',{'error':str(error),'dirty':True,'filepath':'','workspace':''})
file=ROOT/'commands'/f'{sequence+1:08d}.json'
if not file.exists():return 0.05
command=json.loads(file.read_text())
if command.get('afterInput',0):
ack=ROOT/'input-ack.json'
if not ack.exists():return 0.05
applied=json.loads(ack.read_text())
if applied['sequence']<command['afterInput'] or time.time()-applied['at']<0.1:return 0.05
file.unlink();sequence+=1;last_input=time.monotonic()
response={'sequence':sequence,'sessionId':REQUEST['jobId'],'requestSha256':REQUEST['requestSha256']}
output=Output()
try:
kind=command['kind']
if kind=='read-main':
if modal_running():raise RuntimeError('SESSION_MODAL_ACTIVE_FINISH_OR_CANCEL_FIRST')
with bpy.context.temp_override(**context()):
receipt={'command':kind,'sequence':command['sequence'],'activeObject':bpy.context.object.name if bpy.context.object else None,'objects':[{'name':obj.name,'location':[float(v) for v in obj.location]} for obj in sorted(bpy.data.objects,key=lambda item:item.name)]}
observed={'version':list(bpy.app.version),'objects':len(bpy.data.objects),'workspace':str(ROOT),'filepath':bpy.data.filepath,'filepathExists':os.path.isfile(bpy.data.filepath),'dirty':bpy.data.is_dirty,'packedImages':{image.name:bool(image.packed_file) for image in bpy.data.images}}
observed['images']=[{'name':image.name,'filepath':image.filepath,'absolute':bpy.path.abspath(image.filepath,library=image.library) if image.filepath else '', 'exists':bool(image.filepath) and os.path.isfile(bpy.path.abspath(image.filepath,library=image.library)),'packed':bool(image.packed_file)} for image in sorted(bpy.data.images,key=lambda item:item.name) if image.source=='FILE']
response.update(status='SUCCEEDED',output=json.dumps(observed),artifacts=[],mainReceipt=receipt)
elif kind=='python':
if modal_running():raise RuntimeError('SESSION_MODAL_ACTIVE_FINISH_OR_CANCEL_FIRST')
with bpy.context.temp_override(**context()):
python_error=execute_console(command['source'],output)
response.update(snapshot_result(command,output))
if python_error:response.update(status='PYTHON_ERROR',error=python_error)
elif kind in ('undo','redo'):
with bpy.context.temp_override(**context()):result=getattr(bpy.ops.ed,kind)()
if 'CANCELLED' in result:raise RuntimeError(f'SESSION_{kind.upper()}_CANCELLED')
response.update(snapshot_result(command,output));response['output']=','.join(sorted(result))
elif kind=='invoke':
with bpy.context.temp_override(**context()):
family,name=command['operator'].split('.',1);target=getattr(getattr(bpy.ops,family),name)
if not target.poll():raise RuntimeError('SESSION_OPERATOR_POLL_FAILED')
properties=validated_operator_properties(target,command.get('properties',{}))
result=target('INVOKE_DEFAULT',**properties)
if 'CANCELLED' in result:raise RuntimeError('SESSION_OPERATOR_CANCELLED')
response.update(status='SUCCEEDED',operatorState=sorted(result),output=','.join(sorted(result)))
elif kind=='commit':response.update(snapshot_result(command,output))
elif kind=='download-project':
result=snapshot_result(command,output)
blend=ROOT/result['artifacts'][0]['id']
archive=ROOT/f'project-{sequence:08d}.zip'
with zipfile.ZipFile(archive,'w',zipfile.ZIP_DEFLATED) as bundle:
main_name='project.blend'
while (ROOT/'resources'/main_name).exists():main_name='_'+main_name
bundle.write(blend,main_name)
for item in result['artifacts']:
if item['mime']=='application/zip':
with zipfile.ZipFile(ROOT/item['id']) as resources:
for name in resources.namelist():bundle.writestr(name,resources.read(name))
if archive.stat().st_size>256*1024*1024:raise RuntimeError('SESSION_PACKAGE_BUDGET')
result['artifacts'].append({'id':archive.name,'name':'Blender-project.zip','mime':'application/zip',
'bytes':archive.stat().st_size,'sha256':hashlib.sha256(archive.read_bytes()).hexdigest()})
response.update(result)
elif kind=='clipboard':
bpy.context.window_manager.clipboard=command['text']
response.update(status='SUCCEEDED',output='')
elif kind=='clipboard-read':
text=bpy.context.window_manager.clipboard
if len(text.encode())>65536:raise RuntimeError('SESSION_CLIPBOARD_BUDGET')
response.update(status='SUCCEEDED',output=text)
elif kind=='file-browser':
browser_directory(command['path'],command.get('filename'))
response.update(status='SUCCEEDED',output='')
elif kind=='open-blend':
if modal_running():raise RuntimeError('SESSION_MODAL_ACTIVE_FINISH_OR_CANCEL_FIRST')
file=(ROOT/'resources'/'user'/command['path']).resolve()
if not file.is_relative_to((ROOT/'resources'/'user').resolve()):raise RuntimeError('SESSION_FILE_PATH')
state=bpy.ops.wm.open_mainfile(filepath=str(file),load_ui=True,use_scripts=False)
if 'FINISHED' not in state:raise RuntimeError('SESSION_OPEN_FAILED')
response.update(status='SUCCEEDED',output=file.name)
elif kind=='preferences-save':
if modal_running():raise RuntimeError('SESSION_MODAL_ACTIVE_FINISH_OR_CANCEL_FIRST')
file=save_preferences(ROOT,sequence)
response.update(status='SUCCEEDED',output='Preferences saved for future Web Blender sessions',
artifacts=[{'id':file.name,'name':'userpref.blend','mime':'application/x-blender-preferences',
'bytes':file.stat().st_size,'sha256':hashlib.sha256(file.read_bytes()).hexdigest()}])
elif kind=='editor':
if modal_running():raise RuntimeError('SESSION_MODAL_ACTIVE_FINISH_OR_CANCEL_FIRST')
area=context()['area']
area.type=command['areaType']
REQUEST['context']['areaType']=command['areaType']
response.update(status='SUCCEEDED',output='Editor: '+area.type)
elif kind=='cancel-modal':
publish(ROOT/'events'/f'cancel-{time.time_ns()}.json',[{'type':'key','code':'Escape','down':True},{'type':'key','code':'Escape','down':False}])
response.update(status='SUCCEEDED',output='Escape sent')
else:raise RuntimeError('SESSION_COMMAND_UNKNOWN')
except BaseException as error:
response.update(status='FAILED',error=str(error),output=''.join(output.parts)+'\n'+traceback.format_exc(limit=8))
publish(ROOT/'responses'/f'{sequence:08d}.json',response)
return 0.05
def tablet_tick():
file=ROOT/'pen-input.json'
if not file.exists():return 0.008
request=json.loads(file.read_text());file.unlink()
result={'id':request['id'],'handled':[]}
try:
target=getattr(bpy.context.window_manager,'web_pointer_event',None)
if target is None:raise RuntimeError('NATIVE_TABLET_API_NOT_BUILT')
for sample in request['samples']:
result['handled'].append(target(x=sample['x'],y=sample['y'],button=sample['button'],
action={'move':0,'press':1,'release':2}[sample['action']],pressure=sample['pressure'],
tilt_x=sample['tiltX'],tilt_y=sample['tiltY'],eraser=sample['eraser']))
except BaseException as error:result['error']=str(error)
publish(ROOT/'pen-output.json',result)
return 0.008
def start():
global desktop_io,startup_stamp
try:
load_preferences(ROOT)
startup=ROOT/'config'/'startup.blend'
startup_stamp=startup.stat().st_mtime_ns if startup.is_file() else None
if preserve_layout and REQUEST.get('desktop',{}).get('useSavedStartup') and not (ROOT/'desktop-recovery.json').exists():
bpy.ops.wm.read_homefile('EXEC_DEFAULT')
else:bpy.ops.wm.open_mainfile(filepath=str(ROOT/'source.blend'),load_ui=preserve_layout,use_scripts=False)
resource_report=install_resources(ROOT)
if resource_report['missing'] and not preserve_layout:raise RuntimeError('SESSION_RESOURCES_MISSING: '+', '.join(resource_report['missing'][:32]))
bpy.app.timers.register(tablet_tick,first_interval=0.008,persistent=True)
if preserve_layout:
webbrowser.register('web-blender',None,BrowserBridge(),preferred=True)
bpy.app.handlers.save_post.append(saved_from_menu)
desktop_io=subprocess.Popen(['/usr/bin/python3',str(TOOLS/'native_desktop_io.py'),str(ROOT)])
publish(ROOT/'session-ready.json',{'status':'READY','blenderVersion':bpy.app.version_string,'resources':resource_report,'inputCapabilities':{'pen':hasattr(bpy.context.window_manager,'web_pointer_event'),'logicalKeys':True,'horizontalWheel':True}})
bpy.app.timers.register(tick,first_interval=0.1,persistent=True)
return None
ctx=context();settings=REQUEST['context']
with bpy.context.temp_override(**ctx):
if settings.get('sceneName'):
scene=bpy.data.scenes.get(settings['sceneName'])
if scene is None:raise RuntimeError('SESSION_SCENE_NOT_FOUND')
ctx['window'].scene=scene
ctx=context()
with bpy.context.temp_override(**ctx):
active=settings.get('activeObject');names=settings.get('selectedObjects',[])
for obj in ctx['view_layer'].objects:obj.select_set(obj.name in names or obj.name==active)
if active:
obj=ctx['view_layer'].objects.get(active)
if obj is None:raise RuntimeError('SESSION_ACTIVE_OBJECT_NOT_FOUND')
ctx['view_layer'].objects.active=obj
selection=settings.get('meshSelection')
if selection:
obj=bpy.context.object
if not obj or obj.type!='MESH':raise RuntimeError('SESSION_MESH_REQUIRED')
if obj.mode!='OBJECT':bpy.ops.object.mode_set(mode='OBJECT')
groups={'VERT':obj.data.vertices,'EDGE':obj.data.edges,'FACE':obj.data.polygons};elements=groups[selection['domain']]
for group in groups.values():
for element in group:element.select=False
for index in selection['indices']:
if index>=len(elements):raise RuntimeError('SESSION_SELECTION_RANGE')
elements[index].select=True
bpy.context.tool_settings.mesh_select_mode=tuple(selection['domain']==d for d in ('VERT','EDGE','FACE'))
mode=settings.get('mode','OBJECT')
if bpy.context.object and bpy.context.object.mode!=mode:bpy.ops.object.mode_set(mode=mode)
if ctx['area'].type=='NODE_EDITOR':
ctx['area'].ui_type=settings.get('nodeTreeType','GeometryNodeTree')
tree=bpy.data.node_groups.get(settings.get('nodeTreeName',''))
if tree:ctx['area'].spaces.active.pin=True;ctx['area'].spaces.active.node_tree=tree
desktop_io=subprocess.Popen(['/usr/bin/python3',str(TOOLS/'native_desktop_io.py'),str(ROOT)])
publish(ROOT/'session-ready.json',{'status':'READY','blenderVersion':bpy.app.version_string,'resources':resource_report,'inputCapabilities':{'pen':hasattr(bpy.context.window_manager,'web_pointer_event'),'logicalKeys':True,'horizontalWheel':True}})
bpy.app.timers.register(tick,first_interval=0.1,persistent=True)
except BaseException as error:publish(ROOT/'session-ready.json',{'status':'FAILED','error':str(error),'detail':traceback.format_exc(limit=6)})
return None
bpy.app.timers.register(start,first_interval=0.5,persistent=True)