Files
blender-portable-repo/scripts/addons/rokoko-studio-live-blender-master/updater.py
T
2026-03-17 14:58:51 -06:00

749 lines
24 KiB
Python

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