66import hashlib
77import textwrap
88import tempfile
9+ from packaging .version import Version as SemVer
910import requests
1011from PyQt6 .QtCore import QObject , pyqtSignal
1112from tuf .ngclient .updater import Updater # TUF verification
@@ -49,6 +50,8 @@ def __init__(self, repo_owner: str, repo_name: str, version: str, app_install_di
4950 fetcher = self ._fetcher ,
5051 )
5152
53+ self .bundle_extensions = (".tar.gz" , ".zip" )
54+
5255 @staticmethod
5356 def _extract_archive (archive_path : str , dest_dir : str ) -> None :
5457 """Extract .tar.* or .zip archives into dest_dir."""
@@ -85,22 +88,33 @@ def _platform_prefix(self) -> str:
8588 else :
8689 return "telemetry-linux-"
8790
88- @staticmethod
89- def _bundle_name_for (version : str ) -> str :
91+ def _bundle_base (self , version : str ) -> str :
92+ if sys .platform .startswith ("win" ):
93+ app = "telemetry-windows"
94+ elif sys .platform == "darwin" :
95+ app = "telemetry-macos"
96+ else :
97+ app = "telemetry-linux"
98+ return f"{ app } -{ version } "
99+
100+ def _resolve_bundle (self , updater : Updater , version : str ):
101+ base = self ._bundle_base (version )
102+ for ext in self .bundle_extensions :
103+ name = base + ext
104+ ti = updater .get_targetinfo (name )
105+ if ti is not None :
106+ return name , ti
107+ return None , None
108+
109+ def _bundle_name_for (self , version : str ) -> str :
90110 """
91111 Compute the TUF target file name (tar.gz bundle) for the given
92112 version. This matches scripts/build_tuf_repo.py where we call
93113 repo.add_bundle with app_name per platform:
94114 telemetry-windows | telemetry-macos | telemetry-linux
95115 -> bundle names: telemetry-<platform>-<version>.tar.gz
96116 """
97- if sys .platform .startswith ("win" ):
98- app = "telemetry-windows"
99- elif sys .platform == "darwin" :
100- app = "telemetry-macos"
101- else :
102- app = "telemetry-linux"
103- return f"{ app } -{ version } .tar.gz"
117+ return self ._bundle_base (version ) + self .bundle_extensions [0 ]
104118
105119 # ---------- helpers ----------
106120 def _latest_version_from_github (self ) -> str | None :
@@ -127,140 +141,73 @@ def _running_binary_path(self) -> str:
127141
128142 # ---------- public API ----------
129143 def check_for_updates (self ) -> bool :
130- """
131- Returns True if a newer version than self.version is available.
132- Discovery via GitHub API; download/verify via TUF.
133- """
134- # Dev mode: skip
144+ """Return True if a newer version is available and staged in TUF."""
135145 if not getattr (sys , "frozen" , False ):
136146 return False
137147
138148 try :
139- latest = self ._latest_version_from_github ()
140- if not latest or latest == self .version :
141- return False # up-to-date or unknown
142-
143- # Verify the bundle for the latest version exists in TUF metadata
144- self .updater .refresh ()
145- bundle_name = self ._bundle_name_for (latest )
146- ti = self .updater .get_targetinfo (bundle_name )
147- if ti is None :
148- self .update_error .emit (
149- f"TUF metadata does not contain bundle '{ bundle_name } '. "
150- f"Make sure your release assets include TUF metadata + { bundle_name } ."
151- )
149+ versions = self .list_available_versions (limit = 10 )
150+ if not versions :
152151 return False
153152
154- self .update_available .emit (latest )
155- return True
153+ current = SemVer (self .version )
154+ for candidate in versions :
155+ try :
156+ cand_ver = SemVer (candidate )
157+ except Exception :
158+ continue
159+ if cand_ver <= current :
160+ continue
161+ if self ._version_has_bundle (candidate ):
162+ self .update_available .emit (candidate )
163+ return True
164+ return False
156165 except Exception as e :
157166 self .update_error .emit (str (e ))
158167 return False
159168
169+ def _version_has_bundle (self , version : str ) -> bool :
170+ try :
171+ base = f"https://github.com/{ self .repo_owner } /{ self .repo_name } /releases/download/v{ version } "
172+ updater = Updater (
173+ self .metadata_dir ,
174+ metadata_base_url = base + "/" ,
175+ target_base_url = base + "/" ,
176+ fetcher = self ._fetcher ,
177+ )
178+ updater .refresh ()
179+ name , ti = self ._resolve_bundle (updater , version )
180+ return ti is not None
181+ except Exception :
182+ return False
183+
160184 def download_and_apply_update (self ) -> bool :
161- """
162- Downloads {target_name} using TUF (verified), then atomically swaps it in.
163- Handles Windows by spawning a small .bat to replace after exit.
164- """
165- # Dev mode: skip
166185 if not getattr (sys , "frozen" , False ):
167186 self .update_error .emit ("Updater only runs in a packaged build." )
168187 return False
169188
170- try :
171- latest = self ._latest_version_from_github () or self .version
172- bundle_name = self ._bundle_name_for (latest )
173- ti = self .updater .get_targetinfo (bundle_name )
174- if ti is None :
175- self .update_error .emit (f"Bundle '{ bundle_name } ' not found in TUF metadata." )
176- return False
177-
178- # Download the tar.gz bundle
179- bundle_path = os .path .join (self .download_dir , bundle_name )
180- # Use our progress fetcher to emit per-byte progress
181- def _emit (received : int , total : int | None , pct : int | None ):
182- if pct is not None :
183- try :
184- self .update_progress .emit (int (pct ))
185- except Exception :
186- pass
187- self ._fetcher .set_callback (_emit )
188- self .updater .download_target (ti , filepath = bundle_path )
189- self ._fetcher .set_callback (None )
190-
191- # Extract bundle and locate the binary inside
192- extract_dir = tempfile .mkdtemp (prefix = "tuf_bundle_" )
193- try :
194- self ._extract_archive (bundle_path , extract_dir )
195- except Exception as e :
196- self .update_error .emit (f"Failed to extract bundle: { e } " )
197- return False
198-
199- # Find expected binary inside extracted contents
200- binary_name = self .target_name
201- candidate = None
202- bundle_root = None
203- for root , _dirs , files in os .walk (extract_dir ):
204- if candidate is None and binary_name in files :
205- candidate = os .path .join (root , binary_name )
206- bundle_root = root
207- if candidate and bundle_root :
208- break
209- if not candidate or not bundle_root :
210- self .update_error .emit (f"Bundle did not contain expected binary '{ binary_name } '." )
211- return False
189+ versions = self .list_available_versions (limit = 10 )
190+ if not versions :
191+ self .update_error .emit ("No releases found." )
192+ return False
212193
213- staged_root = os .path .join (self .download_dir , "staged_bundle" )
194+ current = SemVer (self .version )
195+ target = None
196+ for candidate in versions :
214197 try :
215- if os .path .exists (staged_root ):
216- shutil .rmtree (staged_root , ignore_errors = True )
217- shutil .copytree (bundle_root , staged_root , dirs_exist_ok = True )
218- except Exception as e :
219- self .update_error .emit (f"Failed staging new bundle: { e } " )
220- return False
221-
222- new_exe_path = os .path .join (staged_root , binary_name )
223-
224- old_exe = self ._running_binary_path ()
225- if not old_exe :
226- self .update_error .emit ("No runnable binary path detected." )
227- return False
228-
229- if os .name == "nt" :
230- bat_path = os .path .join (self .download_dir , "apply_update.bat" )
231- app_dir = os .path .dirname (old_exe )
232- script = textwrap .dedent (f"""
233- @echo off
234- set NEW_DIR="{ staged_root } "
235- set OLD_EXE="{ old_exe } "
236- set APP_DIR="{ app_dir } "
237- :wait
238- ping 127.0.0.1 -n 2 >nul
239- tasklist /FI "PID eq { os .getpid ()} " | findstr /I "{ os .getpid ()} " >nul && goto wait
240- robocopy %NEW_DIR% %APP_DIR% /E /COPY:DAT /R:2 /W:1 /NFL /NDL /NJH /NJS >nul
241- if errorlevel 8 echo Robocopy returned %errorlevel%
242- start "" %OLD_EXE%
243- """ )
244- with open (bat_path , "w" , encoding = "utf-8" ) as f :
245- f .write (script )
246- env = os .environ .copy ()
247- subprocess .Popen (["cmd" , "/c" , bat_path ], creationflags = 0x08000000 , env = env )
248- sys .exit (0 )
249- else :
250- backup = old_exe + ".bak"
251- try :
252- os .replace (old_exe , backup )
253- except Exception :
254- pass
255- os .replace (new_exe_path , old_exe )
256- env = os .environ .copy ()
257- subprocess .Popen ([old_exe ] + sys .argv [1 :], cwd = os .path .dirname (old_exe ), env = env )
258- sys .exit (0 )
259-
260- except Exception as e :
261- self .update_error .emit (str (e ))
198+ cand_ver = SemVer (candidate )
199+ except Exception :
200+ continue
201+ if cand_ver > current and self ._version_has_bundle (candidate ):
202+ target = candidate
203+ break
204+
205+ if not target :
206+ self .update_error .emit ("Already running latest version." )
262207 return False
263208
209+ return self .download_and_apply_version (target )
210+
264211 # ---------- multi-version support ----------
265212 def list_available_versions (self , limit : int = 15 ) -> list [str ]:
266213 """
@@ -282,7 +229,7 @@ def list_available_versions(self, limit: int = 15) -> list[str]:
282229 tag = tag [1 :]
283230 # Ensure matching asset exists
284231 assets = rel .get ('assets' ) or []
285- found = any ((a .get ('name' ) or '' ).startswith (pref ) and (a .get ('name' ) or '' ).endswith ('.tar.gz' ) for a in assets )
232+ found = any ((a .get ('name' ) or '' ).startswith (pref ) and (a .get ('name' ) or '' ).endswith (tuple ( self . bundle_extensions ) ) for a in assets )
286233 if found and tag :
287234 versions .append (tag )
288235 # Deduplicate while preserving order
@@ -416,4 +363,4 @@ def _extract_archive(self, archive_path: str, dest_dir: str) -> None:
416363 else :
417364 import tarfile
418365 with tarfile .open (archive_path , "r:*" ) as tf :
419- tf .extractall (path = dest_dir )
366+ tf .extractall (path = dest_dir )
0 commit comments