11# Jack Murray
22# Nova Foundry / Echo Hub
3- # v1.0.4
3+ # v1.1.0
44
55import os
66import sys
1111import customtkinter as ctk
1212from tkinter import filedialog
1313from PIL import Image
14+ import urllib .request
15+ import json
1416
1517# ---------- CONFIG ----------
1618IMPORT_DESTINATION = r"Working_game"
1719EXPORT_SOURCE = r"Working_game"
1820DEFAULT_WIDTH = 600
1921DEFAULT_HEIGHT = 640 # taller window to fit new button
2022PROGRESS_AREA_HEIGHT = 70
23+ VERSION = "2.1"
24+ GITURL = "https://github.com/DirectedHunt42/EchoEngine"
2125
2226# ---------- Helper Functions ----------
2327def show_custom_message (title , message , is_error = False ):
@@ -75,6 +79,9 @@ def load_resized_image(path, max_size=64):
7579 print (f"Could not load image: { e } " )
7680 return None
7781
82+ def version_tuple (v ):
83+ return tuple (map (int , v .split ('.' )))
84+
7885# ---------- Progress Bar Logic ----------
7986def show_progress_indicators ():
8087 new_height = DEFAULT_HEIGHT + PROGRESS_AREA_HEIGHT
@@ -94,18 +101,19 @@ def hide_progress_indicators():
94101 app .geometry (f"{ DEFAULT_WIDTH } x{ DEFAULT_HEIGHT } " )
95102 app .minsize (DEFAULT_WIDTH , DEFAULT_HEIGHT )
96103
97- def update_progress_indicators (current , total , progress ):
98- progress_bar .set (progress )
99- file_status_label .configure (text = f"File { current } of { total } " )
100- app .update_idletasks ()
101-
102104def run_with_progress (task_name , actions ):
103105 def task ():
104106 total_files = len (actions )
105- for i , action in enumerate (actions , start = 1 ):
107+ for i , item in enumerate (actions , start = 1 ):
108+ if isinstance (item , tuple ):
109+ action , desc = item
110+ else :
111+ action = item
112+ desc = "Processing"
113+ app .after (0 , lambda d = desc , c = i , t = total_files : file_status_label .configure (text = f"{ d } ({ c } /{ t } )" ))
106114 action ()
107115 progress = i / total_files if total_files else 1
108- app .after (0 , lambda c = i , t = total_files , p = progress : update_progress_indicators ( c , t , p ))
116+ app .after (0 , lambda p = progress : progress_bar . set ( p ))
109117 app .after (0 , task_done )
110118 def task_done ():
111119 hide_progress_indicators ()
@@ -119,38 +127,54 @@ def task_done():
119127 threading .Thread (target = task , daemon = True ).start ()
120128
121129# ---------- Folder Utilities ----------
130+ def get_clear_actions (folder_path ):
131+ actions = []
132+ for root , dirs , files in os .walk (folder_path , topdown = False ):
133+ for filename in files :
134+ file_path = os .path .join (root , filename )
135+ actions .append ((lambda p = file_path : os .unlink (p ), f"Deleting { os .path .relpath (file_path , folder_path )} " ))
136+ for dir_name in dirs :
137+ dir_path = os .path .join (root , dir_name )
138+ actions .append ((lambda p = dir_path : os .rmdir (p ), f"Removing directory { os .path .relpath (dir_path , folder_path )} " ))
139+ return actions
140+
122141def clear_folder (folder_path ):
123142 if not os .path .exists (folder_path ):
124143 show_custom_message ("Info" , f"'{ folder_path } ' does not exist." )
125144 return
126145 if not ask_confirmation ("Confirm Deletion" ,
127146 f"The contents of '{ folder_path } ' will be permanently deleted.\n Proceed?" ):
128147 return
129- for filename in os .listdir (folder_path ):
130- file_path = os .path .join (folder_path , filename )
131- try :
132- if os .path .isfile (file_path ) or os .path .islink (file_path ):
133- os .unlink (file_path )
134- elif os .path .isdir (file_path ):
135- shutil .rmtree (file_path )
136- except Exception as e :
137- print (f"Failed to delete { file_path } . Reason: { e } " )
148+ actions = get_clear_actions (folder_path )
149+ if actions :
150+ run_with_progress ("Clearing working directory" , actions )
151+ else :
152+ show_custom_message ("Info" , "Directory is already empty." )
138153
139154def copy_folder_with_progress (src , dest ):
155+ actions = []
140156 if os .path .exists (dest ):
141157 if not ask_confirmation ("Overwrite Project" ,
142158 f"The working directory '{ dest } ' contains project files.\n Overwrite its contents?" ):
143159 return []
144- clear_folder (dest )
145- os .makedirs (dest , exist_ok = True )
146- actions = []
147- for item in os .listdir (src ):
148- s = os .path .join (src , item )
149- d = os .path .join (dest , item )
150- if os .path .isdir (s ):
151- actions .append (lambda s = s , d = d : shutil .copytree (s , d , dirs_exist_ok = True ))
152- else :
153- actions .append (lambda s = s , d = d : shutil .copy2 (s , d ))
160+ actions .extend (get_clear_actions (dest ))
161+ # Collect directory creation actions
162+ for root , dirs , _ in os .walk (src ):
163+ rel_root = os .path .relpath (root , src )
164+ dest_root = os .path .join (dest , rel_root )
165+ for dir_name in dirs :
166+ dir_path = os .path .join (dest_root , dir_name )
167+ rel_dir = os .path .join (rel_root , dir_name )
168+ actions .append ((lambda p = dir_path : os .makedirs (p , exist_ok = True ), f"Creating directory { rel_dir } " ))
169+ # Collect file copy actions
170+ for root , _ , files in os .walk (src ):
171+ rel_root = os .path .relpath (root , src )
172+ dest_root = os .path .join (dest , rel_root )
173+ for filename in files :
174+ src_path = os .path .join (root , filename )
175+ dest_path = os .path .join (dest_root , filename )
176+ rel_path = os .path .join (rel_root , filename )
177+ actions .append ((lambda s = src_path , d = dest_path : shutil .copy2 (s , d ), f"Copying { rel_path } " ))
154178 return actions
155179
156180# ---------- Main Actions ----------
@@ -168,25 +192,43 @@ def import_zip():
168192 zip_path = filedialog .askopenfilename (filetypes = [("Echo Project" , "*.echo" )])
169193 if not zip_path :
170194 return
171- import_project_from_path (zip_path )
195+ import_project (zip_path )
172196
173- def import_project_from_path (zip_path ):
197+ def import_project (zip_path ):
174198 if os .path .exists (IMPORT_DESTINATION ):
175199 if not ask_confirmation ("Overwrite Project" ,
176200 f"The working directory '{ IMPORT_DESTINATION } ' contains project files.\n Overwrite its contents?" ):
177201 return
178- clear_folder (IMPORT_DESTINATION )
179- os .makedirs (IMPORT_DESTINATION , exist_ok = True )
180- try :
181- with zipfile .ZipFile (zip_path , 'r' ) as zip_ref :
182- file_list = [info for info in zip_ref .infolist () if not info .is_dir ()]
183- actions = [lambda info = info , z = zip_ref : z .extract (info .filename , IMPORT_DESTINATION ) for info in file_list ]
184- if actions :
185- run_with_progress ("Importing project" , actions )
186- else :
187- show_custom_message ("Success" , "Project imported successfully!" )
188- except Exception as e :
189- show_custom_message ("Error" , str (e ), is_error = True )
202+ for btn in (copy_btn , import_btn , export_btn , open_btn , clear_btn ):
203+ btn .configure (state = 'disabled' )
204+ status_label .configure (text = "Importing project..." )
205+ show_progress_indicators ()
206+ def task_done (success = True , message = "Project imported successfully!" ):
207+ hide_progress_indicators ()
208+ for btn in (copy_btn , import_btn , export_btn , open_btn , clear_btn ):
209+ btn .configure (state = 'normal' )
210+ if success :
211+ show_custom_message ("Success" , message )
212+ else :
213+ show_custom_message ("Error" , message , is_error = True )
214+ def import_task ():
215+ try :
216+ os .makedirs (IMPORT_DESTINATION , exist_ok = True )
217+ with zipfile .ZipFile (zip_path , 'r' ) as zip_ref :
218+ file_list = [info for info in zip_ref .infolist () if not info .is_dir ()]
219+ total_files = len (file_list )
220+ if not total_files :
221+ app .after (0 , task_done , True , "Empty project" )
222+ return
223+ for i , info in enumerate (file_list , start = 1 ):
224+ app .after (0 , lambda f = info .filename , c = i , t = total_files : file_status_label .configure (text = f"Extracting { f } ({ c } /{ t } )" ))
225+ zip_ref .extract (info , IMPORT_DESTINATION )
226+ progress = i / total_files
227+ app .after (0 , lambda p = progress : progress_bar .set (p ))
228+ app .after (0 , task_done )
229+ except Exception as e :
230+ app .after (0 , task_done , False , str (e ))
231+ threading .Thread (target = import_task , daemon = True ).start ()
190232
191233def export_zip ():
192234 zip_path = filedialog .asksaveasfilename (defaultextension = ".echo" ,
@@ -213,15 +255,16 @@ def export_task():
213255 full_path = os .path .join (root , file )
214256 arcname = os .path .relpath (full_path , EXPORT_SOURCE )
215257 file_paths .append ((full_path , arcname ))
216- if not file_paths :
258+ total_files = len (file_paths )
259+ if not total_files :
217260 app .after (0 , task_done , True , "No project found" )
218261 return
219- total_files = len (file_paths )
220262 with zipfile .ZipFile (zip_path , 'w' , zipfile .ZIP_DEFLATED ) as zip_ref :
221263 for i , (full_path , arcname ) in enumerate (file_paths , start = 1 ):
264+ app .after (0 , lambda a = arcname , c = i , t = total_files : file_status_label .configure (text = f"Adding { a } ({ c } /{ t } )" ))
222265 zip_ref .write (full_path , arcname )
223266 progress = i / total_files
224- app .after (0 , lambda c = i , t = total_files , p = progress : update_progress_indicators ( c , t , p ))
267+ app .after (0 , lambda p = progress : progress_bar . set ( p ))
225268 app .after (0 , task_done )
226269 except Exception as e :
227270 app .after (0 , task_done , False , str (e ))
@@ -234,12 +277,79 @@ def open_project():
234277 except Exception as e :
235278 show_custom_message ("Error" , str (e ), is_error = True )
236279
280+ # ---------- Auto Update ----------
281+ def check_for_update ():
282+ def check_task ():
283+ try :
284+ url = "https://api.github.com/repos/DirectedHunt42/EchoEngine/releases/latest"
285+ req = urllib .request .Request (url , headers = {'User-Agent' : 'EchoHub' , 'Accept' : 'application/vnd.github.v3+json' })
286+ with urllib .request .urlopen (req ) as response :
287+ data = json .loads (response .read ().decode ('utf-8' ))
288+ app .after (0 , lambda d = data : do_update_confirm (d ))
289+ except :
290+ pass
291+ threading .Thread (target = check_task , daemon = True ).start ()
292+
293+ def do_update_confirm (data ):
294+ try :
295+ title = data .get ('name' , '' )
296+ if title .startswith ("Release " ):
297+ new_ver = title [len ("Release " ):].strip ()
298+ else :
299+ return
300+ current_ver = VERSION
301+ current_t = version_tuple (current_ver )
302+ new_t = version_tuple (new_ver )
303+ if new_t > current_t :
304+ if ask_confirmation ("Update Available" , f"New version { new_ver } available (current { current_ver } ).\n Download and install?" ):
305+ download_and_install (data )
306+ except :
307+ pass
308+
309+ def download_and_install (data ):
310+ assets = data .get ('assets' , [])
311+ download_url = None
312+ for asset in assets :
313+ if asset ['name' ] == "Echo_Editor_Setup.exe" :
314+ download_url = asset ['browser_download_url' ]
315+ break
316+ if not download_url :
317+ show_custom_message ("Error" , "Update file not found." , is_error = True )
318+ return
319+ setup_file = os .path .join (os .path .dirname (sys .argv [0 ]), "Echo_Editor_Setup.exe" )
320+ for btn in (copy_btn , import_btn , export_btn , open_btn , clear_btn ):
321+ btn .configure (state = 'disabled' )
322+ status_label .configure (text = "Downloading update..." )
323+ show_progress_indicators ()
324+ def download_task ():
325+ try :
326+ def reporthook (count , block_size , total_size ):
327+ if total_size <= 0 :
328+ app .after (0 , file_status_label .configure (text = "Downloading..." ))
329+ return
330+ progress = min (1.0 , float (count * block_size ) / total_size )
331+ mb_done = (count * block_size ) / (1024 ** 2 )
332+ mb_total = total_size / (1024 ** 2 )
333+ app .after (0 , file_status_label .configure (text = f"Downloading ({ mb_done :.2f} /{ mb_total :.2f} MB)" ))
334+ app .after (0 , lambda p = progress : progress_bar .set (p ))
335+ urllib .request .urlretrieve (download_url , setup_file , reporthook )
336+ app .after (0 , hide_progress_indicators )
337+ app .after (0 , lambda : show_custom_message ("Update Ready" , "Update downloaded. Installing..." ))
338+ subprocess .Popen ([setup_file ])
339+ app .after (100 , app .destroy )
340+ except Exception as e :
341+ app .after (0 , hide_progress_indicators )
342+ app .after (0 , lambda : show_custom_message ("Error" , str (e ), is_error = True ))
343+ for btn in (copy_btn , import_btn , export_btn , open_btn , clear_btn ):
344+ btn .configure (state = 'normal' )
345+ threading .Thread (target = download_task , daemon = True ).start ()
346+
237347# ---------- Startup File Handling ----------
238348def check_startup_file ():
239349 if len (sys .argv ) > 1 :
240350 file_path = sys .argv [1 ]
241351 if file_path .lower ().endswith (".echo" ) and os .path .exists (file_path ):
242- import_project_from_path (file_path )
352+ import_project (file_path )
243353
244354# ---------- App Setup ----------
245355ctk .set_appearance_mode ("dark" )
@@ -312,9 +422,10 @@ def check_startup_file():
312422 logo_label = ctk .CTkLabel (frame , image = logo_ctk , text = "" )
313423 logo_label .pack (pady = 10 )
314424
315- ctk .CTkLabel (frame , text = "v1.0.4 " , font = ("Segoe UI" , 10 ), text_color = "gray" ).pack (pady = (0 , 10 ))
425+ ctk .CTkLabel (frame , text = "v1.1.0 " , font = ("Segoe UI" , 10 ), text_color = "gray" ).pack (pady = (0 , 10 ))
316426
317427# ---------- Start ----------
318428hide_progress_indicators ()
429+ check_for_update ()
319430check_startup_file ()
320431app .mainloop ()
0 commit comments