Skip to content

Commit 3fce996

Browse files
committed
feat: introduce app update progress dialog and enhance updater script with better path handling and logging.
1 parent 8cf2392 commit 3fce996

5 files changed

Lines changed: 189 additions & 78 deletions

File tree

main.py

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -740,38 +740,21 @@ def _check_for_update(self, force=False):
740740
)
741741

742742
def _do_app_update(self, update_info: dict):
743-
"""Download update and launch updater script."""
744-
try:
745-
from update_app import download_update, create_updater_script, launch_updater_and_exit
746-
747-
self.info_label.setText(tr('updater.downloading'))
748-
QApplication.processEvents()
749-
750-
# Download
751-
zip_path = download_update(update_info['url'])
752-
753-
self.info_label.setText(tr('updater.extracting'))
754-
QApplication.processEvents()
743+
"""Download update with progress dialog and restart on user action."""
744+
from progress_dialog import AppUpdateProgressDialog
745+
from update_app import launch_updater_and_exit
746+
from pathlib import Path
755747

756-
# Create bat script
757-
bat_path = create_updater_script(zip_path, update_info['latest'])
748+
dialog = AppUpdateProgressDialog(self)
758749

759-
self.info_label.setText(tr('updater.success'))
760-
QApplication.processEvents()
761-
762-
# Save state before exit
750+
def on_restart(bat_path_str):
763751
self.save_window_state()
764-
765-
# Launch updater and quit
766-
launch_updater_and_exit(bat_path)
752+
launch_updater_and_exit(Path(bat_path_str))
767753
QApplication.quit()
768754

769-
except Exception as e:
770-
QMessageBox.critical(
771-
self,
772-
tr('updater.title'),
773-
tr('updater.error', error=str(e))
774-
)
755+
dialog.restart_requested.connect(on_restart)
756+
dialog.start_download(update_info)
757+
dialog.exec()
775758

776759
def save_progress(self, position_sec, file_path):
777760
"""Save playback progress."""

progress_dialog.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,68 @@ def on_update_finished(self, success):
195195
self.status_label.setText(tr('libmpv_updater.success', version=''))
196196
else:
197197
self.status_label.setText(tr('libmpv_updater.error', error=''))
198+
199+
200+
class AppUpdateThread(QThread):
201+
"""Background thread for downloading app update and creating updater script."""
202+
progress = pyqtSignal(str)
203+
finished_update = pyqtSignal(bool, str, str) # success, zip_path, bat_path
204+
205+
def __init__(self, update_info: dict):
206+
super().__init__()
207+
self.update_info = update_info
208+
209+
def run(self):
210+
old_stdout = sys.stdout
211+
try:
212+
sys.stdout = OutputCapture(self.progress)
213+
214+
from update_app import download_update, create_updater_script
215+
216+
zip_path = download_update(self.update_info['url'])
217+
bat_path = create_updater_script(zip_path, self.update_info['latest'])
218+
219+
sys.stdout = old_stdout
220+
self.finished_update.emit(True, str(zip_path), str(bat_path))
221+
except Exception as e:
222+
sys.stdout = old_stdout
223+
self.progress.emit(f"Error: {e}")
224+
self.finished_update.emit(False, '', '')
225+
226+
227+
class AppUpdateProgressDialog(BaseProgressDialog):
228+
"""Download progress dialog with a restart button on success."""
229+
230+
restart_requested = pyqtSignal(str) # bat_path
231+
232+
def __init__(self, parent=None):
233+
super().__init__(parent, tr('updater.title'))
234+
self.status_label.setText(tr('updater.downloading'))
235+
236+
self.restart_btn = QPushButton(tr('updater.update_now'))
237+
self.restart_btn.setObjectName("updateNowBtn")
238+
self.restart_btn.setVisible(False)
239+
self.restart_btn.clicked.connect(self._on_restart)
240+
self.layout().insertWidget(self.layout().indexOf(self.close_btn), self.restart_btn)
241+
242+
self._bat_path = ''
243+
244+
def start_download(self, update_info: dict):
245+
self.update_thread = AppUpdateThread(update_info)
246+
self.thread = self.update_thread
247+
self.update_thread.progress.connect(self.append_log)
248+
self.update_thread.finished_update.connect(self._on_download_finished)
249+
self.update_thread.start()
250+
251+
def _on_download_finished(self, success: bool, zip_path: str, bat_path: str):
252+
super().on_finished()
253+
if success:
254+
self._bat_path = bat_path
255+
self.status_label.setText(tr('updater.success'))
256+
self.restart_btn.setVisible(True)
257+
else:
258+
self.status_label.setText(tr('updater.error', error=''))
259+
260+
def _on_restart(self):
261+
self.restart_requested.emit(self._bat_path)
262+
self.accept()

