import bpy import os import ssl import json import urllib import shutil import pathlib import zipfile import time from urllib import request, error from threading import Thread from bpy.app.handlers import persistent from .translations import t from .addon_preferences import get_preference, get_current_version, save_preference from ..ui.main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from typing import Dict, List, Tuple, Optional, Set, Any GITHUB_REPO = "teamneoneko/Avatar-Toolkit" # Define which version series this installation can update to # For example: ["0.1"] means only look for 0.1.x updates # ["0.2", "0.3"] would look for both 0.2.x and 0.3.x updates ALLOWED_VERSION_SERIES = ["0.3"] is_checking_for_update: bool = False update_needed: bool = False latest_version: Optional[str] = None latest_version_str: str = '' version_list: Optional[Dict[str, List[str]]] = None last_manual_check_time: float = 0 main_dir: str = os.path.dirname(os.path.dirname(__file__)) downloads_dir: str = os.path.join(main_dir, "downloads") class AvatarToolkit_OT_CheckForUpdate(bpy.types.Operator): bl_idname = 'avatar_toolkit.check_for_update' bl_label = t('CheckForUpdateButton.label') bl_description = t('CheckForUpdateButton.desc') bl_options = {'INTERNAL'} def execute(self, context: bpy.types.Context) -> Set[str]: global last_manual_check_time check_for_update_background() last_manual_check_time = time.time() # Reset the timer on manual check return {'FINISHED'} class AvatarToolkit_OT_UpdateToLatest(bpy.types.Operator): bl_idname = 'avatar_toolkit.update_latest' bl_label = t('UpdateToLatestButton.label') bl_description = t('UpdateToLatestButton.desc') bl_options = {'INTERNAL'} def execute(self, context: bpy.types.Context) -> Set[str]: update_now(latest=True) return {'FINISHED'} class AvatarToolkit_OT_UpdateNotificationPopup(bpy.types.Operator): bl_idname = "avatar_toolkit.update_notification_popup" bl_label = t('UpdateNotificationPopup.label') bl_description = t('UpdateNotificationPopup.desc') bl_options = {'INTERNAL'} def execute(self, context: bpy.types.Context) -> Set[str]: update_now(latest=True) self.report({'INFO'}, "Update started. Please wait for the process to complete.") return {'FINISHED'} def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]: return context.window_manager.invoke_props_dialog(self, width=300) def draw(self, context: bpy.types.Context) -> None: layout = self.layout col = layout.column(align=True) col.label(text=t('UpdateNotificationPopup.newUpdate', default="New update available: {version}").format(version=latest_version_str)) class AvatarToolkit_PT_UpdaterPanel(bpy.types.Panel): bl_label = t("Updater.label") bl_idname = "OBJECT_PT_avatar_toolkit_updater" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = CATEGORY_NAME bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname bl_order = 9 bl_options = {'DEFAULT_CLOSED'} def draw(self, context: bpy.types.Context) -> None: global last_manual_check_time layout = self.layout # Auto-check for updates when panel is drawn, but not too frequently current_time = time.time() if current_time - last_manual_check_time > 300: # 5 minutes between auto-checks if not is_checking_for_update and not update_needed: check_for_update_background() last_manual_check_time = current_time draw_updater_panel(context, layout) class AvatarToolkit_OT_RestartBlenderPopup(bpy.types.Operator): bl_idname = "avatar_toolkit.restart_blender_popup" bl_label = t('RestartBlenderPopup.label', default="Restart Blender") bl_description = t('RestartBlenderPopup.desc', default="Restart Blender to complete the update") bl_options = {'INTERNAL'} def execute(self, context: bpy.types.Context) -> Set[str]: return {'FINISHED'} def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]: return context.window_manager.invoke_props_dialog(self, width=300) def draw(self, context: bpy.types.Context) -> None: layout = self.layout col = layout.column(align=True) col.label(text=t('RestartBlenderPopup.message', default="Update successful! Please restart Blender.")) @persistent def check_for_update_on_start(dummy: Any) -> None: if get_preference("check_for_updates_on_startup", True): current_time = time.time() last_check = get_preference("last_update_check", 0) if current_time - last_check > 86400: # 24 hours check_for_update_background() save_preference("last_update_check", current_time) def check_for_update_background() -> None: global is_checking_for_update if is_checking_for_update: return is_checking_for_update = True Thread(target=check_for_update).start() def check_for_update() -> None: global update_needed, latest_version, latest_version_str, version_list if not get_github_releases(): bpy.app.timers.register(lambda: finish_update_checking(error=t('check_for_update.cantCheck'))) return update_needed = check_for_update_available() if update_needed: print('Update found!') bpy.app.timers.register(lambda: bpy.ops.avatar_toolkit.update_notification_popup('INVOKE_DEFAULT') or None) else: print('No update found.') bpy.app.timers.register(finish_update_checking) def get_github_releases() -> bool: global version_list version_list = {} try: ssl._create_default_https_context = ssl._create_unverified_context with request.urlopen(f'https://api.github.com/repos/{GITHUB_REPO}/releases') as url: data = json.loads(url.read().decode()) except error.URLError: print('URL ERROR') return False for version in data: version_tag = version['tag_name'] version_list[version_tag] = [ version['zipball_url'], version['body'], version['published_at'].split('T')[0] ] return True def check_for_update_available() -> bool: global latest_version, latest_version_str, version_list if not version_list: return False # Filter versions by allowed version series compatible_versions = {} for v, info in version_list.items(): for prefix in ALLOWED_VERSION_SERIES: if v.startswith(prefix): compatible_versions[v] = info break if not compatible_versions: print(f"No compatible versions found in series: {', '.join(ALLOWED_VERSION_SERIES)}") return False latest_version = max(compatible_versions.keys(), key=lambda v: [int(x) for x in v.split('.')]) latest_version_str = latest_version current_version = get_current_version() print(f"Current version: {current_version}") # Debugging statement if current_version is None: print("Current version is None. Cannot check for updates.") return False try: # Validate that the version string contains only numeric parts current_version_parts = [int(x) for x in current_version.split('.')] latest_version_parts = [int(x) for x in latest_version.split('.')] except ValueError as e: print(f"Error parsing version numbers: {e}") return False return latest_version_parts > current_version_parts def finish_update_checking(error: str = '') -> None: global is_checking_for_update is_checking_for_update = False if update_needed: bpy.ops.avatar_toolkit.update_notification_popup('INVOKE_DEFAULT') ui_refresh() return None # Important for bpy.app.timers def update_now(latest: bool = False) -> None: if not version_list: print("No version list available. Please check for updates first.") return if latest: # Filter compatible versions compatible_versions = {} for v, info in version_list.items(): for prefix in ALLOWED_VERSION_SERIES: if v.startswith(prefix): compatible_versions[v] = info break if not compatible_versions: print(f"No compatible versions found in series: {', '.join(ALLOWED_VERSION_SERIES)}") return latest_compatible = max(compatible_versions.keys(), key=lambda v: [int(x) for x in v.split('.')]) update_link = version_list[latest_compatible][0] else: selected_version = bpy.context.scene.avatar_toolkit_updater_version_list # Check if selected version is compatible is_compatible = False for prefix in ALLOWED_VERSION_SERIES: if selected_version.startswith(prefix): is_compatible = True break if not is_compatible: print(f"Selected version {selected_version} is not in allowed series: {', '.join(ALLOWED_VERSION_SERIES)}") return update_link = version_list[selected_version][0] download_file(update_link) ui_refresh() def download_file(update_url: str) -> None: update_zip_file = os.path.join(downloads_dir, "avatar-toolkit-update.zip") if os.path.isdir(downloads_dir): shutil.rmtree(downloads_dir) pathlib.Path(downloads_dir).mkdir(exist_ok=True) try: ssl._create_default_https_context = ssl._create_unverified_context urllib.request.urlretrieve(update_url, update_zip_file) except error.URLError: finish_update(error=t('download_file.cantConnect')) return if not os.path.isfile(update_zip_file): finish_update(error=t('download_file.cantFindZip')) return with zipfile.ZipFile(update_zip_file, "r") as zip_ref: zip_ref.extractall(downloads_dir) os.remove(update_zip_file) extracted_zip_dir = find_init_directory(downloads_dir) if not extracted_zip_dir: finish_update(error=t('download_file.cantFindAvatarToolkit')) return clean_addon_dir() move_files(extracted_zip_dir, main_dir) shutil.rmtree(downloads_dir) finish_update() def find_init_directory(path: str) -> Optional[str]: for root, dirs, files in os.walk(path): if "__init__.py" in files: return root return None def clean_addon_dir() -> None: for item in os.listdir(main_dir): item_path = os.path.join(main_dir, item) if item.startswith('.') or item in ['resources', 'downloads']: continue if os.path.isfile(item_path): os.remove(item_path) elif os.path.isdir(item_path): shutil.rmtree(item_path) def move_files(from_dir: str, to_dir: str) -> None: for item in os.listdir(from_dir): s = os.path.join(from_dir, item) d = os.path.join(to_dir, item) if os.path.isdir(s): shutil.copytree(s, d, dirs_exist_ok=True) else: shutil.copy2(s, d) def finish_update(error: str = '') -> None: if error: print(f"Update failed: {error}") else: print("Update successful!") save_preference("version", latest_version_str) bpy.ops.avatar_toolkit.restart_blender_popup('INVOKE_DEFAULT') ui_refresh() def get_version_list(self, context: bpy.types.Context) -> List[Tuple[str, str, str]]: if not version_list: return [] compatible_versions = [] for v in version_list.keys(): for prefix in ALLOWED_VERSION_SERIES: if v.startswith(prefix): compatible_versions.append(v) break return [(v, v, '') for v in compatible_versions] def draw_updater_panel(context: bpy.types.Context, layout: bpy.types.UILayout) -> None: box = layout.box() col = box.column(align=True) # Header row = col.row() row.scale_y = 1.2 row.label(text=t('Updater.label'), icon='DOWNARROW_HLT') col.separator() # Show compatibility info col.label(text=f"Update series: {', '.join(s + '.x' for s in ALLOWED_VERSION_SERIES)}", icon='INFO') col.label(text=f"Blender version: {bpy.app.version_string}", icon='BLENDER') col.separator() # Update check/status section if is_checking_for_update: col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname, text=t('Updater.CheckForUpdateButton.label'), icon='SORTTIME') elif update_needed: update_row = col.row(align=True) update_row.scale_y = 1.5 update_row.alert = True update_row.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname, text=t('Updater.UpdateToLatestButton.label', name=latest_version_str), icon='IMPORT') else: col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname, text=t('Updater.CheckForUpdateButton.label_alt'), icon='FILE_REFRESH') # Version selection section col.separator() box_inner = col.box() box_inner.label(text=t('Updater.selectVersion'), icon='SETTINGS') row = box_inner.row(align=True) row.prop(context.scene.avatar_toolkit, 'avatar_toolkit_updater_version_list', text='') row.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname, text=t('Updater.UpdateToSelectedButton.label'), icon='IMPORT') # Current version info col.separator() curr_ver_row = col.row() curr_ver_row.label(text=t('Updater.currentVersion').format(name=get_current_version()), icon='CHECKMARK') def ui_refresh() -> None: for windowManager in bpy.data.window_managers: for window in windowManager.windows: for area in window.screen.areas: area.tag_redraw()