22
33import ctypes
44import os
5+ import subprocess
56import sys
67import threading
78import time
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)
4647from 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
107110def _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