update_app.py

Lines changed: 108 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@
3232
}
3333

3434

35+
def _get_app_dir() -> Path:
36+
"""
37+
Get the top-level application directory.
38+
In frozen PyInstaller onedir builds, ROOT_DIR points to _internal/,
39+
but the .exe lives one level up. This returns the correct dir.
40+
"""
41+
if getattr(sys, 'frozen', False):
42+
return Path(sys.executable).parent
43+
return ROOT_DIR
44+
45+
3546
def get_current_version() -> str:
3647
"""Read current app version from resources/version.txt."""
3748
try:
@@ -112,19 +123,47 @@ def check_for_update() -> dict | None:
112123

113124
def download_update(download_url: str) -> Path:
114125
"""
115-
Download update zip to ROOT_DIR/_update_download.zip.
126+
Download update zip to app_dir/_update_download.zip.
116127
Uses Downloader from update_libmpv for multi-threaded download.
117128
Returns path to downloaded file.
118129
"""
119-
target = ROOT_DIR / '_update_download.zip'
130+
import time as _time
131+
132+
app_dir = _get_app_dir()
133+
target = app_dir / '_update_download.zip'
134+
135+
print("=" * 50)
136+
print(" Downloading application update...")
137+
print("=" * 50)
138+
print(f"URL: {download_url}")
139+
print(f"Target: {target}")
140+
print("")
120141

121142
try:
122-
from update_libmpv import Downloader
143+
from update_libmpv import Downloader, format_size
123144
downloader = Downloader(download_url, str(target))
124-
downloader.download()
145+
146+
# Get file size before download
147+
req = urllib.request.Request(download_url, headers={'User-Agent': USER_AGENT}, method='HEAD')
148+
try:
149+
with urllib.request.urlopen(req, timeout=15) as resp:
150+
total = int(resp.info().get('Content-Length', 0))
151+
if total > 0:
152+
print(f"File size: {format_size(total)}")
153+
except Exception:
154+
pass
155+
156+
print(f"Threads: {downloader.num_threads}")
157+
print("Downloading...")
158+
print("")
159+
160+
duration = downloader.download()
161+
162+
print("")
163+
print(f"Download completed in {duration:.1f}s")
125164
except ImportError:
126165
# Fallback: simple download
127-
print(f"Downloading update from {download_url}...")
166+
print("Using single-thread fallback...")
128167
req = urllib.request.Request(download_url, headers={'User-Agent': USER_AGENT})
129168
with urllib.request.urlopen(req, timeout=300) as response:
130169
with open(target, 'wb') as f:
@@ -137,6 +176,11 @@ def download_update(download_url: str) -> Path:
137176

138177
if not target.exists():
139178
raise RuntimeError("Download failed: file not found")
179+
180+
size_mb = target.stat().st_size / (1024 * 1024)
181+
print(f"Saved: {target.name} ({size_mb:.1f} MB)")
182+
print("")
183+
print("Creating updater script...")
140184
return target
141185

142186

