Skip to content

Commit b3ed5c0

Browse files
committed
Windows auto update
1 parent b8556dc commit b3ed5c0

2 files changed

Lines changed: 290 additions & 2 deletions

File tree

utils/update_check.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"latest": None,
3131
"html_url": None,
3232
"error": None,
33+
"assets": [],
3334
}
3435

3536

@@ -162,6 +163,7 @@ def run_check(current_version: str) -> None:
162163
tag = (cache.get("tag_name") or "").strip()
163164
if tag:
164165
_apply_release_tag(tag, cache.get("html_url") or "", current_version)
166+
_state["assets"] = cache.get("assets") or []
165167
return
166168
err = cache.get("last_error")
167169
_state["error"] = (
@@ -181,6 +183,7 @@ def run_check(current_version: str) -> None:
181183
tag = (cache.get("tag_name") or "").strip()
182184
url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL
183185
_apply_release_tag(tag, url, current_version)
186+
_state["assets"] = cache.get("assets") or []
184187
if new_etag:
185188
cache["etag"] = new_etag
186189
_save_cache(cache_path, cache)
@@ -200,6 +203,13 @@ def run_check(current_version: str) -> None:
200203
cache["etag"] = new_etag
201204
cache["tag_name"] = tag
202205
cache["html_url"] = html_url
206+
assets = [
207+
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
208+
for a in (data.get("assets") or [])
209+
if a.get("name") and a.get("browser_download_url")
210+
]
211+
_state["assets"] = assets
212+
cache["assets"] = assets
203213
cache.pop("last_error", None)
204214
_save_cache(cache_path, cache)
205215
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:
@@ -221,3 +231,45 @@ def run_check(current_version: str) -> None:
221231
def get_status() -> Dict[str, Any]:
222232
"""Снимок состояния после run_check (для подписей в настройках)."""
223233
return dict(_state)
234+
235+
236+
def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
237+
assets = _state.get("assets") or []
238+
if not assets:
239+
return None
240+
241+
# Try SHA256 match against release asset digests
242+
try:
243+
import hashlib
244+
h = hashlib.sha256()
245+
with open(exe_path, "rb") as f:
246+
while True:
247+
chunk = f.read(65536)
248+
if not chunk:
249+
break
250+
h.update(chunk)
251+
exe_sha = h.hexdigest().lower()
252+
for a in assets:
253+
d = (a.get("digest") or "").lower()
254+
if d.startswith("sha256:") and d[7:] == exe_sha:
255+
return a["url"], a["name"]
256+
except Exception:
257+
pass
258+
259+
# Fallback
260+
import struct
261+
is_64 = struct.calcsize("P") * 8 == 64
262+
try:
263+
is_modern = sys.getwindowsversion().major >= 10
264+
except Exception:
265+
is_modern = True
266+
if is_modern:
267+
name = "TgWsProxy_windows.exe"
268+
elif is_64:
269+
name = "TgWsProxy_windows_7_64bit.exe"
270+
else:
271+
name = "TgWsProxy_windows_7_32bit.exe"
272+
for a in assets:
273+
if a.get("name") == name:
274+
return a["url"], a["name"]
275+
return None

windows.py

Lines changed: 238 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import ctypes
44
import os
5+
import subprocess
56
import sys
67
import threading
78
import time
@@ -40,7 +41,7 @@
4041
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
4142
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
4243
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
43-
maybe_notify_update, quit_ctk, release_lock, restart_proxy,
44+
quit_ctk, release_lock, restart_proxy,
4445
save_config, start_proxy, stop_proxy, tg_proxy_url,
4546
)
4647
from ui.ctk_tray_ui import (
@@ -101,7 +102,9 @@ def _release_win_mutex() -> None:
101102
_MB_OK_ERR = 0x10
102103
_MB_OK_INFO = 0x40
103104
_MB_YESNO_Q = 0x24
105+
_MB_YESNOCANCEL_Q = 0x23
104106
_IDYES = 6
107+
_IDNO = 7
105108

106109

107110
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
@@ -116,6 +119,227 @@ def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
116119
return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES
117120

118121

122+
def update_ctk_form(
123+
text: str, title: str = "TG WS Proxy", download_url: Optional[str] = None,
124+
release_url: Optional[str] = None,
125+
) -> str:
126+
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
127+
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
128+
if result == _IDYES:
129+
return "update"
130+
if result == _IDNO:
131+
return "open"
132+
return "close"
133+
134+
result = {"value": "close"}
135+
136+
def _build(done: threading.Event) -> None:
137+
theme = ctk_theme_for_platform()
138+
root = create_ctk_toplevel(
139+
ctk,
140+
title=title,
141+
width=310 if IS_FROZEN else 210,
142+
height=130 if IS_FROZEN else 100,
143+
theme=theme,
144+
after_create=lambda r: r.iconbitmap(ICON_PATH),
145+
)
146+
frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
147+
148+
ctk.CTkLabel(
149+
frame,
150+
text=text,
151+
justify="left",
152+
anchor="w",
153+
wraplength=270,
154+
font=(theme.ui_font_family, 12),
155+
text_color=theme.text_primary,
156+
).pack(fill="x", pady=(0, 10))
157+
158+
row = ctk.CTkFrame(frame, fg_color="transparent")
159+
row.pack(fill="x")
160+
161+
status_label = ctk.CTkLabel(
162+
frame, text="", justify="left", anchor="w", wraplength=270,
163+
font=(theme.ui_font_family, 11), text_color=theme.text_secondary,
164+
)
165+
status_label.pack(fill="x", pady=(6, 0))
166+
167+
btns: list = []
168+
169+
def _set_status(msg: str) -> None:
170+
root.after(0, lambda: status_label.configure(text=msg))
171+
172+
def _close_with(value: str) -> None:
173+
result["value"] = value
174+
root.destroy()
175+
done.set()
176+
177+
def _on_update() -> None:
178+
if not download_url:
179+
if release_url:
180+
webbrowser.open(release_url)
181+
_close_with("open")
182+
return
183+
for b in btns:
184+
b.configure(state="disabled")
185+
root.protocol("WM_DELETE_WINDOW", lambda: None)
186+
def _run():
187+
_perform_update(download_url, set_status=_set_status)
188+
root.after(0, lambda: [b.configure(state="normal") for b in btns])
189+
root.after(0, lambda: root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close")))
190+
threading.Thread(target=_run, daemon=True).start()
191+
192+
if IS_FROZEN:
193+
btn_upd = ctk.CTkButton(
194+
row, text="Обновить", width=88, height=34,
195+
font=(theme.ui_font_family, 13), command=_on_update,
196+
)
197+
btn_upd.pack(side="left", padx=(0, 6))
198+
btns.append(btn_upd)
199+
btn_pg = ctk.CTkButton(
200+
row, text="Страница", width=88, height=34,
201+
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"),
202+
)
203+
btn_pg.pack(side="left", padx=(0, 6))
204+
btns.append(btn_pg)
205+
btn_cl = ctk.CTkButton(
206+
row, text="Закрыть", width=88, height=34,
207+
font=(theme.ui_font_family, 13),
208+
fg_color=theme.field_bg, hover_color=theme.field_border,
209+
text_color=theme.text_primary, border_width=1, border_color=theme.field_border,
210+
command=lambda: _close_with("close"),
211+
)
212+
btn_cl.pack(side="left")
213+
btns.append(btn_cl)
214+
215+
root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close"))
216+
217+
ctk_run_dialog(_build)
218+
return result["value"]
219+
220+
221+
def _perform_update(download_url: str, set_status=None) -> None:
222+
import tempfile
223+
import urllib.request
224+
225+
def _step(msg: str) -> None:
226+
log.info("Update: %s", msg)
227+
if set_status:
228+
set_status(msg)
229+
time.sleep(0.8)
230+
231+
def _err(msg: str) -> None:
232+
log.error("Update error: %s", msg)
233+
if set_status:
234+
set_status(f"Ошибка: {msg}")
235+
else:
236+
_show_error(msg)
237+
238+
_step("Скачивание...")
239+
cur_exe = Path(sys.executable)
240+
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
241+
tmp_path = None
242+
try:
243+
fd, tmp_name = tempfile.mkstemp(dir=cur_exe.parent, suffix=".tmp")
244+
os.close(fd)
245+
tmp_path = Path(tmp_name)
246+
log.info("Downloading update from %s", download_url)
247+
urllib.request.urlretrieve(download_url, str(tmp_path))
248+
except Exception as exc:
249+
_err(f"Не удалось скачать:\n{exc}")
250+
if tmp_path:
251+
try:
252+
tmp_path.unlink(missing_ok=True)
253+
except OSError:
254+
pass
255+
return
256+
257+
_step("Замена файла...")
258+
try:
259+
if old_exe.exists():
260+
old_exe.unlink()
261+
cur_exe.rename(old_exe)
262+
except Exception as exc:
263+
_err(f"Не удалось переименовать файл:\n{exc}")
264+
try:
265+
tmp_path.unlink(missing_ok=True)
266+
except OSError:
267+
pass
268+
return
269+
270+
try:
271+
tmp_path.rename(cur_exe)
272+
except Exception as exc:
273+
_err(f"Не удалось переместить файл:\n{exc}")
274+
try:
275+
old_exe.rename(cur_exe)
276+
except OSError:
277+
pass
278+
try:
279+
tmp_path.unlink(missing_ok=True)
280+
except OSError:
281+
pass
282+
return
283+
284+
_step("Перезапуск...")
285+
_release_win_mutex()
286+
stop_proxy()
287+
288+
# Don't reuse existing _MEI* dir
289+
env = os.environ.copy()
290+
for _k in [k for k in env if k.startswith("_PYI_") or k == "_MEIPASS"]:
291+
del env[_k]
292+
if hasattr(sys, "_MEIPASS"):
293+
_mei = os.path.normcase(sys._MEIPASS.rstrip("\\/"))
294+
env["PATH"] = os.pathsep.join(
295+
p for p in env.get("PATH", "").split(os.pathsep)
296+
if os.path.normcase(p.rstrip("\\/")) != _mei
297+
)
298+
299+
try:
300+
subprocess.Popen(
301+
[str(cur_exe)],
302+
env=env,
303+
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
304+
)
305+
except Exception as exc:
306+
log.error("Failed to launch updated exe: %s", exc)
307+
time.sleep(0.5)
308+
os._exit(0)
309+
310+
311+
def _maybe_do_update(cfg: dict, is_exiting) -> None:
312+
if not cfg.get("check_updates", True):
313+
return
314+
315+
def _work():
316+
time.sleep(1.5)
317+
if is_exiting():
318+
return
319+
try:
320+
from proxy import __version__
321+
from utils.update_check import RELEASES_PAGE_URL, get_status, get_update_asset, run_check
322+
323+
run_check(__version__)
324+
st = get_status()
325+
if not st.get("has_update") or is_exiting():
326+
return
327+
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
328+
ver = st.get("latest") or "?"
329+
asset = get_update_asset(Path(sys.executable)) if IS_FROZEN else None
330+
choice = update_ctk_form(
331+
f"Доступна новая версия: {ver}",
332+
download_url=asset[0] if asset else None,
333+
release_url=url,
334+
)
335+
if choice == "open":
336+
webbrowser.open(url)
337+
except Exception as exc:
338+
log.warning("Update check failed: %s", repr(exc))
339+
340+
threading.Thread(target=_work, daemon=True, name="update-check").start()
341+
342+
119343
# autostart (registry)
120344

121345
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
@@ -370,7 +594,7 @@ def run_tray() -> None:
370594
return
371595

372596
start_proxy(_config, _show_error)
373-
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
597+
_maybe_do_update(_config, lambda: _exiting)
374598
_show_first_run()
375599
check_ipv6_warning(_show_info)
376600

@@ -387,6 +611,18 @@ def main() -> None:
387611
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
388612
return
389613

614+
if IS_FROZEN:
615+
def _cleanup_old_exes():
616+
exe_dir = Path(sys.executable).parent
617+
time.sleep(3)
618+
for _f in exe_dir.glob("*_oldtgws.exe"):
619+
try:
620+
_f.unlink()
621+
log.info("Deleted leftover: %s", _f)
622+
except OSError:
623+
pass
624+
threading.Thread(target=_cleanup_old_exes, daemon=True, name="cleanup-old").start()
625+
390626
try:
391627
run_tray()
392628
finally:

0 commit comments

Comments
 (0)