diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6169be4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +gaffer_updater \ No newline at end of file diff --git a/__init__.py b/__init__.py index 9064a4e..7c0fc71 100644 --- a/__init__.py +++ b/__init__.py @@ -38,6 +38,7 @@ from . import constants, functions, operators, ui import bpy +from . import addon_updater_ops from collections import OrderedDict import bgl, blf from math import pi, cos, sin, log @@ -46,6 +47,47 @@ from bpy.app.handlers import persistent +class GafferPreferences(bpy.types.AddonPreferences): + bl_idname = __package__ + + # addon updater preferences + auto_check_update = bpy.props.BoolProperty( + name = "Auto-check for Update", + description = "If enabled, auto-check for updates using an interval", + default = True, + ) + updater_intrval_months = bpy.props.IntProperty( + name='Months', + description = "Number of months between checking for updates", + default=0, + min=0 + ) + updater_intrval_days = bpy.props.IntProperty( + name='Days', + description = "Number of days between checking for updates", + default=1, + min=0, + ) + updater_intrval_hours = bpy.props.IntProperty( + name='Hours', + description = "Number of hours between checking for updates", + default=0, + min=0, + max=23 + ) + updater_intrval_minutes = bpy.props.IntProperty( + name='Minutes', + description = "Number of minutes between checking for updates", + default=0, + min=0, + max=59 + ) + + def draw(self, context): + layout = self.layout + addon_updater_ops.update_settings_ui(self,context) + + def do_set_world_refl_only(context): scene = context.scene if scene.gaf_props.WorldReflOnly and not scene.gaf_props.WorldVis: @@ -237,6 +279,7 @@ class GafferProperties(bpy.types.PropertyGroup): Blacklist = bpy.props.CollectionProperty(type=BlacklistedObject) # must be registered after classes def register(): + addon_updater_ops.register(bl_info) bpy.types.NODE_PT_active_node_generic.append(ui.gaffer_node_menu_func) bpy.utils.register_module(__name__) bpy.types.Scene.gaf_props = bpy.props.PointerProperty(type=GafferProperties) diff --git a/addon_updater.py b/addon_updater.py new file mode 100644 index 0000000..3e9afa7 --- /dev/null +++ b/addon_updater.py @@ -0,0 +1,1072 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + + +""" +See documentation for usage +https://github.com/CGCookie/blender-addon-updater + +""" + +import urllib.request +import urllib +import os +import json +import zipfile +import shutil +import asyncio # for async processing +import threading +import time +from datetime import datetime,timedelta + +# blender imports, used in limited cases +import bpy +import addon_utils + +# ----------------------------------------------------------------------------- +# Define error messages/notices & hard coded globals +# ----------------------------------------------------------------------------- + +DEFAULT_API_URL = "https://api.github.com" # plausibly could be some other system +DEFAULT_TIMEOUT = 10 +DEFAULT_PER_PAGE = 30 + + +# ----------------------------------------------------------------------------- +# The main class +# ----------------------------------------------------------------------------- + +class Singleton_updater(object): + """ + This is the singleton class to reference a copy from, + it is the shared module level class + """ + + def __init__(self): + """ + #UPDATE + :param user: string # name of the user owning the repository + :param repo: string # name of the repository + :param api_url: string # should just be the github api link + :param timeout: integer # request timeout + :param current_version: tuple # typically 3 values meaning the version # + """ + + self._user = None + self._repo = None + self._website = None + self._api_url = DEFAULT_API_URL + self._current_version = None + self._tags = [] + self._tag_latest = None + self._tag_names = [] + self._latest_release = None + self._include_master = False + self._manual_only = False + self._version_min_update = None + self._version_max_update = None + + # by default, backup current addon if new is being loaded + self._backup_current = True + + # by default, enable/disable the addon.. but less safe. + self._auto_reload_post_update = False + + self._check_interval_enable = False + self._check_interval_months = 0 + self._check_interval_days = 7 + self._check_interval_hours = 0 + self._check_interval_minutes = 0 + + # runtime variables, initial conditions + self._verbose = False + self._fake_install = False + self._async_checking = False # only true when async daemon started + self._update_ready = None + self._update_link = None + self._update_version = None + self._source_zip = None + self._check_thread = None + self._skip_tag = None + + # get from module data + self._addon = __package__.lower() + self._addon_package = __package__ # must not change + self._updater_path = os.path.join(os.path.dirname(__file__), + self._addon+"_updater") + self._addon_root = os.path.dirname(__file__) + self._json = {} + self._error = None + self._error_msg = None + self._prefiltered_tag_count = 0 + + + # ------------------------------------------------------------------------- + # Getters and setters + # ------------------------------------------------------------------------- + + @property + def addon(self): + return self._addon + @addon.setter + def addon(self, value): + self._addon = str(value) + + @property + def verbose(self): + return self._verbose + @verbose.setter + def verbose(self, value): + try: + self._verbose = bool(value) + if self._verbose == True: + print(self._addon+" updater verbose is enabled") + except: + raise ValueError("Verbose must be a boolean value") + + @property + def include_master(self): + return self._include_master + @include_master.setter + def include_master(self, value): + try: + self._include_master = bool(value) + except: + raise ValueError("include_master must be a boolean value") + + @property + def manual_only(self): + return self._manual_only + @manual_only.setter + def manual_only(self, value): + try: + self._manual_only = bool(value) + except: + raise ValueError("manual_only must be a boolean value") + + @property + def auto_reload_post_update(self): + return self._auto_reload_post_update + @auto_reload_post_update.setter + def auto_reload_post_update(self, value): + try: + self._auto_reload_post_update = bool(value) + except: + raise ValueError("Must be a boolean value") + + @property + def fake_install(self): + return self._verbose + @fake_install.setter + def fake_install(self, value): + if type(value) != type(False): + raise ValueError("Verbose must be a boolean value") + self._fake_install = bool(value) + + @property + def user(self): + return self._user + @user.setter + def user(self, value): + try: + self._user = str(value) + except: + raise ValueError("User must be a string value") + + @property + def json(self): + if self._json == {}: + self.set_updater_json() + return self._json + + @property + def repo(self): + return self._repo + @repo.setter + def repo(self, value): + try: + self._repo = str(value) + except: + raise ValueError("User must be a string") + + @property + def website(self): + return self._website + @website.setter + def website(self, value): + if self.check_is_url(value) == False: + raise ValueError("Not a valid URL: " + value) + self._website = value + + @property + def async_checking(self): + return self._async_checking + + @property + def api_url(self): + return self._api_url + @api_url.setter + def api_url(self, value): + if self.check_is_url(value) == False: + raise ValueError("Not a valid URL: " + value) + self._api_url = value + + @property + def stage_path(self): + return self._updater_path + @stage_path.setter + def stage_path(self, value): + if value == None: + if self._verbose:print("Aborting assigning stage_path, it's null") + return + elif value != None and not os.path.exists(value): + try: + os.makedirs(value) + except: + if self._verbose:print("Error trying to staging path") + return + # definitely check for errors here, user issues + self._updater_path = value + + + @property + def tags(self): + if self._tags == []: + return [] + tag_names = [] + for tag in self._tags: + tag_names.append(tag["name"]) + + return tag_names + + @property + def tag_latest(self): + if self._tag_latest == None: + return None + return self._tag_latest["name"] + + @property + def latest_release(self): + if self._releases_latest == None: + return None + return self._latest_release + + @property + def current_version(self): + return self._current_version + + @property + def update_ready(self): + return self._update_ready + + @property + def update_version(self): + return self._update_version + + @property + def update_link(self): + return self._update_link + + @current_version.setter + def current_version(self,tuple_values): + if type(tuple_values) is not tuple: + raise ValueError(\ + "Not a tuple! current_version must be a tuple of integers") + for i in tuple_values: + if type(i) is not int: + raise ValueError(\ + "Not an integer! current_version must be a tuple of integers") + self._current_version = tuple_values + + def set_check_interval(self,enable=False,months=0,days=14,hours=0,minutes=0): + # enabled = False, default initially will not check against frequency + # if enabled, default is then 2 weeks + + if type(enable) is not bool: + raise ValueError("Enable must be a boolean value") + if type(months) is not int: + raise ValueError("Months must be an integer value") + if type(days) is not int: + raise ValueError("Days must be an integer value") + if type(hours) is not int: + raise ValueError("Hours must be an integer value") + if type(minutes) is not int: + raise ValueError("Minutes must be an integer value") + + if enable==False: + self._check_interval_enable = False + else: + self._check_interval_enable = True + + self._check_interval_months = months + self._check_interval_days = days + self._check_interval_hours = hours + self._check_interval_minutes = minutes + + @property + def check_interval(self): + return (self._check_interval_enable, + self._check_interval_months, + self._check_interval_days, + self._check_interval_hours, + self._check_interval_minutes) + + @property + def error(self): + return self._error + + @property + def error_msg(self): + return self._error_msg + + @property + def version_min_update(self): + return self._version_min_update + @version_min_update.setter + def version_min_update(self, value): + if value == None: + self._version_min_update = None + return + if type(value) != type((1,2,3)): + raise ValueError("Version minimum must be a tuple") + else: + # potentially check entries are integers + self._version_min_update = value + + + @property + def version_max_update(self): + return self._version_max_update + @version_max_update.setter + def version_max_update(self, value): + if value == None: + self._version_max_update = None + return + if type(value) != type((1,2,3)): + raise ValueError("Version maximum must be a tuple") + else: + # potentially check entries are integers + self._version_max_update = value + + + + # ------------------------------------------------------------------------- + # Parameter validation related functions + # ------------------------------------------------------------------------- + + + def check_is_url(self,url): + if not ("http://" in url or "https://" in url): + return False + if "." not in url: + return False + return True + + def get_tag_names(self): + tag_names = [] + self.get_tags(self) + for tag in self._tags: + tag_names.append(tag["name"]) + return tag_names + + # declare how the class gets printed + + def __repr__(self): + return "".format(a=__file__) + + def __str__(self): + return "Updater, with user: {a}, repository: {b}, url: {c}".format( + a=self._user, + b=self._repo, c=self.form_repo_url()) + + + # ------------------------------------------------------------------------- + # API-related functions + # ------------------------------------------------------------------------- + + def form_repo_url(self): + return self._api_url+"/repos/"+self.user+"/"+self.repo + + + def get_tags(self): + request = "/repos/"+self.user+"/"+self.repo+"/tags" + if self.verbose:print("Getting tags from server") + + # get all tags, internet call + all_tags = self.get_api(request) + self._prefiltered_tag_count = len(all_tags) + + # pre-process to skip tags + if self.skip_tag != None: + self._tags = [tg for tg in all_tags if self.skip_tag(tg)==False] + else: + self._tags = all_tags + + # get master too, if needed, and place in front but not actively + if self._include_master == True: + request = self._api_url +"/repos/" \ + +self.user+"/"+self.repo+"/zipball/master" + master = { + "name":"Master", + "zipball_url":request + } + self._tags = [master] + self._tags # append to front + + if self._tags == None: + # some error occured + self._tag_latest = None + self._tags = [] + return + elif self._prefiltered_tag_count == 0 and self._include_master == False: + self._tag_latest = None + self._error = "No releases found" + self._error_msg = "No releases or tags found on this repository" + if self.verbose:print("No releases or tags found on this repository") + elif self._prefiltered_tag_count == 0 and self._include_master == True: + self._tag_latest = self._tags[0] + if self.verbose:print("Only master branch found:",self._tags[0]) + elif len(self._tags) == 0 and self._prefiltered_tag_count > 0: + self._tag_latest = None + self._error = "No releases available" + self._error_msg = "No versions found within compatible version range" + if self.verbose:print("No versions found within compatible version range") + else: + self._tag_latest = self._tags[0] + if self.verbose:print("Most recent tag found:",self._tags[0]) + + + # all API calls to base url + def get_api_raw(self, url): + request = urllib.request.Request(self._api_url + url) + try: + result = urllib.request.urlopen(request) + except urllib.error.HTTPError as e: + self._error = "HTTP error" + if str(e.code) == '404': + self._error_msg = "404 - repository not found, verify register settings" + else: + self._error_msg = "Response: "+str(e.code) + self._update_ready = None + except urllib.error.URLError as e: + self._error = "URL error, check internet connection" + self._error_msg = str(e.reason) + self._update_ready = None + return None + else: + result_string = result.read() + result.close() + return result_string.decode() + # if we didn't get here, return or raise something else + + + # result of all api calls, decoded into json format + def get_api(self, url): + # return the json version + get = None + get = self.get_api_raw(url) # this can fail by self-created error raising + if get != None: + return json.JSONDecoder().decode( get ) + else: + return None + + + # create a working directory and download the new files + def stage_repository(self, url): + + # first make/clear the staging folder + # ensure the folder is always "clean" + local = os.path.join(self._updater_path,"update_staging") + error = None + + if self._verbose:print("Preparing staging folder for download:\n",local) + if os.path.isdir(local) == True: + try: + shutil.rmtree(local) + os.makedirs(local) + except: + error = "failed to remove existing staging directory" + else: + try: + os.makedirs(local) + except: + error = "failed to make staging directory" + + if error != None: + if self._verbose: print("Error: Aborting update, "+error) + raise ValueError("Aborting update, "+error) + + if self._backup_current==True: + self.create_backup() + if self._verbose:print("Now retreiving the new source zip") + + self._source_zip = os.path.join(local,"source.zip") + + if self._verbose:print("Starting download update zip") + urllib.request.urlretrieve(url, self._source_zip) + if self._verbose:print("Successfully downloaded update zip") + + def create_backup(self): + if self._verbose:print("Backing up current addon folder") + local = os.path.join(self._updater_path,"backup") + tempdest = os.path.join(self._addon_root, + os.pardir, + self._addon+"_updater_backup_temp") + + if os.path.isdir(local) == True: + shutil.rmtree(local) + if self._verbose:print("Backup destination path: ",local) + + # make the copy + shutil.copytree(self._addon_root,tempdest) + shutil.move(tempdest,local) + + # save the date for future ref + now = datetime.now() + self._json["backup_date"] = "{m}-{d}-{yr}".format( + m=now.strftime("%B"),d=now.day,yr=now.year) + self.save_updater_json() + + def restore_backup(self): + if self._verbose:print("Restoring backup") + + if self._verbose:print("Backing up current addon folder") + backuploc = os.path.join(self._updater_path,"backup") + tempdest = os.path.join(self._addon_root, + os.pardir, + self._addon+"_updater_backup_temp") + tempdest = os.path.abspath(tempdest) + + # make the copy + shutil.move(backuploc,tempdest) + shutil.rmtree(self._addon_root) + os.rename(tempdest,self._addon_root) + + self._json["backup_date"] = "" + self._json["just_restored"] = True + self._json["just_updated"] = True + self.save_updater_json() + + self.reload_addon() + + def upack_staged_zip(self): + + if os.path.isfile(self._source_zip) == False: + if self._verbose:print("Error, update zip not found") + return -1 + + # clear the existing source folder in case previous files remain + try: + shutil.rmtree( os.path.join(self._updater_path,"source") ) + os.makedirs( os.path.join(self._updater_path,"source") ) + if self._verbose:print("Source folder cleared and recreated") + except: + pass + + + if self.verbose:print("Begin extracting source") + if zipfile.is_zipfile(self._source_zip): + with zipfile.ZipFile(self._source_zip) as zf: + # extractall is no longer a security hazard + zf.extractall(os.path.join(self._updater_path,"source")) + else: + if self._verbose: + print("Not a zip file, future add support for just .py files") + raise ValueError("Resulting file is not a zip") + if self.verbose:print("Extracted source") + + # either directly in root of zip, or one folder level deep + unpath = os.path.join(self._updater_path,"source") + if os.path.isfile(os.path.join(unpath,"__init__.py")) == False: + dirlist = os.listdir(unpath) + if len(dirlist)>0: + unpath = os.path.join(unpath,dirlist[0]) + + if os.path.isfile(os.path.join(unpath,"__init__.py")) == False: + if self._verbose:print("not a valid addon found") + if self._verbose:print("Paths:") + if self._verbose:print(dirlist) + self._error = "Install addon update manually" + self._error_msg = "Valid addon zip not found" + + raise ValueError("__init__ file not found in new source") + + # now commence merging in the two locations: + origpath = os.path.dirname(__file__) # verify, is __file__ always valid? + + self.deepMergeDirectory(origpath,unpath) + + # now save the json state + # Change to True, to trigger the handler on other side + # if allowing reloading within same blender instance + self._json["just_updated"] = True + self.save_updater_json() + self.reload_addon() + self._update_ready = False + + + # merge folder 'merger' into folder 'base' without deleting existing + def deepMergeDirectory(self,base,merger): + if not os.path.exists(base): + if self._verbose:print("Base path does not exist") + return -1 + elif not os.path.exists(merger): + if self._verbose:print("Merger path does not exist") + return -1 + + # this should have better error handling + # and also avoid the addon dir + # Could also do error handling outside this function + for path, dirs, files in os.walk(merger): + relPath = os.path.relpath(path, merger) + destPath = os.path.join(base, relPath) + if not os.path.exists(destPath): + os.makedirs(destPath) + for file in files: + destFile = os.path.join(destPath, file) + if os.path.isfile(destFile): + os.remove(destFile) + srcFile = os.path.join(path, file) + os.rename(srcFile, destFile) + + + def reload_addon(self): + # if post_update false, skip this function + # else, unload/reload addon & trigger popup + if self._auto_reload_post_update == False: + print("Restart blender to reload addon and complete update") + return + + + if self._verbose:print("Reloading addon...") + addon_utils.modules(refresh=True) + bpy.utils.refresh_script_paths() + + # not allowed in restricted context, such as register module + # toggle to refresh + bpy.ops.wm.addon_disable(module=self._addon_package) + bpy.ops.wm.addon_refresh() + bpy.ops.wm.addon_enable(module=self._addon_package) + + + # ------------------------------------------------------------------------- + # Other non-api functions and setups + # ------------------------------------------------------------------------- + + def clear_state(self): + self._update_ready = None + self._update_link = None + self._update_version = None + self._source_zip = None + self._error = None + self._error_msg = None + + def version_tuple_from_text(self,text): + + if text == None: return () + + # should go through string and remove all non-integers, + # and for any given break split into a different section + + segments = [] + tmp = '' + for l in str(text): + if l.isdigit()==False: + if len(tmp)>0: + segments.append(int(tmp)) + tmp = '' + else: + tmp+=l + if len(tmp)>0: + segments.append(int(tmp)) + + if len(segments)==0: + if self._verbose:print("No version strings found text: ",text) + if self._include_master == False: + return () + else: + return ('master') + return tuple(segments) + + # called for running check in a background thread + def check_for_update_async(self, callback=None): + + if self._json != None and "update_ready" in self._json: + if self._json["update_ready"] == True: + self._update_ready = True + self._update_link = self._json["version_text"]["link"] + self._update_version = str(self._json["version_text"]["version"]) + # cached update + callback(True) + return + + # do the check + if self._check_interval_enable == False: + return + elif self._async_checking == True: + if self._verbose:print("Skipping async check, already started") + return # already running the bg thread + elif self._update_ready == None: + self.start_async_check_update(False, callback) + + def check_for_update_now(self, callback=None): + + self._error = None + self._error_msg = None + + if self._verbose: + print("Check update pressed, first getting current status") + if self._async_checking == True: + if self._verbose:print("Skipping async check, already started") + return # already running the bg thread + elif self._update_ready == None: + self.start_async_check_update(True, callback) + else: + self._update_ready = None + self.start_async_check_update(True, callback) + + + # this function is not async, will always return in sequential fashion + # but should have a parent which calls it in another thread + def check_for_update(self, now=False): + if self._verbose:print("Checking for update function") + + # clear the errors if any + self._error = None + self._error_msg = None + + # avoid running again in, just return past result if found + # but if force now check, then still do it + if self._update_ready != None and now == False: + return (self._update_ready,self._update_version,self._update_link) + + if self._current_version == None: + raise ValueError("current_version not yet defined") + if self._repo == None: + raise ValueError("repo not yet defined") + if self._user == None: + raise ValueError("username not yet defined") + + self.set_updater_json() # self._json + + if now == False and self.past_interval_timestamp()==False: + if self.verbose: + print("Aborting check for updated, check interval not reached") + return (False, None, None) + + # check if using tags or releases + # note that if called the first time, this will pull tags from online + if self._fake_install == True: + if self._verbose: + print("fake_install = True, setting fake version as ready") + self._update_ready = True + self._update_version = "(999,999,999)" + self._update_link = "http://127.0.0.1" + + return (self._update_ready, self._update_version, self._update_link) + + # primaryb internet call + self.get_tags() # sets self._tags and self._tag_latest + + self._json["last_check"] = str(datetime.now()) + self.save_updater_json() + + + # if (len(self._tags) == 0 and self._include_master == False) or\ + # (len(self._tags) < 2 and self._include_master == True): + # if self._verbose:print("No tag found on this repository") + # self._update_ready = False + # self._error = "No online versions found" + # if self._include_master == True: + # self._error_msg = "Try installing master from Reinstall" + # else: + # self._error_msg = "No repository tags found for version comparison" + # return (False, None, None) + + # can be () or ('master') in addition to version tag + new_version = self.version_tuple_from_text(self.tag_latest) + + if len(self._tags)==0: + self._update_ready = False + self._update_version = None + self._update_link = None + return (False, None, None) + elif self._include_master == False: + link = self._tags[0]["zipball_url"] # potentially other sources + elif self._include_master == True and len(self._tags)>1: + link = self._tags[1]["zipball_url"] # potentially other sources + else: + link = self._tags[0]["zipball_url"] # potentially other sources + + if new_version == (): + self._update_ready = False + self._update_version = None + self._update_link = None + return (False, None, None) + elif str(new_version).lower() == "master": + self._update_ready = True + self._update_version = new_version + self._update_link = link + self.save_updater_json() + return (True, new_version, link) + elif new_version > self._current_version: + self._update_ready = True + self._update_version = new_version + self._update_link = link + self.save_updater_json() + return (True, new_version, link) + # elif new_version != self._current_version: + # self._update_ready = False + # self._update_version = new_version + # self._update_link = link + # self.save_updater_json() + # return (True, new_version, link) + + # if no update, set ready to False from None + self._update_ready = False + self._update_version = None + self._update_link = None + return (False, None, None) + + def set_tag(self,name): + tg = None + for tag in self._tags: + if name == tag["name"]: + tg = tag + break + if tg == None: + raise ValueError("Version tag not found: "+revert_tag) + new_version = self.version_tuple_from_text(self.tag_latest) + self._update_version = new_version + self._update_link = tg["zipball_url"] + + + def run_update(self,force=False,revert_tag=None,clean=False,callback=None): + # revert_tag: could e.g. get from drop down list + # different versions of the addon to revert back to + # clean: not used, but in future could use to totally refresh addon + self._json["update_ready"] = False + self._json["ignore"] = False # clear ignore flag + self._json["version_text"] = {} + + if revert_tag != None: + self.set_tag(revert_tag) + self._update_ready = True + + # clear the errors if any + self._error = None + self._error_msg = None + + if self.verbose:print("Running update") + + if self._fake_install == True: + # change to True, to trigger the reload/"update installed" handler + if self._verbose: + print("fake_install=True") + print("Just reloading and running any trigger") + self._json["just_updated"] = True + self.save_updater_json() + if self._backup_current == True: + self.create_backup() + self.reload_addon() + self._update_ready = False + + elif force==False: + if self._update_ready != True: + if self.verbose:print("Update stopped, new version not ready") + return 1 # stopped + elif self._update_link == None: + # this shouldn't happen if update is ready + if self.verbose:print("Update stopped, update link unavailable") + return 1 # stopped + + if self.verbose and revert_tag==None: + print("Staging update") + elif self.verbose: + print("Staging install") + self.stage_repository(self._update_link) + self.upack_staged_zip() + + else: + if self._update_link == None: + return # stopped, no link - run check update first or set tag + if self.verbose:print("Forcing update") + # first do a check + if self._update_link == None: + if self.verbose:print("Update stopped, could not get link") + return + self.stage_repository(self._update_link) + self.upack_staged_zip() + # would need to compare against other versions held in tags + + # run the front-end's callback if provided + if callback != None:callback() + + # return something meaningful, 0 means it worked + return 0 + + + def past_interval_timestamp(self): + if self._check_interval_enable == False: + return True # ie this exact feature is disabled + + if "last_check" not in self._json or self._json["last_check"] == "": + return True + else: + now = datetime.now() + last_check = datetime.strptime(self._json["last_check"], + "%Y-%m-%d %H:%M:%S.%f") + next_check = last_check + offset = timedelta( + days=self._check_interval_days + 30*self._check_interval_months, + hours=self._check_interval_hours, + minutes=self._check_interval_minutes + ) + + delta = (now - offset) - last_check + if delta.total_seconds() > 0: + if self._verbose: + print("Determined it's time to check for updates") + return True + else: + if self._verbose: + print("Determined it's not yet time to check for updates") + return False + + + def set_updater_json(self): + if self._updater_path == None: + raise ValueError("updater_path is not defined") + elif os.path.isdir(self._updater_path) == False: + os.makedirs(self._updater_path) + + jpath = os.path.join(self._updater_path,"updater_status.json") + if os.path.isfile(jpath): + with open(jpath) as data_file: + self._json = json.load(data_file) + if self._verbose:print("Read in json settings from file") + else: + # set data structure + self._json = { + "last_check":"", + "backup_date":"", + "update_ready":False, + "ignore":False, + "just_restored":False, + "just_updated":False, + "version_text":{} + } + self.save_updater_json() + + + def save_updater_json(self): + + # first save the state + if self._update_ready == True: + self._json["update_ready"] = True + self._json["version_text"]["link"]=self._update_link + self._json["version_text"]["version"]=self._update_version + else: + self._json["update_ready"] = False + self._json["version_text"] = {} + + jpath = os.path.join(self._updater_path,"updater_status.json") + outf = open(jpath,'w') + data_out = json.dumps(self._json,indent=4) + outf.write(data_out) + outf.close() + if self._verbose: + print("Wrote out json settings to file, with the contents:") + print(self._json) + + def json_reset_postupdate(self): + self._json["just_updated"] = False + self._json["update_ready"] = False + self._json["version_text"] = {} + self.save_updater_json() + def json_reset_restore(self): + self._json["just_restored"] = False + self._json["update_ready"] = False + self._json["version_text"] = {} + self.save_updater_json() + self._update_ready = None # reset so you could check update again + + def ignore_update(self): + self._json["ignore"] = True + self.save_updater_json() + + # ------------------------------------------------------------------------- + # ASYNC stuff + # ------------------------------------------------------------------------- + + def start_async_check_update(self, now=False,callback=None): + if self._async_checking == True: + return + if self._verbose: print("Starting background checking thread") + check_thread = threading.Thread(target=self.async_check_update, + args=(now,callback,)) + check_thread.daemon = True + self._check_thread = check_thread + check_thread.start() + + return True + + def async_check_update(self, now, callback=None): + self._async_checking = True + if self._verbose:print("BG: Checking for update now in background") + # time.sleep(3) # to test background, in case internet too fast to tell + # try: + self.check_for_update(now=now) + # except Exception as exception: + # print("Checking for update error:") + # print(exception) + # self._update_ready = False + # self._update_version = None + # self._update_link = None + # self._error = "Error occurred" + # self._error_msg = "Encountered an error while checking for updates" + + if self._verbose: + print("BG: Finished checking for update, doing callback") + if callback != None:callback(self._update_ready) + self._async_checking = False + self._check_thread = None + + + def stop_async_check_update(self): + if self._check_thread != None: + try: + if self._verbose:print("Thread will end in normal course.") + # however, "There is no direct kill method on a thread object." + # better to let it run its course + #self._check_thread.stop() + except: + pass + self._async_checking = False + self._error = None + self._error_msg = None + + + + +# ----------------------------------------------------------------------------- +# The module-shared class instance, +# should be what's imported to other files +# ----------------------------------------------------------------------------- + +Updater = Singleton_updater() + diff --git a/addon_updater_ops.py b/addon_updater_ops.py new file mode 100644 index 0000000..1a9432d --- /dev/null +++ b/addon_updater_ops.py @@ -0,0 +1,813 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +import bpy +from .addon_updater import Updater as updater +from bpy.app.handlers import persistent +import os + +# Must declare this before classes are loaded +# otherwise the bl_idnames will not match and have errors. +# Must be all lowercase and no spaces +updater.addon = "gaffer" + + +# ----------------------------------------------------------------------------- +# Updater operators +# ----------------------------------------------------------------------------- + + +# simple popup for prompting checking for update & allow to install if available +class addon_updater_install_popup(bpy.types.Operator): + """Check and install update if available""" + bl_label = "Update {x} addon".format(x=updater.addon) + bl_idname = updater.addon+".updater_install_popup" + bl_description = "Popup menu to check and display current updates available" + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + if updater.update_ready == True: + layout.label("Update ready! Press OK to install v"\ + +str(updater.update_version)) + layout.label("or click outside window to defer") + # could offer to remove popups here, but window will not redraw + # so may be confusing to the user/look like a bug + # row = layout.row() + # row.label("Prevent future popups:") + # row.operator(addon_updater_ignore.bl_idname,text="Ignore update") + elif updater.update_ready == False: + layout.label("No updates available") + layout.label("Press okay to dismiss dialog") + # add option to force install + else: + # case: updater.update_ready = None + # we have not yet checked for the update + layout.label("Check for update now?") + + # potentially in future, could have UI for 'check to select old version' + # to revert back to. + + def execute(self,context): + + if updater.update_ready == True: + res = updater.run_update(force=False, callback=post_update_callback) + # should return 0, if not something happened + if updater.verbose: + if res==0: print("Updater returned successful") + else: print("Updater returned "+str(res)+", error occured") + + elif updater.update_ready == None: + (update_ready, version, link) = updater.check_for_update(now=True) + + # re-launch this dialog + atr = addon_updater_install_popup.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]),atr[1])('INVOKE_DEFAULT') + #bpy.ops.retopoflow.updater_install_popup('INVOKE_DEFAULT') + + else: + if updater.verbose:print("Doing nothing, not ready for update") + return {'FINISHED'} + + +# User preference check-now operator +class addon_updater_check_now(bpy.types.Operator): + bl_label = "Check now for "+updater.addon+" update" + bl_idname = updater.addon+".updater_check_now" + bl_description = "Check now for an update to the {x} addon".format( + x=updater.addon) + + def execute(self,context): + + if updater.async_checking == True and updater.error == None: + # Check already happened + # Used here to just avoid constant applying settings below + # Ignoring if erro, to prevent being stuck on the error screen + return {'CANCELLED'} + return + + # apply the UI settings + settings = context.user_preferences.addons[__package__].preferences + updater.set_check_interval(enable=settings.auto_check_update, + months=settings.updater_intrval_months, + days=settings.updater_intrval_days, + hours=settings.updater_intrval_hours, + minutes=settings.updater_intrval_minutes + ) # optional, if auto_check_update + + # input is an optional callback function + # this function should take a bool input, if true: update ready + # if false, no update ready + updater.check_for_update_now() + + return {'FINISHED'} + +class addon_updater_update_now(bpy.types.Operator): + bl_label = "Update "+updater.addon+" addon now" + bl_idname = updater.addon+".updater_update_now" + bl_description = "Update to the latest verison of the {x} addon".format( + x=updater.addon) + + + def execute(self,context): + + if updater.update_ready == True: + # if it fails, offer to open the website instead + try: + res = updater.run_update( + force=False, + callback=post_update_callback) + + # should return 0, if not something happened + if updater.verbose: + if res==0: print("Updater returned successful") + else: print("Updater returned "+str(res)+", error occured") + except: + atr = addon_updater_install_manually.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]),atr[1])('INVOKE_DEFAULT') + elif updater.update_ready == None: + (update_ready, version, link) = updater.check_for_update(now=True) + # re-launch this dialog + atr = addon_updater_install_popup.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]),atr[1])('INVOKE_DEFAULT') + + elif updater.update_ready == False: + self.report({'INFO'}, "Nothing to update") + else: + self.report({'ERROR'}, "Encountered problem while trying to update") + + return {'FINISHED'} + + +class addon_updater_update_target(bpy.types.Operator): + bl_label = updater.addon+" addon version target" + bl_idname = updater.addon+".updater_update_target" + bl_description = "Install a targeted version of the {x} addon".format( + x=updater.addon) + + def target_version(self, context): + ret = [] + i=0 + for tag in updater.tags: + ret.append( (tag,tag,"Select to install version "+tag) ) + i+=1 + return ret + + target = bpy.props.EnumProperty( + name="Target version", + description="Select the version to install", + items=target_version + ) + + @classmethod + def poll(cls, context): + return updater.update_ready != None and len(updater.tags)>0 + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + split = layout.split(percentage=0.66) + subcol = split.column() + subcol.label("Select install version") + subcol = split.column() + subcol.prop(self, "target", text="") + + + def execute(self,context): + + res = updater.run_update( + force=False, + revert_tag=self.target, + callback=post_update_callback) + + # should return 0, if not something happened + if updater.verbose: + if res==0: print("Updater returned successful") + else: print("Updater returned "+str(res)+", error occurred") + # try: + # updater.run_update(force=False,revert_tag=self.target) + # except: + # self.report({'ERROR'}, "Problem installing target version") + + return {'FINISHED'} + + +class addon_updater_install_manually(bpy.types.Operator): + """As a fallback, direct the user to download the addon manually""" + bl_label = "Install update manually" + bl_idname = updater.addon+".updater_install_manually" + bl_description = "Proceed to manually install update" + + def invoke(self, context, event): + return context.window_manager.invoke_popup(self) + + def draw(self, context): + layout = self.layout + # use a "failed flag"? it show this label if the case failed. + if False: + layout.label("There was an issue trying to auto-install") + else: + layout.label("Install the addon manually") + layout.label("Press the download button below and install") + layout.label("the zip file like a normal addon.") + + # if check hasn't happened, ie accidentally called this menu + # allow to check here + + row = layout.row() + + if updater.update_link != None: + row.operator("wm.url_open",text="Direct download").url=\ + updater.update_link + else: + row.operator("wm.url_open",text="(failed to retreive)") + row.enabled = False + + if updater.website != None: + row = layout.row() + row.label("Grab update from account") + + row.operator("wm.url_open",text="Open website").url=\ + updater.website + else: + row = layout.row() + + row.label("See source website to download the update") + + def execute(self,context): + + return {'FINISHED'} + + +class addon_updater_updated_successful(bpy.types.Operator): + """Addon in place, popup telling user it completed""" + bl_label = "Success" + bl_idname = updater.addon+".updater_update_successful" + bl_description = "Update installation was successful" + bl_options = {'REGISTER', 'UNDO'} + + def invoke(self, context, event): + return context.window_manager.invoke_props_popup(self, event) + + def draw(self, context): + layout = self.layout + # use a "failed flag"? it show this label if the case failed. + saved = updater.json + if updater.auto_reload_post_update == False: + # tell user to restart blender + if "just_restored" in saved and saved["just_restored"] == True: + layout.label("Addon restored") + layout.label("Restart blender to reload.") + updater.json_reset_restore() + else: + layout.label("Addon successfully installed") + layout.label("Restart blender to reload.") + + else: + # reload addon, but still recommend they restart blender + if "just_restored" in saved and saved["just_restored"] == True: + layout.label("Addon restored") + layout.label("Consider restarting blender to fully reload.") + updater.json_reset_restore() + else: + layout.label("Addon successfully installed.") + layout.label("Consider restarting blender to fully reload.") + + def execut(self, context): + return {'FINISHED'} + + +class addon_updater_restore_backup(bpy.types.Operator): + """Restore addon from backup""" + bl_label = "Restore backup" + bl_idname = updater.addon+".updater_restore_backup" + bl_description = "Restore addon from backup" + + @classmethod + def poll(cls, context): + try: + return os.path.isdir(os.path.join(updater.stage_path,"backup")) + except: + return False + + def execute(self, context): + updater.restore_backup() + return {'FINISHED'} + + +class addon_updater_ignore(bpy.types.Operator): + """Prevent future update notice popups""" + bl_label = "Ignore update" + bl_idname = updater.addon+".updater_ignore" + bl_description = "Ignore update to prevent future popups" + + @classmethod + def poll(cls, context): + if updater.update_ready == True: + return True + else: + return False + + def execute(self, context): + updater.ignore_update() + self.report({"INFO"},"Open addon preferences for updater options") + return {'FINISHED'} + + +class addon_updater_end_background(bpy.types.Operator): + """Stop checking for update in the background""" + bl_label = "End background check" + bl_idname = updater.addon+".end_background_check" + bl_description = "Stop checking for update in the background" + + # @classmethod + # def poll(cls, context): + # if updater.async_checking == True: + # return True + # else: + # return False + + def execute(self, context): + updater.stop_async_check_update() + return {'FINISHED'} + + +# ----------------------------------------------------------------------------- +# Handler related, to create popups +# ----------------------------------------------------------------------------- + + +# global vars used to prevent duplicate popup handlers +ran_autocheck_install_popup = False +ran_update_sucess_popup = False + +# global var for preventing successive calls +ran_background_check = False + +@persistent +def updater_run_success_popup_handler(scene): + global ran_update_sucess_popup + ran_update_sucess_popup = True + try: + bpy.app.handlers.scene_update_post.remove( + updater_run_success_popup_handler) + except: + pass + + atr = addon_updater_updated_successful.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]),atr[1])('INVOKE_DEFAULT') + + +@persistent +def updater_run_install_popup_handler(scene): + global ran_autocheck_install_popup + ran_autocheck_install_popup = True + try: + bpy.app.handlers.scene_update_post.remove( + updater_run_install_popup_handler) + except: + pass + + if "ignore" in updater.json and updater.json["ignore"] == True: + return # don't do popup if ignore pressed + atr = addon_updater_install_popup.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]),atr[1])('INVOKE_DEFAULT') + + +# passed into the updater, background thread updater +def background_update_callback(update_ready): + global ran_autocheck_install_popup + + if update_ready != True: + return + + if updater_run_install_popup_handler not in \ + bpy.app.handlers.scene_update_post and \ + ran_autocheck_install_popup==False: + bpy.app.handlers.scene_update_post.append( + updater_run_install_popup_handler) + + ran_autocheck_install_popup = True + + +# a callback for once the updater has completed +# Only makes sense to use this if "auto_reload_post_update" == False, +# ie don't auto-restart the addon +def post_update_callback(): + # this is the same code as in conditional at the end of the register function + # ie if "auto_reload_post_update" == True, comment out this code + if updater.verbose: print("Running post update callback") + #bpy.app.handlers.scene_update_post.append(updater_run_success_popup_handler) + + atr = addon_updater_updated_successful.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]),atr[1])('INVOKE_DEFAULT') + global ran_update_sucess_popup + ran_update_sucess_popup = True + return + + +# function for asynchronous background check, which *could* be called on register +def check_for_update_background(context): + + global ran_background_check + if ran_background_check == True: + # Global var ensures check only happens once + return + elif updater.update_ready != None or updater.async_checking == True: + # Check already happened + # Used here to just avoid constant applying settings below + return + + # apply the UI settings + settings = context.user_preferences.addons[__package__].preferences + updater.set_check_interval(enable=settings.auto_check_update, + months=settings.updater_intrval_months, + days=settings.updater_intrval_days, + hours=settings.updater_intrval_hours, + minutes=settings.updater_intrval_minutes + ) # optional, if auto_check_update + + # input is an optional callback function + # this function should take a bool input, if true: update ready + # if false, no update ready + if updater.verbose: print("Running background check for update") + updater.check_for_update_async(background_update_callback) + ran_background_check = True + + +# can be placed in front of other operators to launch when pressed +def check_for_update_nonthreaded(self, context): + + # only check if it's ready, ie after the time interval specified + # should be the async wrapper call here + + settings = context.user_preferences.addons[__package__].preferences + updater.set_check_interval(enable=settings.auto_check_update, + months=settings.updater_intrval_months, + days=settings.updater_intrval_days, + hours=settings.updater_intrval_hours, + minutes=settings.updater_intrval_minutes + ) # optional, if auto_check_update + + (update_ready, version, link) = updater.check_for_update(now=False) + if update_ready == True: + atr = addon_updater_install_popup.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]),atr[1])('INVOKE_DEFAULT') + else: + if updater.verbose: print("No update ready") + self.report({'INFO'}, "No update ready") + +# for use in register only, to show popup after re-enabling the addon +# must be enabled by developer +def showReloadPopup(): + saved_state = updater.json + global ran_update_sucess_popup + + a = saved_state != None + b = "just_updated" in saved_state + c = saved_state["just_updated"] + + if a and b and c: + updater.json_reset_postupdate() # so this only runs once + + # no handlers in this case + if updater.auto_reload_post_update == False: return + + if updater_run_success_popup_handler not in \ + bpy.app.handlers.scene_update_post \ + and ran_update_sucess_popup==False: + bpy.app.handlers.scene_update_post.append( + updater_run_success_popup_handler) + ran_update_sucess_popup = True + + +# ----------------------------------------------------------------------------- +# Example includable UI integrations +# ----------------------------------------------------------------------------- + + +# UI to place e.g. at the end of a UI panel where to notify update available +def update_notice_box_ui(self, context): + + saved_state = updater.json + if updater.auto_reload_post_update == False: + if "just_updated" in saved_state and saved_state["just_updated"] == True: + layout = self.layout + box = layout.box() + box.label("Restart blender", icon="ERROR") + box.label("to complete update") + return + + # if user pressed ignore, don't draw the box + if "ignore" in updater.json and updater.json["ignore"] == True: + return + + if updater.update_ready != True: return + + settings = context.user_preferences.addons[__package__].preferences + layout = self.layout + box = layout.box() + col = box.column(align=True) + col.label("Update ready!",icon="ERROR") + col.operator("wm.url_open", text="Open website").url = updater.website + #col.operator("wm.url_open",text="Direct download").url=updater.update_link + col.operator(addon_updater_install_manually.bl_idname, "Install manually") + if updater.manual_only==False: + col.operator(addon_updater_update_now.bl_idname, + "Update now", icon="LOOP_FORWARDS") + col.operator(addon_updater_ignore.bl_idname,icon="X") + + + +# create a function that can be run inside user preferences panel for prefs UI +# place inside UI draw using: addon_updater_ops.updaterSettingsUI(self, context) +# or by: addon_updater_ops.updaterSettingsUI(context) +def update_settings_ui(self, context): + settings = context.user_preferences.addons[__package__].preferences + + layout = self.layout + box = layout.box() + + # auto-update settings + box.label("Updater Settings") + row = box.row() + + # special case to tell user to restart blender, if set that way + if updater.auto_reload_post_update == False: + saved_state = updater.json + if "just_updated" in saved_state and saved_state["just_updated"] == True: + row.label("Restart blender to complete update", icon="ERROR") + return + + split = row.split(percentage=0.3) + subcol = split.column() + subcol.prop(settings, "auto_check_update") + subcol = split.column() + + if settings.auto_check_update==False: subcol.enabled = False + subrow = subcol.row() + subrow.label("Interval between checks") + subrow = subcol.row(align=True) + checkcol = subrow.column(align=True) + checkcol.prop(settings,"updater_intrval_months") + checkcol = subrow.column(align=True) + checkcol.prop(settings,"updater_intrval_days") + checkcol = subrow.column(align=True) + checkcol.prop(settings,"updater_intrval_hours") + checkcol = subrow.column(align=True) + checkcol.prop(settings,"updater_intrval_minutes") + + + # checking / managing updates + row = box.row() + col = row.column() + movemosue = False + if updater.error != None: + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.enabled = False + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + updater.error) + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text = "", icon="FILE_REFRESH") + + elif updater.update_ready == None and updater.async_checking == False: + col.scale_y = 2 + col.operator(addon_updater_check_now.bl_idname) + elif updater.update_ready == None: # async is running + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.enabled = False + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + "Checking...") + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_end_background.bl_idname, + text = "", icon="X") + + elif updater.update_ready==True and updater.manual_only==False: + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_update_now.bl_idname, + "Update now to "+str(updater.update_version)) + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text = "", icon="FILE_REFRESH") + + elif updater.update_ready==True and updater.manual_only==True: + col.scale_y = 2 + col.operator("wm.url_open", + "Download "+str(updater.update_version)).url=updater.website + else: # ie that updater.update_ready == False + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.enabled = False + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + "Addon is up to date") + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text = "", icon="FILE_REFRESH") + + if updater.manual_only == False: + col = row.column(align=True) + if updater.include_master == True: + col.operator(addon_updater_update_target.bl_idname, + "Install master / old verison") + else: + col.operator(addon_updater_update_target.bl_idname, + "Reinstall / install old verison") + lastdate = "none found" + backuppath = os.path.join(updater.stage_path,"backup") + if "backup_date" in updater.json and os.path.isdir(backuppath): + if updater.json["backup_date"] == "": + lastdate = "Date not found" + else: + lastdate = updater.json["backup_date"] + backuptext = "Restore addon backup ({x})".format(x=lastdate) + col.operator(addon_updater_restore_backup.bl_idname, backuptext) + + row = box.row() + lastcheck = updater.json["last_check"] + if updater.error != None and updater.error_msg != None: + row.label(updater.error_msg) + elif movemosue == True: + row.label("Move mouse if button doesn't update") + elif lastcheck != "" and lastcheck != None: + lastcheck = lastcheck[0: lastcheck.index(".") ] + row.label("Last update check: " + lastcheck) + else: + row.label("Last update check: None") + + +# a global function for tag skipping +# a way to filter which tags are displayed, +# e.g. to limit downgrading too far +# input is a tag text, e.g. "v1.2.3" +# output is True for skipping this tag number, +# False if the tag is allowed (default for all) +def skip_tag_function(tag): + + # ---- write any custom code here, return true to disallow version ---- # + # + # # Filter out e.g. if 'beta' is in name of release + # if 'beta' in tag.lower(): + # return True + # ---- write any custom code above, return true to disallow version --- # + + if tag["name"].lower() == 'master' and updater.include_master == True: + return False + + # function converting string to tuple, ignoring e.g. leading 'v' + tupled = updater.version_tuple_from_text(tag["name"]) + if type(tupled) != type( (1,2,3) ): return True # master + + # select the min tag version - change tuple accordingly + if updater.version_min_update != None: + if tupled < updater.version_min_update: + return True # skip if current version below this + + # select the max tag version + if updater.version_max_update != None: + if tupled >= updater.version_max_update: + return True # skip if current version at or above this + + # in all other cases, allow showing the tag for updating/reverting + return False + + +# ----------------------------------------------------------------------------- +# Register, should be run in the register module itself +# ----------------------------------------------------------------------------- + + +# registering the operators in this module +def register(bl_info): + + # See output to verify this register function is working properly + # print("Running updater reg") + + # choose your own username + updater.user = "gregzaal" + + # choose your own repository, must match github name + updater.repo = "Gaffer" + + #updater.addon = # define at top of module, must be done first + + # Website for manual addon download, optional + updater.website = "https://github.com/gregzaal/Gaffer" + + # used to check/compare versions + updater.current_version = bl_info["version"] + + # to hard-set udpate frequency, use this here - however, this demo + # has this set via UI properties. Optional + # updater.set_check_interval( + # enable=False,months=0,days=0,hours=0,minutes=2) + + # optional, consider turning off for production or allow as an option + # This will print out additional debugging info to the console + updater.verbose = False # make False for production default + + # optional, customize where the addon updater processing subfolder is, + # needs to be within the same folder as the addon itself + # updater.updater_path = # set path of updater folder, by default: + # /addons/{__package__}/{__package__}_updater + + # auto create a backup of the addon when installing other versions + updater.backup_current = True # True by default + + # allow 'master' as an option to update to, skipping any releases. + # releases are still accessible from re-install menu + # updater.include_master = True + + # only allow manual install, thus prompting the user to open + # the webpage to download but not auto-installing. Useful if + # only wanting to get notification of updates + # updater.manual_only = True + + # used for development only, "pretend" to install an update to test + # reloading conditions + updater.fake_install = False # Set to true to test callback/reloading + + # Override with a custom function on what tags + # to skip showing for udpater; see code for function above. + # Set the min and max versions allowed to install. + # Optional, default None + updater.version_min_update = (0,0,0) # min install (>=) will install this and higher + # updater.version_min_update = None # if not wanting to define a min + updater.version_max_update = (9,9,9) # max install (<) will install strictly anything lower + # updater.version_max_update = None # if not wanting to define a max + updater.skip_tag = skip_tag_function # min and max used in this function + + # The register line items for all operators/panels + # If using bpy.utils.register_module(__name__) to register elsewhere + # in the addon, delete these lines (also from unregister) + bpy.utils.register_class(addon_updater_install_popup) + bpy.utils.register_class(addon_updater_check_now) + bpy.utils.register_class(addon_updater_update_now) + bpy.utils.register_class(addon_updater_update_target) + bpy.utils.register_class(addon_updater_install_manually) + bpy.utils.register_class(addon_updater_updated_successful) + bpy.utils.register_class(addon_updater_restore_backup) + bpy.utils.register_class(addon_updater_ignore) + bpy.utils.register_class(addon_updater_end_background) + + # special situation: we just updated the addon, show a popup + # to tell the user it worked + # should be enclosed in try/catch in case other issues arise + showReloadPopup() + + +def unregister(): + bpy.utils.unregister_class(addon_updater_install_popup) + bpy.utils.unregister_class(addon_updater_check_now) + bpy.utils.unregister_class(addon_updater_update_now) + bpy.utils.unregister_class(addon_updater_update_target) + bpy.utils.unregister_class(addon_updater_install_manually) + bpy.utils.unregister_class(addon_updater_updated_successful) + bpy.utils.unregister_class(addon_updater_restore_backup) + bpy.utils.unregister_class(addon_updater_ignore) + bpy.utils.unregister_class(addon_updater_end_background) + + # clear global vars since they may persist if not restarting blender + global ran_autocheck_install_popup + ran_autocheck_install_popup = False + + global ran_update_sucess_popup + ran_update_sucess_popup = False + + global ran_background_check + ran_background_check = False + diff --git a/ui.py b/ui.py index fc02833..2fc3de7 100644 --- a/ui.py +++ b/ui.py @@ -17,6 +17,7 @@ # END GPL LICENSE BLOCK ##### import bpy +from . import addon_updater_ops from collections import OrderedDict import bgl, blf from math import pi, cos, sin, log @@ -655,6 +656,8 @@ def poll(cls, context): return True if context.scene.render.engine in supported_renderers else False def draw(self, context): + addon_updater_ops.check_for_update_background(context) + scene = context.scene gaf_props = scene.gaf_props lights_str = gaf_props.Lights @@ -693,6 +696,8 @@ def draw(self, context): else: layout.label ("Render Engine not supported!") + addon_updater_ops.update_notice_box_ui(self, context) + class GafferPanelTools(bpy.types.Panel):