Merge pull request #71 from Yusarina/Updater

Updater
This commit is contained in:
Onan Chew
2024-10-21 20:18:31 -04:00
committed by GitHub
8 changed files with 1325 additions and 6 deletions
+5
View File
@@ -7,6 +7,7 @@ if "bpy" not in locals():
from .core.register import __bl_ordered_classes from .core.register import __bl_ordered_classes
from .core import properties from .core import properties
from .core import addon_preferences from .core import addon_preferences
from .core.updater import check_for_update_on_start
else: else:
import importlib import importlib
importlib.reload(ui) importlib.reload(ui)
@@ -33,6 +34,8 @@ def register():
#finally register properties that may use some classes. #finally register properties that may use some classes.
core.register.register_properties() core.register.register_properties()
bpy.app.handlers.load_post.append(check_for_update_on_start)
from .functions.mesh_tools import AvatarToolkit_OT_ApplyShapeKey from .functions.mesh_tools import AvatarToolkit_OT_ApplyShapeKey
bpy.types.MESH_MT_shape_key_context_menu.append((lambda self, context: self.layout.separator())) bpy.types.MESH_MT_shape_key_context_menu.append((lambda self, context: self.layout.separator()))
@@ -41,6 +44,8 @@ def register():
def unregister(): def unregister():
print("Unregistering Avatar Toolkit") print("Unregistering Avatar Toolkit")
# Unregister the UI classes # Unregister the UI classes
if check_for_update_on_start in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(check_for_update_on_start)
# Iterate over the classes to unregister in reverse order and unregister them # Iterate over the classes to unregister in reverse order and unregister them
for cls in reversed(list(__bl_ordered_classes)): for cls in reversed(list(__bl_ordered_classes)):
+1 -1
View File
@@ -3,7 +3,7 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "avatar_toolkit" id = "avatar_toolkit"
version = "4.3.1" version = "0.1.0"
name = "Avatar Toolkit" name = "Avatar Toolkit"
tagline = "A modern tool for importing and optimizing models for VRChat, Resonite, and other similar games." tagline = "A modern tool for importing and optimizing models for VRChat, Resonite, and other similar games."
maintainer = "Team NekoNeo" maintainer = "Team NekoNeo"
+986
View File
@@ -0,0 +1,986 @@
# MIT License
import os
import ssl
import bpy
import time
import json
import urllib
import shutil
import pathlib
import zipfile
import addon_utils
from threading import Thread
from collections import OrderedDict
from bpy.app.handlers import persistent
from .tools.translations import t
from .tools.common import wrap_dynamic_enum_items
from . import CATS_VERSION, dev_branch
no_ver_check = False
fake_update = False
is_checking_for_update = False
checked_on_startup = False
version_list = None
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 = ''
main_dir = os.path.dirname(__file__)
downloads_dir = os.path.join(main_dir, "downloads")
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")
# Get package name, important for panel in user preferences
package_name = ''
for mod in addon_utils.modules():
if mod.bl_info['name'] == 'Cats Blender Plugin':
package_name = mod.__name__
# Icons for UI
ICON_URL = 'URL'
class CheckForUpdateButton(bpy.types.Operator):
bl_idname = 'cats_updater.check_for_update'
bl_label = t('CheckForUpdateButton.label')
bl_description = t('CheckForUpdateButton.desc')
bl_options = {'INTERNAL'}
@classmethod
def poll(cls, context):
return not is_checking_for_update
def execute(self, context):
global used_updater_panel
used_updater_panel = True
check_for_update_background()
return {'FINISHED'}
class UpdateToLatestButton(bpy.types.Operator):
bl_idname = 'cats_updater.update_latest'
bl_label = t('UpdateToLatestButton.label')
bl_description = t('UpdateToLatestButton.desc')
bl_options = {'INTERNAL'}
@classmethod
def poll(cls, context):
return update_needed
def execute(self, context):
global confirm_update_to, used_updater_panel
confirm_update_to = 'latest'
used_updater_panel = True
bpy.ops.cats_updater.confirm_update_panel('INVOKE_DEFAULT')
return {'FINISHED'}
class UpdateToSelectedButton(bpy.types.Operator):
bl_idname = 'cats_updater.update_selected'
bl_label = t('UpdateToSelectedButton.label')
bl_description = t('UpdateToSelectedButton.desc')
bl_options = {'INTERNAL'}
@classmethod
def poll(cls, context):
if is_checking_for_update or not version_list:
return False
return True
def execute(self, context):
global confirm_update_to, used_updater_panel
confirm_update_to = context.scene.cats_updater_version_list
used_updater_panel = True
bpy.ops.cats_updater.confirm_update_panel('INVOKE_DEFAULT')
return {'FINISHED'}
class UpdateToDevButton(bpy.types.Operator):
bl_idname = 'cats_updater.update_dev'
bl_label = t('UpdateToDevButton.label')
bl_description = t('UpdateToDevButton.desc')
bl_options = {'INTERNAL'}
def execute(self, context):
global confirm_update_to, used_updater_panel
confirm_update_to = 'dev'
used_updater_panel = True
bpy.ops.cats_updater.confirm_update_panel('INVOKE_DEFAULT')
return {'FINISHED'}
class RemindMeLaterButton(bpy.types.Operator):
bl_idname = 'cats_updater.remind_me_later'
bl_label = t('RemindMeLaterButton.label')
bl_description = t('RemindMeLaterButton.desc')
bl_options = {'INTERNAL'}
def execute(self, context):
global remind_me_later
remind_me_later = True
self.report({'INFO'}, t('RemindMeLaterButton.success'))
return {'FINISHED'}
class IgnoreThisVersionButton(bpy.types.Operator):
bl_idname = 'cats_updater.ignore_this_version'
bl_label = t('IgnoreThisVersionButton.label')
bl_description = t('IgnoreThisVersionButton.desc')
bl_options = {'INTERNAL'}
def execute(self, context):
set_ignored_version()
self.report({'INFO'}, t('IgnoreThisVersionButton.success', name=latest_version_str))
return {'FINISHED'}
class ShowPatchnotesPanel(bpy.types.Operator):
bl_idname = 'cats_updater.show_patchnotes'
bl_label = t('ShowPatchnotesPanel.label')
bl_description = t('ShowPatchnotesPanel.desc')
bl_options = {'INTERNAL'}
@classmethod
def poll(cls, context):
if is_checking_for_update or not version_list:
return False
return True
def execute(self, context):
return {'FINISHED'}
def invoke(self, context, event):
global used_updater_panel
used_updater_panel = True
dpi_value = get_user_preferences().system.dpi
return context.window_manager.invoke_props_dialog(self, width=int(dpi_value * 8.2))
def check(self, context):
# Important for changing options
return True
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
row = col.row(align=True)
row.prop(context.scene, 'cats_updater_version_list')
if context.scene.cats_updater_version_list:
version = version_list.get(context.scene.cats_updater_version_list)
col.separator()
row = col.row(align=True)
row.label(text=t('ShowPatchnotesPanel.releaseDate', date=version[2]))
col.separator()
for line in version[1].replace('**', '').split('\r\n'):
row = col.row(align=True)
row.scale_y = 0.75
row.label(text=line)
col.separator()
class ConfirmUpdatePanel(bpy.types.Operator):
bl_idname = 'cats_updater.confirm_update_panel'
bl_label = t('ConfirmUpdatePanel.label')
bl_description = t('ConfirmUpdatePanel.desc')
bl_options = {'INTERNAL'}
show_patchnotes = False
def execute(self, context):
print('UPDATE TO ' + confirm_update_to)
if confirm_update_to == 'dev':
update_now(dev=True)
elif confirm_update_to == 'latest':
update_now(latest=True)
else:
update_now(version=confirm_update_to)
return {'FINISHED'}
def invoke(self, context, event):
dpi_value = get_user_preferences().system.dpi
return context.window_manager.invoke_props_dialog(self, width=int(dpi_value * 4.1))
def check(self, context):
# Important for changing options
return True
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
version_str = confirm_update_to
if confirm_update_to == 'latest':
version_str = latest_version_str
elif confirm_update_to == 'dev':
version_str = 'Dev'
col.separator()
row = col.row(align=True)
row.label(text='Version: ' + version_str)
if confirm_update_to == 'dev':
col.separator()
col.separator()
row = col.row(align=True)
row.scale_y = 0.75
row.label(text=t('ConfirmUpdatePanel.warn.dev1'))
row = col.row(align=True)
row.scale_y = 0.75
row.label(text=t('ConfirmUpdatePanel.warn.dev2'))
row = col.row(align=True)
row.scale_y = 0.75
row.label(text=t('ConfirmUpdatePanel.warn.dev3'))
row = col.row(align=True)
row.scale_y = 0.75
row.label(text=t('ConfirmUpdatePanel.warn.dev4'))
row = col.row(align=True)
row.scale_y = 0.75
row.label(text=t('ConfirmUpdatePanel.warn.dev5'))
else:
row.operator(ShowPatchnotesPanel.bl_idname, text=t('ConfirmUpdatePanel.ShowPatchnotesPanel.label'))
col.separator()
col.separator()
# col.separator()
row = col.row(align=True)
row.scale_y = 0.65
# row.label(text='Update now to ' + version_str + ':', icon=ICON_URL)
row.label(text=t('ConfirmUpdatePanel.updateNow'), icon=ICON_URL)
class UpdateCompletePanel(bpy.types.Operator):
bl_idname = 'cats_updater.update_complete_panel'
bl_label = t('UpdateCompletePanel.label')
bl_description = t('UpdateCompletePanel.desc')
bl_options = {'INTERNAL'}
show_patchnotes = False
def execute(self, context):
return {'FINISHED'}
def invoke(self, context, event):
dpi_value = get_user_preferences().system.dpi
return context.window_manager.invoke_props_dialog(self, width=int(dpi_value * 4.1))
def check(self, context):
# Important for changing options
return True
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
if update_finished:
row = col.row(align=True)
row.scale_y = 0.9
row.label(text=t('UpdateCompletePanel.success1'), icon='FILE_TICK')
row = col.row(align=True)
row.scale_y = 0.9
row.label(text=t('UpdateCompletePanel.success2'), icon='BLANK1')
else:
row = col.row(align=True)
row.scale_y = 0.9
row.label(text=t('UpdateCompletePanel.failure1'), icon='CANCEL')
row = col.row(align=True)
row.scale_y = 0.9
row.label(text=t('UpdateCompletePanel.failure2'), icon='BLANK1')
class UpdateNotificationPopup(bpy.types.Operator):
bl_idname = 'cats_updater.update_notification_popup'
bl_label = t('UpdateNotificationPopup.label')
bl_description = t('UpdateNotificationPopup.desc')
bl_options = {'INTERNAL'}
def execute(self, context):
action = context.scene.cats_update_action
if action == 'UPDATE':
update_now(latest=True)
elif action == 'IGNORE':
set_ignored_version()
else:
# Remind later aka defer
global remind_me_later
remind_me_later = True
ui_refresh()
return {'FINISHED'}
def invoke(self, context, event):
dpi_value = get_user_preferences().system.dpi
return context.window_manager.invoke_props_dialog(self, width=int(dpi_value * 4.6))
# def invoke(self, context, event):
# return context.window_manager.invoke_props_dialog(self)
def check(self, context):
# Important for changing options
return True
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
row = layout_split(col, factor=0.55, align=True)
row.scale_y = 1.05
row.label(text=t('UpdateNotificationPopup.newUpdate', name=latest_version_str), icon='SOLO_ON')
row.operator(ShowPatchnotesPanel.bl_idname, text=t('UpdateNotificationPopup.ShowPatchnotesPanel.label'))
col.separator()
col.separator()
col.separator()
row = col.row(align=True)
row.prop(context.scene, 'cats_update_action', expand=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 Cats update...')
# Get all releases from Github
if not get_github_releases('teamneoneko'):
finish_update_checking(error=t('check_for_update.cantCheck'))
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(repo):
global version_list
version_list = OrderedDict()
if fake_update:
print('FAKE INSTALL!')
version = 'v-99-99-99'
version_tag = version.replace('-', '.')
if version_tag.startswith('v.'):
version_tag = version_tag[2:]
if version_tag.startswith('v'):
version_tag = version_tag[1:]
version_list[version_tag] = ['', 'Put exiting new stuff here', 'Today']
version_list['12.34.56.78'] = ['', 'Nothing new to see', 'A week ago probably']
return True
try:
ssl._create_default_https_context = ssl._create_unverified_context
with urllib.request.urlopen('https://api.github.com/repos/' + repo + '/Cats-Blender-Plugin-Unofficial-/releases') as url:
data = json.loads(url.read().decode())
except urllib.error.URLError:
print('URL ERROR')
return False
if not data:
return False
if bpy.app.version >= (4, 2) and bpy.app.version < (4, 3):
tag_prefix = "4.2."
for version in data:
full_tag = version.get('tag_name')
if not full_tag.startswith(tag_prefix):
continue
version_tag = full_tag[len(tag_prefix):]
# Normalize version_tag
version_tag = version_tag.replace('-', '.')
# Store full tag
version_list[full_tag] = [
version['zipball_url'],
version['body'],
version['published_at'].split('T')[0]
]
return True
def check_for_update_available():
if not version_list:
return False
global latest_version, latest_version_str
latest_version = []
for version in version_list.keys():
latest_version_str = version
for i in version.split('.'):
if i.isdigit():
latest_version.append(int(i))
if latest_version:
break
# print(latest_version, '>', current_version)
if latest_version > current_version:
return True
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():
# 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')
def update_now(version=None, latest=False, dev=False):
if fake_update:
finish_update()
return
if dev:
print('UPDATE TO DEVELOPMENT')
update_link = 'https://github.com/teamneoneko/Cats-Blender-Plugin-Unofficial-/archive/blender-42-dev.zip'
elif latest or not version:
print('UPDATE TO ' + latest_version_str)
update_link = version_list.get(latest_version_str)[0]
bpy.context.scene.cats_updater_version_list = latest_version_str
else:
print('UPDATE TO ' + version)
update_link = version_list[version][0]
download_file(update_link)
def download_file(update_url):
# Load all the directories and files
update_zip_file = os.path.join(downloads_dir, "cats-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=t('download_file.cantConnect'))
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=t('download_file.cantFindZip'))
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 searchInit(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 searchInit(os.path.join(path, folders[0]))
print('SEARCHING FOR INIT 2')
extracted_zip_dir = searchInit(downloads_dir)
if not extracted_zip_dir:
print("INIT NOT FOUND!")
shutil.rmtree(downloads_dir)
# finish_reloading()
finish_update(error=t('download_file.cantFindCATS'))
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:
file_dir = os.path.join(from_dir, file)
target_dir = os.path.join(to_dir, file)
print('MOVE', file_dir)
# If file exists
if os.path.isfile(file_dir) and os.path.isfile(target_dir):
os.remove(target_dir)
shutil.move(file_dir, to_dir)
print('REMOVED AND MOVED', file)
elif os.path.isdir(file_dir) and os.path.isdir(target_dir):
move_files(file_dir, target_dir)
else:
shutil.move(file_dir, to_dir)
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.cats_updater.update_complete_panel('INVOKE_DEFAULT')
ui_refresh()
print("UPDATE DONE!")
def clean_addon_dir():
print("CLEAN ADDON FOLDER")
# first remove root files and folders (except update folder, important folders and resource folder)
files = [f for f in os.listdir(main_dir) if os.path.isfile(os.path.join(main_dir, f))]
folders = [f for f in os.listdir(main_dir) if os.path.isdir(os.path.join(main_dir, f))]
for f in files:
file = os.path.join(main_dir, f)
try:
os.remove(file)
print("Clean removing file {}".format(file))
except OSError:
print("Failed to pre-remove file " + file)
for f in folders:
folder = os.path.join(main_dir, f)
if f.startswith('.') or f == 'resources' or f == 'downloads':
continue
try:
shutil.rmtree(folder)
print("Clean removing folder and contents {}".format(folder))
except OSError:
print("Failed to pre-remove folder " + folder)
# then remove resource files and folders (except settings and google dict)
resources_folder = os.path.join(main_dir, 'resources')
files = [f for f in os.listdir(resources_folder) if os.path.isfile(os.path.join(resources_folder, f))]
folders = [f for f in os.listdir(resources_folder) if os.path.isdir(os.path.join(resources_folder, f))]
for f in files:
if f == 'settings.json' or f == 'dictionary_google.json':
continue
file = os.path.join(resources_folder, f)
try:
os.remove(file)
print("Clean removing file {}".format(file))
except OSError:
print("Failed to pre-remove " + file)
for f in folders:
folder = os.path.join(resources_folder, f)
try:
shutil.rmtree(folder)
print("Clean removing folder and contents {}".format(folder))
except OSError:
print("Failed to pre-remove folder " + folder)
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 = []
if version_list:
for version in version_list.keys():
choices.append((version, version, version))
return choices
def get_user_preferences():
return bpy.context.user_preferences if hasattr(bpy.context, 'user_preferences') else bpy.context.preferences
def layout_split(layout, factor=0.0, align=False):
return layout.split(factor=factor, align=align)
def draw_update_notification_panel(layout):
if not update_needed or remind_me_later or is_ignored_version:
# pass
return
col = layout.column(align=True)
if update_finished:
col.separator()
row = col.row(align=True)
row.label(text=t('draw_update_notification_panel.success'), icon='ERROR')
col.separator()
return
row = col.row(align=True)
row.scale_y = 0.75
row.label(text=t('draw_update_notification_panel.newUpdate', name=latest_version_str), icon='SOLO_ON')
col.separator()
row = col.row(align=True)
row.scale_y = 1.3
row.operator(UpdateToLatestButton.bl_idname, text=t('draw_update_notification_panel.UpdateToLatestButton.label'))
row = col.row(align=True)
row.scale_y = 1
row.operator(RemindMeLaterButton.bl_idname, text=t('draw_update_notification_panel.RemindMeLaterButton.label'))
row.operator(IgnoreThisVersionButton.bl_idname, text=t('draw_update_notification_panel.IgnoreThisVersionButton.label'))
def draw_updater_panel(context, layout, user_preferences=False):
col = layout.column(align=True)
scale_big = 2
scale_small = 1.2
row = col.row(align=True)
row.scale_y = 0.8
row.label(text=t('draw_updater_panel.updateLabel') if not user_preferences else t('draw_updater_panel.updateLabel_alt'), icon=ICON_URL)
col.separator()
if update_finished:
col.separator()
row = col.row(align=True)
row.label(text=t('draw_updater_panel.success'), icon='ERROR')
col.separator()
return
if show_error:
row = col.row(align=True)
row.label(text=show_error, icon='ERROR')
col.separator()
if is_checking_for_update:
if not used_updater_panel:
row = col.row(align=True)
row.scale_y = scale_big
row.operator(CheckForUpdateButton.bl_idname, text=t('draw_updater_panel.CheckForUpdateButton.label'))
else:
split = col.row(align=True)
row = split.row(align=True)
row.scale_y = scale_big
row.operator(CheckForUpdateButton.bl_idname, text=t('draw_updater_panel.CheckForUpdateButton.label'))
row = split.row(align=True)
row.alignment = 'RIGHT'
row.scale_y = scale_big
row.operator(CheckForUpdateButton.bl_idname, text="", icon='FILE_REFRESH')
elif update_needed:
split = col.row(align=True)
row = split.row(align=True)
row.scale_y = scale_big
row.operator(UpdateToLatestButton.bl_idname, text=t('draw_updater_panel.UpdateToLatestButton.label', name=latest_version_str))
row = split.row(align=True)
row.alignment = 'RIGHT'
row.scale_y = scale_big
row.operator(CheckForUpdateButton.bl_idname, text="", icon='FILE_REFRESH')
elif not used_updater_panel or not version_list:
row = col.row(align=True)
row.scale_y = scale_big
row.operator(CheckForUpdateButton.bl_idname, text=t('draw_updater_panel.CheckForUpdateButton.label_alt'))
else:
split = col.row(align=True)
row = split.row(align=True)
row.scale_y = scale_big
row.operator(UpdateToLatestButton.bl_idname, text=t('draw_updater_panel.UpdateToLatestButton.label_alt'))
row = split.row(align=True)
row.alignment = 'RIGHT'
row.scale_y = scale_big
row.operator(CheckForUpdateButton.bl_idname, text="", icon='FILE_REFRESH')
# col.separator()
# col.separator()
# col.separator()
# row = layout_split(col, factor=0.6, align=True)
# row.scale_y = 0.9
# row.active = True if not is_checking_for_update and version_list else False
# row.label(text="Select Version:")
# row.prop(context.scene, 'cats_updater_version_list', text='')
#
# row = layout_split(col, factor=0.6, align=True)
# row.scale_y = scale_small
# row.operator(UpdateToSelectedButton.bl_idname, text='Install Selected Version')
# row.operator(ShowPatchnotesPanel.bl_idname, text='Show Patchnotes')
col.separator()
col.separator()
split = col.row(align=True)
row = layout_split(split, factor=0.55, align=True)
row.scale_y = scale_small
row.active = True if not is_checking_for_update and version_list else False
row.operator(UpdateToSelectedButton.bl_idname, text=t('draw_updater_panel.UpdateToSelectedButton.label'))
row.prop(context.scene, 'cats_updater_version_list', text='')
row = split.row(align=True)
row.scale_y = scale_small
row.operator(ShowPatchnotesPanel.bl_idname, text="", icon='WORDWRAP_ON')
# topsplit = layout_split(col, factor=0.55, align=True)
#
# split = topsplit.row(align=True)
# row = split.row(align=True)
# row.scale_y = scale_small
# row.active = True if not is_checking_for_update and version_list else False
# row.operator(UpdateToSelectedButton.bl_idname, text='Install Version:')
#
# row = split.row(align=True)
# row.alignment = 'RIGHT'
# row.scale_y = scale_small
# row.operator(ShowPatchnotesPanel.bl_idname, text="", icon='WORDWRAP_ON')
#
# row = topsplit.row(align=True)
# row.scale_y = scale_small
# row.prop(context.scene, 'cats_updater_version_list', text='')
row = col.row(align=True)
row.scale_y = scale_small
row.operator(UpdateToDevButton.bl_idname, text=t('draw_updater_panel.UpdateToDevButton.label'))
col.separator()
row = col.row(align=True)
row.scale_y = 0.65
row.label(text=t('draw_updater_panel.currentVersion', name=current_version_str))
# demo bare-bones preferences
class DemoPreferences(bpy.types.AddonPreferences):
bl_idname = package_name
def draw(self, context):
layout = self.layout
draw_updater_panel(context, layout, user_preferences=True)
to_register = [
CheckForUpdateButton,
UpdateToLatestButton,
UpdateToSelectedButton,
UpdateToDevButton,
RemindMeLaterButton,
IgnoreThisVersionButton,
ShowPatchnotesPanel,
ConfirmUpdatePanel,
UpdateCompletePanel,
UpdateNotificationPopup,
DemoPreferences,
]
def register(dev_branch, version_str):
# print('REGISTER CATS UPDATER')
global current_version, fake_update, current_version_str
# If not dev branch, always disable fake update!
if not dev_branch:
fake_update = False
current_version_str = version_str
# Get current version
current_version = []
version_parts = CATS_VERSION.split(".")
for part in version_parts:
current_version.append(int(part))
bpy.types.Scene.cats_updater_version_list = bpy.props.EnumProperty(
name=t('bpy.types.Scene.cats_updater_version_list.label'),
description=t('bpy.types.Scene.cats_updater_version_list.desc'),
items=wrap_dynamic_enum_items(get_version_list, 'cats_updater_version_list', sort=False)
)
bpy.types.Scene.cats_update_action = bpy.props.EnumProperty(
name=t('bpy.types.Scene.cats_update_action.label'),
description=t('bpy.types.Scene.cats_update_action.desc'),
items=[
("UPDATE", t('bpy.types.Scene.cats_update_action.update.label'), t('bpy.types.Scene.cats_update_action.update.desc')),
("IGNORE", t('bpy.types.Scene.cats_update_action.ignore.label'), t( 'bpy.types.Scene.cats_update_action.ignore.desc')),
("DEFER", t('bpy.types.Scene.cats_update_action.defer.label'), t( 'bpy.types.Scene.cats_update_action.defer.desc'))
]
)
# Register all Updater classes
count = 0
for cls in to_register:
try:
bpy.utils.register_class(cls)
count += 1
except ValueError:
pass
# print('Registered', count, 'CATS updater classes.')
if count < len(to_register):
print('Skipped', len(to_register) - count, 'CATS updater classes.')
def unregister():
# Unregister all Updater classes
for cls in reversed(to_register):
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
pass
if hasattr(bpy.types.Scene, 'cats_updater_version_list'):
del bpy.types.Scene.cats_updater_version_list
+8
View File
@@ -1,5 +1,6 @@
import bpy import bpy
import os import os
import toml
import json import json
from bpy.types import AddonPreferences from bpy.types import AddonPreferences
from typing import Any, Dict from typing import Any, Dict
@@ -8,6 +9,13 @@ from typing import Any, Dict
PREFERENCES_DIR = os.path.dirname(os.path.abspath(__file__)) PREFERENCES_DIR = os.path.dirname(os.path.abspath(__file__))
PREFERENCES_FILE = os.path.join(PREFERENCES_DIR, "preferences.json") PREFERENCES_FILE = os.path.join(PREFERENCES_DIR, "preferences.json")
def get_current_version():
main_dir = os.path.dirname(os.path.dirname(__file__))
manifest_path = os.path.join(main_dir, "blender_manifest.toml")
with open(manifest_path, 'r') as f:
manifest_data = toml.load(f)
return manifest_data.get('version', 'Unknown')
def save_preference(key: str, value: Any) -> None: def save_preference(key: str, value: Any) -> None:
"""Save a single preference to the JSON file.""" """Save a single preference to the JSON file."""
prefs = load_preferences() prefs = load_preferences()
+7 -1
View File
@@ -5,6 +5,7 @@ from bpy.types import Scene, Object, Material, Context
from bpy.props import BoolProperty, EnumProperty, IntProperty, CollectionProperty, StringProperty, FloatVectorProperty, PointerProperty from bpy.props import BoolProperty, EnumProperty, IntProperty, CollectionProperty, StringProperty, FloatVectorProperty, PointerProperty
from ..core.addon_preferences import get_preference from ..core.addon_preferences import get_preference
from ..core.common import SceneMatClass, MaterialListBool, get_armatures, get_mesh_items, get_armatures_that_are_not_selected from ..core.common import SceneMatClass, MaterialListBool, get_armatures, get_mesh_items, get_armatures_that_are_not_selected
from .updater import get_version_list
def register() -> None: def register() -> None:
default_language = get_preference("language", 0) default_language = get_preference("language", 0)
@@ -74,6 +75,12 @@ def register() -> None:
name=t("Quick_Access.selected_armature.label"), name=t("Quick_Access.selected_armature.label"),
description=t("Quick_Access.selected_armature.desc") description=t("Quick_Access.selected_armature.desc")
))) )))
register_property((bpy.types.Scene, "avatar_toolkit_updater_version_list", bpy.props.EnumProperty(
name=t('Scene.avatar_toolkit_updater_version_list.name'),
description=t('Scene.avatar_toolkit_updater_version_list.description'),
items=get_version_list
)))
#happy with how compressed this get_texture_node_list method is - @989onan #happy with how compressed this get_texture_node_list method is - @989onan
def get_texture_node_list(self: Material, context: Context) -> list[set[3]]: def get_texture_node_list(self: Material, context: Context) -> list[set[3]]:
@@ -131,7 +138,6 @@ def register() -> None:
get=MaterialListBool.get_bool, get=MaterialListBool.get_bool,
set=MaterialListBool.set_bool))) set=MaterialListBool.set_bool)))
def unregister() -> None: def unregister() -> None:
#if you register properties with register_property then you shouldn't need this function. #if you register properties with register_property then you shouldn't need this function.
pass pass
+296
View File
@@ -0,0 +1,296 @@
import bpy
import os
import ssl
import json
import urllib
import shutil
import pathlib
import zipfile
import time
from threading import Thread
from bpy.app.handlers import persistent
from ..functions.translations import t
from .addon_preferences import get_preference, get_current_version, save_preference
from .register import register_wrap
from ..ui.panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from typing import Dict, List, Tuple, Optional, Set, Any
GITHUB_REPO = "teamneoneko/Avatar-Toolkit"
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
main_dir: str = os.path.dirname(os.path.dirname(__file__))
downloads_dir: str = os.path.join(main_dir, "downloads")
@register_wrap
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]:
check_for_update_background()
return {'FINISHED'}
@register_wrap
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'}
@register_wrap
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))
@register_wrap
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
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
draw_updater_panel(context, layout)
@register_wrap
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 urllib.request.urlopen(f'https://api.github.com/repos/{GITHUB_REPO}/releases') as url:
data = json.loads(url.read().decode())
except urllib.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
if not version_list:
return False
latest_version = max(version_list.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 latest:
update_link = version_list[latest_version_str][0]
else:
update_link = version_list[bpy.context.scene.avatar_toolkit_updater_version_list][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 urllib.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]]:
return [(v, v, '') for v in version_list.keys()] if version_list else []
def draw_updater_panel(context: bpy.types.Context, layout: bpy.types.UILayout) -> None:
col = layout.column(align=True)
if is_checking_for_update:
col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname, text=t('Updater.CheckForUpdateButton.label'))
elif update_needed:
col.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname, text=t('Updater.UpdateToLatestButton.label', name=latest_version_str))
else:
col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname, text=t('Updater.CheckForUpdateButton.label_alt'))
col.separator()
row = col.row(align=True)
row.prop(context.scene, 'avatar_toolkit_updater_version_list', text='')
row.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname, text=t('Updater.UpdateToSelectedButton.label'))
col.separator()
col.label(text=t('Updater.currentVersion').format(name=get_current_version()))
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()
+2 -2
View File
@@ -66,14 +66,14 @@ def load_translations() -> bool:
return dictionary != old_dictionary return dictionary != old_dictionary
def t(phrase: str, default: str = None) -> str: def t(phrase: str, default: str = None, **kwargs) -> str:
output: str = dictionary.get(phrase) output: str = dictionary.get(phrase)
if output is None: if output is None:
if verbose: if verbose:
print(f'Warning: Unknown phrase: {phrase}') print(f'Warning: Unknown phrase: {phrase}')
return default if default is not None else phrase return default if default is not None else phrase
# print(f"Translating '{phrase}' to '{output}'") # Debug print # print(f"Translating '{phrase}' to '{output}'") # Debug print
return output return output.format(**kwargs) if kwargs else output
def get_language_display_name(lang: str) -> str: def get_language_display_name(lang: str) -> str:
if lang == "auto": if lang == "auto":
+20 -2
View File
@@ -249,7 +249,25 @@
"MMDOptions.renaming_bones": "Renaming bones", "MMDOptions.renaming_bones": "Renaming bones",
"MMDOptions.armature_optimization_complete": "Armature optimization complete", "MMDOptions.armature_optimization_complete": "Armature optimization complete",
"MMDOptions.convert_materials.label": "Convert Materials", "MMDOptions.convert_materials.label": "Convert Materials",
"MMDOptions.convert_materials.desc": "Convert materials to use Principled BSDF shader and fix MMD and VRM shaders", "MMDOptions.converting_materials": "Converting materials for {name}" "MMDOptions.convert_materials.desc": "Convert materials to use Principled BSDF shader and fix MMD and VRM shaders", "MMDOptions.converting_materials": "Converting materials for {name}",
"Updater.label": "Updater",
"Updater.CheckForUpdateButton.label": "Check for Updates",
"Updater.CheckForUpdateButton.label_alt": "No Updates Available",
"Updater.UpdateToLatestButton.label": "Update to {name}",
"Updater.UpdateToSelectedButton.label": "Update",
"Updater.currentVersion": "Current Version: {name}",
"Updater.CheckForUpdateButton.desc": "Check for available updates",
"UpdateToLatestButton.desc": "Update to the latest version",
"UpdateNotificationPopup.label": "Update Notification",
"UpdateNotificationPopup.desc": "Notification about available updates",
"UpdateNotificationPopup.newUpdate": "New update available: {version}",
"RestartBlenderPopup.label": "Restart Blender",
"RestartBlenderPopup.desc": "Restart Blender to complete the update",
"RestartBlenderPopup.message": "Update successful! Please restart Blender.",
"check_for_update.cantCheck": "Unable to check for updates",
"download_file.cantConnect": "Cannot connect to update server",
"download_file.cantFindZip": "Update file not found",
"download_file.cantFindAvatarToolkit": "Avatar Toolkit files not found in update package"
} }
} }