@@ -145,44 +189,48 @@ def create_updater_script(zip_path: Path, new_version: str) -> Path:
145189
Generate _updater.bat that waits for app exit, extracts zip,
146190
copies files (skipping protected ones), restarts app, self-deletes.
147191
"""
148-
bat_path = ROOT_DIR / '_updater.bat'
192+
app_dir = _get_app_dir()
193+
bat_path = app_dir / '_updater.bat'
149194
exe_name = _get_exe_name()
195+
exe_path = _get_exe_path()
150196
current_pid = os.getpid()
151197

152-
# Build exclusion filter for findstr
153-
# Each protected item on its own line for findstr matching
154-
protected_patterns = '\\n'.join([
155-
f"\\\\{item}" if not item.endswith(('.exe', '.dll', '.ini', '.version'))
156-
else f"\\\\{item}"
157-
for item in PROTECTED_ITEMS
158-
])
159-
160198
script = f"""@echo off
161199
chcp 65001 >nul
162200
setlocal EnableDelayedExpansion
163201
164202
set "APP_DIR=%~dp0"
165-
set "EXE_NAME={exe_name}"
203+
set "EXE_PATH={exe_path}"
166204
set "PID={current_pid}"
167205
set "ZIP_PATH={zip_path}"
168206
set "TEMP_DIR=%APP_DIR%_update_temp"
169207
set "VERSION={new_version}"
208+
set "LOG=%APP_DIR%_update_log.txt"
209+
210+
echo [%date% %time%] Update started > "%LOG%"
211+
echo APP_DIR=%APP_DIR% >> "%LOG%"
212+
echo EXE_PATH=%EXE_PATH% >> "%LOG%"
213+
echo ZIP_PATH=%ZIP_PATH% >> "%LOG%"
170214
171215
REM --- Wait for application to exit ---
216+
echo Waiting for PID %PID% to exit... >> "%LOG%"
172217
:wait_loop
173218
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
174219
if not errorlevel 1 (
175220
timeout /t 1 /nobreak >nul
176221
goto wait_loop
177222
)
223+
echo Process exited. >> "%LOG%"
178224
179225
REM --- Small delay for file handles to release ---
180-
timeout /t 2 /nobreak >nul
226+
timeout /t 3 /nobreak >nul
181227
182228
REM --- Extract zip to temp directory ---
183229
if exist "%TEMP_DIR%" rmdir /s /q "%TEMP_DIR%"
184230
mkdir "%TEMP_DIR%" 2>nul
231+
echo Extracting zip... >> "%LOG%"
185232
powershell -Command "Expand-Archive -Path '%ZIP_PATH%' -DestinationPath '%TEMP_DIR%' -Force"
233+
echo Extraction done. >> "%LOG%"
186234
187235
REM --- Find the actual content root (may be nested in a subfolder) ---
188236
set "SOURCE_DIR=%TEMP_DIR%"
@@ -193,76 +241,83 @@ def create_updater_script(zip_path: Path, new_version: str) -> Path:
193241
for /d %%D in ("%TEMP_DIR%\\*") do (
194242
set /a SUBFOLDER_COUNT+=1
195243
set "LAST_SUBFOLDER=%%D"
244+
echo Found subfolder: %%D >> "%LOG%"
196245
)
197246
198-
REM If exactly one subfolder and no files at root, use it as source
247+
REM Count files at root level
199248
set "FILE_COUNT=0"
200-
for %%F in ("%TEMP_DIR%\\*.*") do set /a FILE_COUNT+=1
249+
for %%F in ("%TEMP_DIR%\\*.*") do (
250+
set /a FILE_COUNT+=1
251+
echo Found root file: %%F >> "%LOG%"
252+
)
201253
202-
if %SUBFOLDER_COUNT% equ 1 if %FILE_COUNT% equ 0 (
254+
if !SUBFOLDER_COUNT! equ 1 if !FILE_COUNT! equ 0 (
203255
set "SOURCE_DIR=!LAST_SUBFOLDER!"
256+
echo Using subfolder as source: !SOURCE_DIR! >> "%LOG%"
204257
)
205258
206-
REM --- Copy files, skipping protected items ---
207-
xcopy "!SOURCE_DIR!\\*" "%APP_DIR%" /e /y /i /exclude:%~f0.exc >nul 2>&1
259+
echo SOURCE_DIR=!SOURCE_DIR! >> "%LOG%"
260+
261+
REM --- List source contents for debugging ---
262+
echo Source directory contents: >> "%LOG%"
263+
dir /b "!SOURCE_DIR!" >> "%LOG%" 2>&1
208264
209-
REM If xcopy exclude doesn't work well, use robocopy as fallback
210-
robocopy "!SOURCE_DIR!" "%APP_DIR%" /e /xf settings.ini ffmpeg.exe ffprobe.exe libmpv-2.dll libmpv.version _updater.bat /xd data _update_temp >nul 2>&1
265+
REM --- Copy all files using robocopy, excluding protected items ---
266+
echo Starting robocopy... >> "%LOG%"
267+
robocopy "!SOURCE_DIR!" "%APP_DIR%." /e /np /njh /njs /r:3 /w:2 /xf settings.ini ffmpeg.exe ffprobe.exe libmpv-2.dll libmpv.version _updater.bat _updater.bat.exc _update_log.txt /xd data _update_temp >> "%LOG%" 2>&1
268+
echo Robocopy exit code: !errorlevel! >> "%LOG%"
211269
212270
REM --- Update version file ---
213-
echo %VERSION%> "%APP_DIR%resources\\version.txt"
271+
if exist "%APP_DIR%_internal\\resources\\version.txt" (
272+
echo %VERSION%> "%APP_DIR%_internal\\resources\\version.txt"
273+
echo Updated version in _internal\\resources\\version.txt >> "%LOG%"
274+
) else if exist "%APP_DIR%resources\\version.txt" (
275+
echo %VERSION%> "%APP_DIR%resources\\version.txt"
276+
echo Updated version in resources\\version.txt >> "%LOG%"
277+
)
214278
215279
REM --- Cleanup ---
280+
echo Cleaning up... >> "%LOG%"
216281
rmdir /s /q "%TEMP_DIR%" 2>nul
217282
del /f /q "%ZIP_PATH%" 2>nul
218283
219284
REM --- Restart application ---
220-
start "" "%APP_DIR%%EXE_NAME%"
285+
echo Starting: %EXE_PATH% >> "%LOG%"
286+
start "" "%EXE_PATH%"
221287
222288
REM --- Bring window to foreground ---
223289
timeout /t 2 /nobreak >nul
224-
powershell -Command "(New-Object -ComObject WScript.Shell).AppActivate('%EXE_NAME%')" >nul 2>&1
290+
powershell -Command "(New-Object -ComObject WScript.Shell).AppActivate('{exe_name}')" >nul 2>&1
225291
226292
REM --- Self-delete ---
293+
echo Update complete. >> "%LOG%"
227294
del "%~f0.exc" 2>nul
228295
(goto) 2>nul & del "%~f0"
229296
"""
230297

231-
# Write exclusion file for xcopy (one pattern per line)
232-
exc_path = Path(str(bat_path) + '.exc')
233-
exc_lines = [
234-
'settings.ini',
235-
'ffmpeg.exe',
236-
'ffprobe.exe',
237-
'libmpv-2.dll',
238-
'libmpv.version',
239-
'_updater.bat',
240-
'\\data\\',
241-
'\\_update_temp\\',
242-
]
243-
exc_path.write_text('\n'.join(exc_lines), encoding='utf-8')
244-
245298
bat_path.write_text(script, encoding='utf-8')
246299
return bat_path
247300

248301

249302
def launch_updater_and_exit(bat_path: Path):
250303
"""Launch the updater bat script hidden and quit the application."""
304+
app_dir = _get_app_dir()
251305
subprocess.Popen(
252306
['cmd', '/c', str(bat_path)],
253307
creationflags=subprocess.CREATE_NO_WINDOW,
254308
close_fds=True,
255-
cwd=str(ROOT_DIR),
309+
cwd=str(app_dir),
256310
)
257311

258312

259313
def cleanup_update_artifacts():
260314
"""Remove leftover update files from previous runs."""
315+
app_dir = _get_app_dir()
261316
artifacts = [
262-
ROOT_DIR / '_update_temp',
263-
ROOT_DIR / '_update_download.zip',
264-
ROOT_DIR / '_updater.bat',
265-
ROOT_DIR / '_updater.bat.exc',
317+
app_dir / '_update_temp',
318+
app_dir / '_update_download.zip',
319+
app_dir / '_updater.bat',
320+
app_dir / '_updater.bat.exc',
266321
]
267322
for path in artifacts:
268323
try:
@@ -276,8 +331,14 @@ def cleanup_update_artifacts():
276331

277332

278333
def _get_exe_name() -> str:
279-
"""Get the name of the running executable."""
334+
"""Get the name of the running executable (just filename)."""
280335
if getattr(sys, 'frozen', False):
281336
return Path(sys.executable).name
282-
# Dev mode: return python script name
283337
return 'SP Video Courses Player.exe'
338+
339+
340+
def _get_exe_path() -> str:
341+
"""Get the full path to the running executable."""
342+
if getattr(sys, 'frozen', False):
343+
return str(Path(sys.executable))
344+
return str(ROOT_DIR / 'SP Video Courses Player.exe')

0 commit comments

Comments
 (0)