@@ -38,50 +38,129 @@ def is_newer(remote: str, local: str) -> bool:
3838# ---------------------------------------------------------------------------
3939
4040GITHUB_REPO = "CraftOS-dev/CraftBot"
41- GITHUB_LATEST_RELEASE_URL = f"https://api.github.com/repos/{ GITHUB_REPO } /tags"
41+ GITHUB_TAGS_URL = f"https://api.github.com/repos/{ GITHUB_REPO } /tags"
42+ GITHUB_LATEST_RELEASE_URL = GITHUB_TAGS_URL
43+ UPDATE_BRANCH = "main"
44+ GIT_PROBE_TIMEOUT = 15
4245
4346
4447async def check_for_update () -> Tuple [bool , str , str ]:
4548 """Check whether a newer version is available on the remote repo.
4649
47- Fetches the latest git tag from GitHub (e.g. ``v1.2.2``) and compares
48- it against the local version stored in settings.json.
50+ Source checkouts can be ahead or behind the update branch while still
51+ carrying the same tagged app version. Keep the release tag as the primary
52+ version signal, then use a git comparison to catch source-checkout updates
53+ when the release tag is unchanged.
4954
5055 Returns:
5156 (update_available, current_version, latest_version)
5257 """
5358 from app .config import get_app_version
5459
60+ current = get_app_version ()
61+ project_root = Path (__file__ ).resolve ().parent .parent
62+
63+ release_update = await _check_release_update (current )
64+ if release_update [0 ]:
65+ return release_update
66+
67+ source_update = await _check_source_update (project_root , current )
68+ if source_update is not None :
69+ return source_update
70+
71+ return release_update
72+
73+
74+ async def _check_source_update (
75+ project_root : Path ,
76+ current : str ,
77+ branch : str = UPDATE_BRANCH ,
78+ ) -> Optional [Tuple [bool , str , str ]]:
79+ """Check whether this git checkout is behind the configured update branch.
80+
81+ Returns ``None`` when the probe is inconclusive so callers can fall back to
82+ the release-tag check instead of blocking update checks on git-specific
83+ failures.
84+ """
85+ if getattr (sys , "frozen" , False ):
86+ return None
87+
88+ try :
89+ inside , _ = await _run_git (
90+ ["git" , "rev-parse" , "--is-inside-work-tree" ], str (project_root )
91+ )
92+ if _decode_git_stdout (inside ) != "true" :
93+ return None
94+
95+ await _run_git (
96+ ["git" , "fetch" , "origin" , f"{ branch } :refs/remotes/origin/{ branch } " ],
97+ str (project_root ),
98+ )
99+ local_stdout , _ = await _run_git (
100+ ["git" , "rev-parse" , "HEAD" ], str (project_root )
101+ )
102+ remote_stdout , _ = await _run_git (
103+ ["git" , "rev-parse" , f"origin/{ branch } " ], str (project_root )
104+ )
105+
106+ local_revision = _decode_git_stdout (local_stdout )
107+ remote_revision = _decode_git_stdout (remote_stdout )
108+ if not local_revision or not remote_revision :
109+ return None
110+ if local_revision == remote_revision :
111+ return False , current , current
112+
113+ count_stdout , _ = await _run_git (
114+ [
115+ "git" ,
116+ "rev-list" ,
117+ "--left-right" ,
118+ "--count" ,
119+ f"HEAD...origin/{ branch } " ,
120+ ],
121+ str (project_root ),
122+ )
123+ _ahead_text , behind_text = _decode_git_stdout (count_stdout ).split ()
124+ behind = int (behind_text )
125+ if behind > 0 :
126+ latest = f"{ current } +{ branch } .{ remote_revision [:7 ]} "
127+ return True , current , latest
128+
129+ return False , current , current
130+ except Exception :
131+ return None
132+
133+
134+ async def _check_release_update (current : str ) -> Tuple [bool , str , str ]:
135+ """Check GitHub release tags against the local app version."""
55136 import aiohttp
56137
57- current = get_app_version ()
58138 try :
59139 headers = {"Accept" : "application/vnd.github.v3+json" }
60140 async with aiohttp .ClientSession () as session :
61141 async with session .get (
62- GITHUB_LATEST_RELEASE_URL ,
142+ GITHUB_TAGS_URL ,
63143 headers = headers ,
64144 timeout = aiohttp .ClientTimeout (total = 15 ),
65145 ) as resp :
66146 tags = await resp .json (content_type = None )
67-
68- if not tags or not isinstance (tags , list ):
69- return False , current , current
70-
71- # Find the highest semver tag (tags are not guaranteed sorted)
72- latest = "0.0.0"
73- for tag in tags :
74- name = tag .get ("name" , "" )
75- try :
76- if parse_version (name ) > parse_version (latest ):
77- latest = name .strip ().lstrip ("vV" )
78- except (ValueError , AttributeError ):
79- continue
80-
81147 except Exception :
82- # Network error — treat as "no update available"
148+ # Network error — treat as "no update available".
149+ return False , current , current
150+
151+ if not tags or not isinstance (tags , list ):
83152 return False , current , current
84153
154+ # Find the highest semver tag. GitHub tags are not guaranteed sorted.
155+ latest = "0.0.0"
156+ for tag in tags :
157+ name = tag .get ("name" , "" )
158+ try :
159+ if parse_version (name ) > parse_version (latest ):
160+ latest = name .strip ().lstrip ("vV" )
161+ except (ValueError , AttributeError ):
162+ continue
163+
85164 return is_newer (latest , current ), current , latest
86165
87166
@@ -95,14 +174,12 @@ async def check_for_update() -> Tuple[bool, str, str]:
95174async def perform_update (
96175 progress_callback : Optional [Callable [[str ], Awaitable [None ]]] = None ,
97176) -> None :
98- """Launch the external updater script in a new window, then shut down.
99-
100- The updater script (scripts/updater.bat on Windows) runs in its own
101- visible terminal and handles: waiting for us to exit, git pull, install,
102- and relaunch. This keeps the update logic out of the running Python
103- process — no in-process git mutation, no exit-code signalling, no
104- console-visibility hacks. If the updater fails, its window stays open
105- showing the error.
177+ """Launch the external updater script, then shut down.
178+
179+ The script waits for CraftBot to exit, pulls the update branch, installs
180+ dependencies, and relaunches CraftBot. Running that work outside this
181+ process avoids in-process git mutation and exit-code signalling. Failures
182+ are written to updater.log.
106183 """
107184
108185 async def emit (msg : str ) -> None :
@@ -111,12 +188,8 @@ async def emit(msg: str) -> None:
111188
112189 project_root = Path (__file__ ).resolve ().parent .parent
113190
114- target_branch = "main"
115-
116- if sys .platform == "win32" :
117- updater_script = project_root / "scripts" / "updater.bat"
118- else :
119- updater_script = project_root / "scripts" / "updater.sh"
191+ target_branch = UPDATE_BRANCH
192+ updater_script = _updater_script_path (project_root )
120193
121194 if not updater_script .exists ():
122195 raise RuntimeError (f"Updater script not found: { updater_script } " )
@@ -131,14 +204,14 @@ async def emit(msg: str) -> None:
131204 CREATE_NO_WINDOW = 0x08000000
132205 DETACHED_PROCESS = 0x00000008
133206 subprocess .Popen (
134- [str (updater_script ), target_branch ],
207+ [str (updater_script ), target_branch , sys . executable ],
135208 cwd = str (project_root ),
136209 creationflags = DETACHED_PROCESS | CREATE_NO_WINDOW ,
137210 close_fds = True ,
138211 )
139212 else :
140213 subprocess .Popen (
141- ["sh" , str (updater_script ), target_branch ],
214+ ["sh" , str (updater_script ), target_branch , sys . executable ],
142215 cwd = str (project_root ),
143216 start_new_session = True ,
144217 )
@@ -155,15 +228,39 @@ async def emit(msg: str) -> None:
155228# ---------------------------------------------------------------------------
156229
157230
158- async def _run_git (cmd : list , cwd : str ) -> Tuple [bytes , bytes ]:
231+ def _decode_git_stdout (stdout : bytes ) -> str :
232+ return stdout .decode ("utf-8" , errors = "replace" ).strip ()
233+
234+
235+ def _updater_script_path (project_root : Path , platform : str = sys .platform ) -> Path :
236+ if platform == "win32" :
237+ return project_root / "scripts" / "updater.bat"
238+ return project_root / "scripts" / "updater.sh"
239+
240+
241+ async def _run_git (
242+ cmd : list , cwd : str , timeout : int = GIT_PROBE_TIMEOUT
243+ ) -> Tuple [bytes , bytes ]:
159244 """Run a git command asynchronously; raise on non-zero exit."""
245+ env = os .environ .copy ()
246+ env .setdefault ("GIT_TERMINAL_PROMPT" , "0" )
247+
160248 proc = await asyncio .create_subprocess_exec (
161249 * cmd ,
162250 cwd = cwd ,
251+ env = env ,
163252 stdout = asyncio .subprocess .PIPE ,
164253 stderr = asyncio .subprocess .PIPE ,
165254 )
166- stdout , stderr = await proc .communicate ()
255+ try :
256+ stdout , stderr = await asyncio .wait_for (proc .communicate (), timeout = timeout )
257+ except asyncio .TimeoutError as exc :
258+ proc .kill ()
259+ stdout , stderr = await proc .communicate ()
260+ raise RuntimeError (
261+ f"{ ' ' .join (cmd )} timed out after { timeout } seconds"
262+ ) from exc
263+
167264 if proc .returncode != 0 :
168265 err = (
169266 stderr .decode ("utf-8" , errors = "replace" ).strip ()
0 commit comments