| … | … | @@ -1,43 +1,243 @@ |
| 1 | 1 | import re |
| 2 | import pathlib |
| 3 | import subprocess |
| 4 | import threading |
| 5 | |
| 2 | 6 | import requests |
| 3 | 7 | |
| 4 | 8 | import bpy |
| 5 | 9 | |
| 6 | 10 | from .. utility import addon |
| 7 | 11 | |
| 12 | |
| 13 | # Upstream publishes version TAGS only — that repo carries no addon code, so a new |
| 14 | # tag means "a vendor drop exists, go download it", never "we can fetch it". |
| 8 | 15 | update_url = 'https://api.github.com/repos/teamcsharp/HardOps-updater/tags' |
| 9 | 16 | |
| 17 | # This install is a git checkout of the fork. Remotes are tried in order and the |
| 18 | # first that answers wins; punch:// is P2P over Hyperswarm and falls back to |
| 19 | # HTTPS on its own, so it is worth trying first. |
| 20 | FORK_REMOTES = ('p2p', 'web', 'forge') |
| 21 | FORK_BRANCH = 'main' |
| 22 | |
| 23 | GIT_TIMEOUT = 20 |
| 24 | FORK_TIMEOUT = 120 # punch:// peer discovery is slower than HTTPS |
| 25 | |
| 10 | 26 | version_regex = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+") |
| 27 | |
| 28 | # The check runs off the main thread, so it may not touch bpy data. It parks a |
| 29 | # result here and _drain applies it from a timer, which is on the main thread. |
| 30 | _result = None |
| 31 | _checking = False |
| 32 | |
| 33 | |
| 34 | def addon_path() -> pathlib.Path: |
| 35 | '''Root of the addon, which is also the fork's working tree''' |
| 36 | |
| 37 | return pathlib.Path(__file__).resolve().parent.parent |
| 38 | |
| 39 | |
| 40 | def _git(*args, timeout=GIT_TIMEOUT): |
| 41 | '''Run git against the addon directory. Returns (ok, stdout, stderr)''' |
| 42 | |
| 43 | try: |
| 44 | proc = subprocess.run( |
| 45 | ('git', '-C', str(addon_path())) + args, |
| 46 | capture_output=True, text=True, timeout=timeout) |
| 47 | |
| 48 | except (OSError, subprocess.SubprocessError) as error: |
| 49 | return False, '', str(error) |
| 50 | |
| 51 | return proc.returncode == 0, proc.stdout.strip(), proc.stderr.strip() |
| 52 | |
| 53 | |
| 54 | def is_fork_checkout() -> bool: |
| 55 | '''True if this install is a git working tree we can update in place''' |
| 56 | |
| 57 | ok, out, _ = _git('rev-parse', '--is-inside-work-tree') |
| 58 | |
| 59 | return ok and out == 'true' |
| 60 | |
| 61 | |
| 62 | def remote_head(remote) -> str: |
| 63 | '''Branch tip on a remote without downloading anything, '' if unreachable''' |
| 64 | |
| 65 | ok, out, _ = _git('ls-remote', remote, FORK_BRANCH, timeout=FORK_TIMEOUT) |
| 66 | if not ok: return '' |
| 67 | |
| 68 | for line in out.splitlines(): |
| 69 | parts = line.split() |
| 70 | if len(parts) == 2 and parts[1].endswith(FORK_BRANCH): |
| 71 | return parts[0] |
| 72 | |
| 73 | return '' |
| 74 | |
| 75 | |
| 76 | def have_object(sha) -> bool: |
| 77 | '''True if the commit is already in the local object store''' |
| 78 | |
| 79 | ok, _, _ = _git('cat-file', '-e', f'{sha}^{{commit}}') |
| 80 | |
| 81 | return ok |
| 82 | |
| 83 | |
| 84 | def check_fork(): |
| 85 | '''(remote, sha) for the first remote holding a commit we don't have.\n |
| 86 | Returns (None, '') when up to date, unreachable, or when we are AHEAD of the |
| 87 | remote — a differing sha we already hold means our own commits simply aren't |
| 88 | pushed yet, which is not an update.''' |
| 89 | |
| 90 | if not is_fork_checkout(): return None, '' |
| 91 | |
| 92 | for remote in FORK_REMOTES: |
| 93 | head = remote_head(remote) |
| 94 | if not head: continue |
| 95 | |
| 96 | if have_object(head): return None, '' # up to date, or ahead |
| 97 | |
| 98 | return remote, head |
| 99 | |
| 100 | return None, '' |
| 101 | |
| 102 | |
| 103 | def check_vendor(current_version) -> str: |
| 104 | '''Latest upstream tag if it is newer than ours, else '' ''' |
| 105 | |
| 106 | response = requests.get(update_url, timeout=6) |
| 107 | if response.status_code != 200: |
| 108 | raise Exception |
| 109 | |
| 110 | versions = list() |
| 111 | for tag in response.json(): |
| 112 | name:str = tag['name'] |
| 113 | |
| 114 | # pad 3 number version |
| 115 | if name.count('.') < 4: name = f'{name}.0' |
| 116 | if len(current_version) < 4: current_version = (*current_version, 0) |
| 117 | |
| 118 | if version_regex.match(name): |
| 119 | versions.append(name) |
| 120 | |
| 121 | if not versions: return '' |
| 122 | |
| 123 | latest_version_str = sorted(versions, reverse=True)[0] |
| 124 | current_version_str = '.'.join(map(str, list(current_version))) |
| 125 | |
| 126 | return latest_version_str if str(current_version_str) != str(latest_version_str) else '' |
| 127 | |
| 128 | |
| 129 | def _worker(current_version): |
| 130 | '''Off the main thread — must not touch bpy data, only park a result''' |
| 131 | |
| 132 | global _result |
| 133 | |
| 134 | try: |
| 135 | remote, _sha = check_fork() |
| 136 | if remote: |
| 137 | _result = (f'Fork update available — pull from {remote}', True, remote) |
| 138 | return |
| 139 | |
| 140 | except Exception: |
| 141 | pass |
| 142 | |
| 143 | try: |
| 144 | latest = check_vendor(current_version) |
| 145 | _result = (f'HardOps {latest} available!', False, '') if latest else ('', False, '') |
| 146 | |
| 147 | except Exception: |
| 148 | _result = ('Connection Failed', False, '') |
| 149 | |
| 150 | |
| 151 | def _drain(): |
| 152 | '''Main thread — copy the worker's result into preferences''' |
| 153 | |
| 154 | global _result, _checking |
| 155 | |
| 156 | if _result is None: |
| 157 | return 1.0 # not finished, poll again |
| 158 | |
| 159 | message, fork_update, remote = _result |
| 160 | _result = None |
| 161 | _checking = False |
| 162 | |
| 163 | try: |
| 164 | preference = addon.preference() |
| 165 | preference.needs_update = message |
| 166 | preference.fork_update = fork_update |
| 167 | preference.fork_remote = remote |
| 168 | |
| 169 | if message: print(message) |
| 170 | |
| 171 | except Exception: |
| 172 | pass # addon torn down while the check was in flight |
| 173 | |
| 174 | return None |
| 175 | |
| 176 | |
| 11 | 177 | def check_for_update(current_version): |
| 178 | global _checking |
| 179 | |
| 12 | 180 | if not addon.preference().check_update: return |
| 181 | if _checking: return |
| 13 | 182 | |
| 14 | 183 | # TODO: this should be stored in transient storage, like window_manager, so |
| 15 | 184 | # that we don't need to clear it here |
| 16 | 185 | addon.preference().needs_update = "" |
| 186 | addon.preference().fork_update = False |
| 17 | 187 | |
| 18 | | try: |
| 19 | | response = requests.get(update_url, timeout=6) |
| 20 | | if response.status_code == 200: |
| 21 | | resp_json = response.json() |
| 22 | | versions = list() |
| 23 | | for tag in resp_json: |
| 24 | | name:str = tag['name'] |
| 25 | | |
| 26 | | # pad 3 number version |
| 27 | | if name.count('.') < 4: name = f'{name}.0' |
| 28 | | if len(current_version) < 4: current_version = (*current_version, 0) |
| 29 | | |
| 30 | | if version_regex.match(name): |
| 31 | | versions.append(name) |
| 32 | | latest_version_str = sorted(versions, reverse=True)[0] |
| 33 | | |
| 34 | | current_version_str = '.'.join(map(str, list(current_version))) |
| 35 | | if str(current_version_str) != str(latest_version_str): |
| 36 | | needs_update = f"HardOps {latest_version_str} available!" |
| 37 | | print(needs_update) |
| 38 | | addon.preference().needs_update = needs_update |
| 39 | | else: |
| 40 | | raise Exception |
| 41 | | |
| 42 | | except: |
| 43 | | addon.preference().needs_update = 'Connection Failed' |
| 188 | _checking = True |
| 189 | |
| 190 | threading.Thread(target=_worker, args=(tuple(current_version),), daemon=True).start() |
| 191 | bpy.app.timers.register(_drain, first_interval=1.0) |
| 192 | |
| 193 | |
| 194 | def pull(remote): |
| 195 | '''Fast-forward this install from a fork remote. Returns (ok, message)''' |
| 196 | |
| 197 | if not is_fork_checkout(): |
| 198 | return False, 'This install is not a git checkout of the fork' |
| 199 | |
| 200 | ok, out, err = _git('status', '--porcelain') |
| 201 | if not ok: |
| 202 | return False, err or 'git status failed' |
| 203 | |
| 204 | if out: |
| 205 | return False, 'Local changes present — commit or stash them first' |
| 206 | |
| 207 | ok, out, err = _git('pull', '--ff-only', remote, FORK_BRANCH, timeout=FORK_TIMEOUT) |
| 208 | if not ok: |
| 209 | return False, (err or out or 'pull failed').splitlines()[-1] |
| 210 | |
| 211 | if 'Already up to date' in out: |
| 212 | return True, 'Already up to date' |
| 213 | |
| 214 | return True, 'Updated — restart Blender to load the new version' |
| 215 | |
| 216 | |
| 217 | class HOPS_OT_ForkUpdate(bpy.types.Operator): |
| 218 | bl_idname = 'hops.fork_update' |
| 219 | bl_label = 'Update From Fork' |
| 220 | bl_description = 'Pull the latest commits from the fork. Blender must be restarted for them to take effect' |
| 221 | |
| 222 | remote: bpy.props.StringProperty() |
| 223 | |
| 224 | def execute(self, context): |
| 225 | remote = self.remote or addon.preference().fork_remote or FORK_REMOTES[0] |
| 226 | |
| 227 | if context.window: |
| 228 | context.window.cursor_set('WAIT') |
| 229 | |
| 230 | try: |
| 231 | ok, message = pull(remote) |
| 232 | |
| 233 | finally: |
| 234 | if context.window: |
| 235 | context.window.cursor_set('DEFAULT') |
| 236 | |
| 237 | self.report({'INFO'} if ok else {'WARNING'}, message) |
| 238 | |
| 239 | if ok: |
| 240 | addon.preference().needs_update = message |
| 241 | addon.preference().fork_update = False |
| 242 | |
| 243 | return {'FINISHED'} |