import os import bpy import ssl import time import json import urllib import shutil import pathlib import zipfile import traceback import addon_utils from threading import Thread from bpy.app.handlers import persistent beta_branch = "beta" GITHUB_URL = "https://api.github.com/repos/RokokoElectronics/rokoko-studio-live-blender/releases" GITHUB_URL_BETA = f"https://github.com/RokokoElectronics/rokoko-studio-live-blender/archive/{beta_branch}.zip" GITHUB_COMPATIBILITY_URL = "https://raw.githubusercontent.com/Rokoko/rokoko-studio-live-blender/master/version_compatibility.json" downloads_dir_name = "updater_downloads" path_names_to_keep = [ downloads_dir_name, 'resources/no_auto_ver_check.txt', 'resources/cache', 'resources/custom_bones', ] # Dev testing variables no_ver_check = False fake_update = False # Updater variables version_list = [] is_checking_for_update = False checked_on_startup = False current_version = [] current_version_str = '' update_needed = False latest_version = None latest_version_str = '' used_updater_panel = False update_finished = False remind_me_later = False is_ignored_version = False confirm_update_to = '' show_error = '' file_replacement_extension = '.renamed' main_dir = os.path.dirname(__file__) downloads_dir = os.path.join(main_dir, downloads_dir_name) resources_dir = os.path.join(main_dir, "resources") ignore_ver_file = os.path.join(resources_dir, "ignore_version.txt") no_auto_ver_check_file = os.path.join(resources_dir, "no_auto_ver_check.txt") delete_files_on_startup_file = os.path.join(main_dir, "delete_files_on_startup.txt") compatibility_file = os.path.join(main_dir, "version_compatibility.json") # Compatibility checking variables compatibility_data = {} compatibility_loaded = False # Get package name, important for panel in user preferences package_name = '' for mod in addon_utils.modules(): if mod.bl_info['name'] == 'Rokoko Studio Live for Blender': package_name = mod.__name__ class Version: def __init__(self, data): # Set version string version_string = data.get('tag_name').lower().replace('-', '.').replace('_', '.') if version_string.startswith('v.'): version_string = version_string[2:] if version_string.startswith('v'): version_string = version_string[1:] # Set version number version_number = [] for i in version_string.split('.'): if i.isdigit(): version_number.append(int(i)) # Set version data self.version_string = version_string self.version_display_string = version_string self.version_number = version_number self.name = data.get('name') self.download_link = data.get('zipball_url') self.patch_notes = data.get('body') self.release_date = data.get('published_at') self.is_prerelease = data.get('prerelease') if 'T' in data.get('published_at')[1:]: self.release_date = data.get('published_at').split('T')[0] # If the name of the release contains "yanked", ignore it if 'yanked' in self.name.lower(): return if self.is_prerelease: self.version_display_string += ' (beta)' version_list.append(self) def get_version_by_string(version_string) -> Version: for version in version_list: if version.version_string == version_string: return version def get_latest_version() -> Version: version_list_releases = [version for version in version_list if not version.is_prerelease and is_version_compatible(version.version_string)] return version_list_releases[0] if version_list_releases else None def load_compatibility_data(): """Load the version compatibility JSON from GitHub.""" global compatibility_data, compatibility_loaded if compatibility_loaded: return True try: print("Fetching version compatibility data from GitHub...") ssl._create_default_https_context = ssl._create_unverified_context with urllib.request.urlopen(GITHUB_COMPATIBILITY_URL) as url: data = url.read().decode('utf-8') compatibility_data = json.loads(data) compatibility_loaded = True print("Loaded version compatibility data from GitHub") return True except urllib.error.URLError as e: print(f"Failed to fetch compatibility data from GitHub: {e}") # Try to load from local file as fallback try: if os.path.isfile(compatibility_file): with open(compatibility_file, 'r', encoding='utf-8') as f: compatibility_data = json.load(f) compatibility_loaded = True print("Loaded version compatibility data from local file as fallback") return True except Exception as local_e: print(f"Failed to load local compatibility file: \n{traceback.format_exc()}") print("No compatibility data available, all versions will be considered compatible") compatibility_data = {} compatibility_loaded = True return False except Exception as e: print(f"Error loading compatibility data: \n{traceback.format_exc()}") compatibility_data = {} compatibility_loaded = True return False def get_compatibility_for_version(addon_version_string): """Get compatibility info for a specific addon version. Since the JSON only contains versions where compatibility changed, we need to find the highest version <= the requested version. """ if not compatibility_data: return None # Convert version string to tuple for comparison def version_to_tuple(version_str): try: return tuple(int(x) for x in version_str.split('.')) except: return (0, 0, 0) addon_version_tuple = version_to_tuple(addon_version_string) # Find the highest version in compatibility data that is <= addon_version best_match = None best_match_tuple = (0, 0, 0) for compat_version in compatibility_data.keys(): compat_tuple = version_to_tuple(compat_version) if compat_tuple <= addon_version_tuple and compat_tuple > best_match_tuple: best_match = compat_version best_match_tuple = compat_tuple if best_match: return compatibility_data[best_match] return None def refresh_compatibility_data(): """Force refresh of compatibility data from GitHub.""" global compatibility_loaded compatibility_loaded = False return load_compatibility_data() def is_version_compatible(addon_version_string): """Check if an addon version is compatible with the current Blender version.""" # Load compatibility data if not already loaded if not load_compatibility_data(): # If no compatibility file, assume all versions are compatible return True # Get current Blender version as string blender_version = ".".join(str(x) for x in bpy.app.version) blender_version_tuple = bpy.app.version # Get compatibility info for this addon version compat_info = get_compatibility_for_version(addon_version_string) if not compat_info: # No compatibility info found, assume compatible return True # Check minimum version min_blender = compat_info.get('minimum_blender') if min_blender: try: min_tuple = tuple(int(x) for x in min_blender.split('.')) if blender_version_tuple < min_tuple: return False except: pass # Check maximum version max_blender = compat_info.get('maximum_blender') if max_blender: try: max_tuple = tuple(int(x) for x in max_blender.split('.')) if blender_version_tuple > max_tuple: return False except: pass return True def check_for_update_background(check_on_startup=False): global is_checking_for_update, checked_on_startup if check_on_startup and checked_on_startup: # print('ALREADY CHECKED ON STARTUP') return if is_checking_for_update: # print('ALREADY CHECKING') return checked_on_startup = True if check_on_startup and os.path.isfile(no_auto_ver_check_file): print('AUTO CHECK DISABLED VIA FILE') return is_checking_for_update = True thread = Thread(target=check_for_update, args=[]) thread.start() def check_for_update(): print('Checking for Rokoko Studio Live update...') # Refresh compatibility data from GitHub global compatibility_loaded compatibility_loaded = False # Force reload load_compatibility_data() # Get all releases from Github if not get_github_releases(): finish_update_checking(error='Could not check for updates,' '\ntry again later.') return if not version_list: finish_update_checking(error='No plugin versions available.') return # Check if an update is needed global update_needed, is_ignored_version update_needed = check_for_update_available() is_ignored_version = check_ignored_version() # Update needed, show the notification popup if it wasn't checked through the UI if update_needed: print('Update found!') if not used_updater_panel and not is_ignored_version: prepare_to_show_update_notification() else: print('No update found.') # Finish update checking, update the UI finish_update_checking() def get_github_releases(): global version_list version_list = [] if fake_update: print('FAKE INSTALL!') Version({ 'tag_name': '100.1', 'name': 'Pre release!', 'zipball_url': '', 'body': 'Nothing new to see', 'published_at': 'Just now!!', 'prerelease': True }) Version({ 'tag_name': 'v-99-99', 'name': 'v-99-99', 'zipball_url': '', 'body': 'Put exiting new stuff here\nOr maybe there is?', 'published_at': 'Today', 'prerelease': False }) Version({ 'tag_name': '12.34.56', 'name': '12.34.56 Test Release', 'zipball_url': '', 'body': 'Nothing new to see', 'published_at': 'A week ago probably', 'prerelease': False }) return True try: ssl._create_default_https_context = ssl._create_unverified_context with urllib.request.urlopen(GITHUB_URL) as url: data = json.loads(url.read().decode()) except urllib.error.URLError as e: print('URL ERROR:', e) return False if not data: if type(data) == list: return True return False for version_data in data: Version(version_data) return True def check_for_update_available() -> bool: if not version_list: return False global latest_version, latest_version_str latest_compatible_version = get_latest_version() # No compatible versions found if not latest_compatible_version: return False latest_version = latest_compatible_version.version_number latest_version_str = latest_compatible_version.version_string if latest_version > current_version: return True return False def finish_update_checking(error=''): global is_checking_for_update, show_error is_checking_for_update = False # Only show error if the update panel was used before if used_updater_panel: show_error = error ui_refresh() def ui_refresh(): # A way to refresh the ui refreshed = False while not refreshed: if hasattr(bpy.data, 'window_managers'): for windowManager in bpy.data.window_managers: for window in windowManager.windows: for area in window.screen.areas: area.tag_redraw() refreshed = True # print('Refreshed UI') else: time.sleep(0.5) def get_update_post(): if hasattr(bpy.app.handlers, 'scene_update_post'): return bpy.app.handlers.scene_update_post else: return bpy.app.handlers.depsgraph_update_post def prepare_to_show_update_notification(): return # TODO: Implement? # This is necessary to show a popup directly after startup # You will get a nasty error otherwise # This will add the function to the scene_update_post and it will be executed every frame. that's why it needs to be removed again asap # print('PREPARE TO SHOW UI') if show_update_notification not in get_update_post(): get_update_post().append(show_update_notification) @persistent def show_update_notification(scene): # One argument in necessary for some reason # print('SHOWING UI NOW!!!!') # # Immediately remove this from handlers again if show_update_notification in get_update_post(): get_update_post().remove(show_update_notification) # Show notification popup # atr = UpdateNotificationPopup.bl_idname.split(".") # getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') bpy.ops.rsl_updater.update_notification_popup('INVOKE_DEFAULT') def update_now(version=None, latest=False, beta=False): if fake_update: print('FAKE UPDATE TO VERSION:', version) finish_update() return if beta: print('UPDATE TO BETA') update_link = GITHUB_URL_BETA elif latest or not version: print('UPDATE TO ' + latest_version_str) update_link = get_latest_version().download_link bpy.context.scene.rsl_updater_version_list = latest_version_str else: print('UPDATE TO ' + version) update_link = get_version_by_string(version).download_link download_file(update_link) def download_file(update_url): if not update_url: finish_update() return # Load all the directories and files update_zip_file = os.path.join(downloads_dir, "rokoko-update.zip") # Remove existing download folder if os.path.isdir(downloads_dir): print("DOWNLOAD FOLDER EXISTED") shutil.rmtree(downloads_dir) # Create download folder pathlib.Path(downloads_dir).mkdir(exist_ok=True) # Download zip print('DOWNLOAD FILE') try: ssl._create_default_https_context = ssl._create_unverified_context urllib.request.urlretrieve(update_url, update_zip_file) except urllib.error.URLError: print("FILE COULD NOT BE DOWNLOADED") shutil.rmtree(downloads_dir) finish_update(error='Could not download update.') return print('DOWNLOAD FINISHED') # If zip is not downloaded, abort if not os.path.isfile(update_zip_file): print("ZIP NOT FOUND!") shutil.rmtree(downloads_dir) finish_update(error='Could not find the' '\ndownloaded zip.') return # Extract the downloaded zip print('EXTRACTING ZIP') with zipfile.ZipFile(update_zip_file, "r") as zip_ref: zip_ref.extractall(downloads_dir) print('EXTRACTED') # Delete the extracted zip file print('REMOVING ZIP FILE') os.remove(update_zip_file) # Detect the extracted folders and files print('SEARCHING FOR INIT 1') def search_init(path): print('SEARCHING IN ' + path) files = os.listdir(path) if "__init__.py" in files: print('FOUND') return path folders = [f for f in os.listdir(path) if os.path.isdir(os.path.join(path, f))] if len(folders) != 1: print(len(folders), 'FOLDERS DETECTED') return None print('GOING DEEPER') return search_init(os.path.join(path, folders[0])) print('SEARCHING FOR INIT 2') extracted_zip_dir = search_init(downloads_dir) if not extracted_zip_dir: print("INIT NOT FOUND!") shutil.rmtree(downloads_dir) finish_update(error='Could not find Rokoko Studio' '\nLive in the downloaded zip.') return # Remove old addon files clean_addon_dir() # Move the extracted files to their correct places def move_files(from_dir, to_dir): print('MOVE FILES TO DIR:', to_dir) files = os.listdir(from_dir) for file in files: source_path = os.path.join(from_dir, file) target_path = os.path.join(to_dir, file) print('MOVE', source_path) # If file exists, delete the target and move the new file over if os.path.isfile(source_path) and os.path.isfile(target_path): try: os.remove(target_path) except PermissionError as e: # If removing the target file failed, rename the new file, add its name to a file and move it over # It will re renamed on the next Blender startup print(e) source_path_renamed = os.path.join(from_dir, file) + file_replacement_extension os.rename(source_path, source_path_renamed) source_path = source_path_renamed print('File was not deleted, it will be replaced on the next startup') try: shutil.move(source_path, to_dir) except shutil.Error as e: print('Moving still failed:', e) print('REMOVED AND MOVED', file) elif os.path.isdir(source_path) and os.path.isdir(target_path): move_files(source_path, target_path) else: try: shutil.move(source_path, to_dir) except shutil.Error as e: print(e) print('MOVED', file) move_files(extracted_zip_dir, main_dir) # Delete download folder print('DELETE DOWNLOADS DIR') shutil.rmtree(downloads_dir) # Finish the update finish_update() def finish_update(error=''): global update_finished, show_error show_error = error if not error: update_finished = True bpy.ops.rsl_updater.update_complete_panel('INVOKE_DEFAULT') ui_refresh() print("UPDATE DONE!") def clean_addon_dir(): print("CLEAN ADDON FOLDER") # Convert paths to os specific paths paths_to_keep = [] for path_name in path_names_to_keep: path_parts = path_name.split('/') paths_to_keep.append(os.path.join(*path_parts)) for root, dirs, files in os.walk(main_dir, topdown=False): root_rel = os.path.relpath(root, main_dir) # Ignore folders that start with a dot. If the relative path is a dot only, it means that it's the main path which shouldn't be ignored if root_rel.startswith('.') and root_rel != '.': continue # Go over every file and decide whether to delete it or not for file in files: file_rel = os.path.join(root_rel, file) file_abs = os.path.join(root, file) if file_rel.startswith('.\\') or file_rel.startswith('./'): file_rel = file_rel[2:] # Keep the file if its exact name is on the ignore list if file_rel in paths_to_keep: continue # Keep the file if part of its path is on the ignore list keep_file = False for path in paths_to_keep: if file_rel.startswith(path): keep_file = True break if keep_file: continue # Delete the file try: os.remove(file_abs) print('Removed file', file_abs) except OSError: print('Failed to remove file', file_abs) add_file_to_delete_on_startup(file_abs) # Go over every folder and decide whether to delete it or not for folder in dirs: folder_rel = os.path.join(root_rel, folder) folder_abs = os.path.join(root, folder) if folder_rel.startswith('.\\'): folder_rel = folder_rel[2:] # Keep the folder if its exact name is on the ignore list if folder_rel in paths_to_keep: continue # Delete the folder. It won't get deleted if it's not empty and that is on purpose. # All files in the folder should be deleted already, so keep it if there are still files in it try: os.rmdir(folder_abs) print('Removed folder', folder_abs) except OSError: print('Failed to remove folder', folder_abs) def add_file_to_delete_on_startup(file_path): # w = create and write # a = append to end of file write_type = 'a' if os.path.isfile(delete_files_on_startup_file) else 'w' # Create or append "delete on startup" file with open(delete_files_on_startup_file, write_type, encoding="utf8") as outfile: outfile.write(file_path + '\n') def delete_and_rename_files_on_startup(): if not os.path.isfile(delete_files_on_startup_file): return with open(delete_files_on_startup_file, 'r', encoding="utf8") as outfile: lines = outfile.readlines() # Delete the file immediately to allow it to be recreated if something fails os.remove(delete_files_on_startup_file) for path in lines: if not path: continue # Remove the line separator from the end of the path path = path[:-1] if os.path.isfile(path): try: os.remove(path) print('Removed file on startup', path) except OSError: print('Failed to remove file on startup', path) add_file_to_delete_on_startup(path) continue path_renamed = path + file_replacement_extension if os.path.isfile(path_renamed): os.rename(path_renamed, path) print('Renamed', path_renamed, 'to', path) def set_ignored_version(): # Create resources folder pathlib.Path(resources_dir).mkdir(exist_ok=True) # Create ignore file with open(ignore_ver_file, 'w', encoding="utf8") as outfile: outfile.write(latest_version_str) # Set ignored status global is_ignored_version is_ignored_version = True print('IGNORE VERSION ' + latest_version_str) def check_ignored_version(): if not os.path.isfile(ignore_ver_file): # print('IGNORE FILE NOT FOUND') return False # Read ignore file with open(ignore_ver_file, 'r', encoding="utf8") as outfile: version = outfile.read() # Check if the latest version matches the one in the ignore file if latest_version_str == version: print('Update ignored.') return True # Delete ignore version file if the latest version is not the version in the file try: os.remove(ignore_ver_file) except OSError: print("FAILED TO REMOVE IGNORE VERSION FILE") return False def get_version_list(self, context): choices = [] for version in version_list: # Only include compatible versions if is_version_compatible(version.version_string): # 1. Will be returned by context.scene # 2. Will be shown in lists # 3. will be shown in the hover description (below description) choices.append((version.version_string, version.version_display_string, version.version_display_string)) else: # Add incompatible versions with a warning display_string = version.version_display_string + " (incompatible)" description = f"Version {version.version_string} is not compatible with Blender {'.'.join(str(x) for x in bpy.app.version)}" choices.append((version.version_string, display_string, description)) bpy.types.Object.Enum = choices return bpy.types.Object.Enum def get_user_preferences(): return bpy.context.user_preferences if hasattr(bpy.context, 'user_preferences') else bpy.context.preferences