"""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='') 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='',mode='exec') compiled=compile(ast.Interactive(body=tree.body),'','single') except (SyntaxError,OverflowError,ValueError): CONSOLE.showsyntaxerror('') 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']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)