Skip to content

Commit f63ae76

Browse files
committed
GUI: PyQt6 ffprobe non-blocking; hardened HTTPS; robust Tkinter logging; tests: headless stubs
1 parent d27b275 commit f63ae76

10 files changed

Lines changed: 99 additions & 19 deletions

.DS_Store

6 KB
Binary file not shown.

.vscode/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"Codegeex.Chat.fontSize": 12,
3+
"Codegeex.CommitMessageStyle": "ConventionalCommits"
4+
}

GUI_pyqt6_WINFF.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import zipfile
1313
import shlex
1414
import tarfile
15+
import ssl
1516
from io import BytesIO
1617
from functools import partial
1718
from utils_safe_extract import safe_tar_extract, safe_zip_extract
@@ -352,15 +353,16 @@ def _finalize_proc(self):
352353

353354
def _on_proc_finished(self, code, status):
354355
ok = (code == 0)
355-
self._finalize_proc()
356+
# Show dialog first, then finalize to avoid brief window where Convert is re-enabled during dialog
356357
if ok:
357358
QtWidgets.QMessageBox.information(self, 'Sucesso', 'Vídeo convertido com sucesso!')
358359
else:
359360
QtWidgets.QMessageBox.critical(self, 'Erro', f'Falha ao converter vídeo (código {code}).')
361+
self._finalize_proc()
360362

361363
def _on_proc_error(self, err):
362-
self._finalize_proc()
363364
QtWidgets.QMessageBox.critical(self, 'Erro', f'Erro de processo: {err}')
365+
self._finalize_proc()
364366

365367
def _append_log(self, msg):
366368
# Efficient append without rewriting whole buffer
@@ -387,13 +389,27 @@ def show_video_info(self):
387389
if not candidate or not os.path.exists(candidate):
388390
candidate = ffprobe_name # rely on PATH
389391
ffprobe = candidate
392+
# Run ffprobe non-blocking using QProcess
390393
try:
391-
command = [ffprobe, '-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', inp]
392-
p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
393-
out, err = p.communicate()
394-
if p.returncode != 0:
395-
raise RuntimeError('ffprobe error')
396-
data = json.loads(out)
394+
self._info_proc = QtCore.QProcess(self)
395+
self._info_proc.setProgram(ffprobe)
396+
self._info_proc.setArguments(['-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', inp])
397+
self._info_proc.setProcessChannelMode(QtCore.QProcess.ProcessChannelMode.MergedChannels)
398+
self._info_proc.finished.connect(self._on_info_finished)
399+
self._info_proc.errorOccurred.connect(lambda err: QtWidgets.QMessageBox.critical(self, 'Erro', f'Erro ao executar ffprobe: {err}'))
400+
self._info_proc.start()
401+
except FileNotFoundError:
402+
QtWidgets.QMessageBox.critical(self, 'Erro', 'ffprobe não encontrado (verifique PATH ou caminho configurado).')
403+
except Exception as e:
404+
QtWidgets.QMessageBox.critical(self, 'Erro', f'Falha ao iniciar ffprobe: {e}')
405+
406+
def _on_info_finished(self, code, status):
407+
try:
408+
out = bytes(self._info_proc.readAllStandardOutput()).decode('utf-8', errors='ignore') if hasattr(self, '_info_proc') else ''
409+
if code != 0:
410+
QtWidgets.QMessageBox.critical(self, 'Erro', f'ffprobe falhou (código {code}).')
411+
return
412+
data = json.loads(out or '{}')
397413
info_text = json.dumps(data, indent=2, ensure_ascii=False)
398414
dlg = QtWidgets.QDialog(self)
399415
dlg.setWindowTitle('Informações detalhadas do vídeo')
@@ -404,10 +420,12 @@ def show_video_info(self):
404420
v.addWidget(b)
405421
dlg.setLayout(v)
406422
dlg.exec()
407-
except FileNotFoundError:
408-
QtWidgets.QMessageBox.critical(self, 'Erro', 'ffprobe não encontrado (verifique PATH ou caminho configurado).')
409-
except Exception as e:
410-
QtWidgets.QMessageBox.critical(self, 'Erro', f'Falha ao obter info: {e}')
423+
finally:
424+
try:
425+
self._info_proc.deleteLater()
426+
except Exception:
427+
pass
428+
self._info_proc = None
411429

412430
def on_no_audio_change(self):
413431
disabled = self.no_audio_chk.isChecked()
@@ -422,13 +440,16 @@ def _http_get(self, url: str, timeout: int = 60) -> bytes:
422440
"""Download URL returning raw bytes. Tries requests first, then urllib as fallback."""
423441
try:
424442
import requests # type: ignore
425-
resp = requests.get(url, timeout=timeout)
443+
resp = requests.get(url, timeout=timeout, verify=True)
426444
resp.raise_for_status()
427445
return resp.content
428446
except Exception:
429447
# Fallback to urllib
430448
import urllib.request
431-
with urllib.request.urlopen(url, timeout=timeout) as resp: # nosec B310
449+
ctx = ssl.create_default_context()
450+
ctx.check_hostname = True
451+
ctx.verify_mode = ssl.CERT_REQUIRED
452+
with urllib.request.urlopen(url, timeout=timeout, context=ctx) as resp: # nosec B310
432453
if resp.status != 200:
433454
raise RuntimeError(f"HTTP {resp.status}")
434455
return resp.read()

GUI_tkinter_WINFF.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import tkinter as tk
1818
from tkinter import ttk, filedialog, messagebox
1919
from utils_safe_extract import safe_tar_extract, safe_zip_extract
20+
import ssl
2021

2122
import importlib
2223
try:
@@ -295,7 +296,10 @@ def _append_log(self, text: str):
295296
def _reader_worker(self):
296297
try:
297298
assert self._proc is not None
298-
for line in self._proc.stdout:
299+
while True:
300+
line = self._proc.stdout.readline()
301+
if not line:
302+
break
299303
self._log_q.put(line.rstrip())
300304
except Exception as e:
301305
self._log_q.put(f"[erro leitor]: {e}")
@@ -307,12 +311,13 @@ def _poll_logs(self):
307311
self._append_log(line)
308312
except queue.Empty:
309313
pass
310-
# if process still alive, reschedule; else finalize
311-
if self._proc is not None and self._proc.poll() is None:
314+
alive = (self._proc is not None and self._proc.poll() is None)
315+
if alive and self._reader_thread is not None and self._reader_thread.is_alive():
312316
self.after(200, self._poll_logs)
313317
else:
314318
code = None if self._proc is None else self._proc.returncode
315319
self._proc = None
320+
self._reader_thread = None
316321
self.convert_btn.configure(state='normal')
317322
self.cancel_btn.configure(state='disabled')
318323
if code == 0:
@@ -418,13 +423,16 @@ def _http_get(self, url: str, timeout: int = 60) -> bytes:
418423
"""Download com requests (se disponível) e fallback para urllib."""
419424
if requests is not None:
420425
try:
421-
r = requests.get(url, timeout=timeout)
426+
r = requests.get(url, timeout=timeout, verify=True)
422427
r.raise_for_status()
423428
return r.content
424429
except Exception:
425430
pass
426431
import urllib.request
427-
with urllib.request.urlopen(url, timeout=timeout) as resp: # nosec B310
432+
ctx = ssl.create_default_context()
433+
ctx.check_hostname = True
434+
ctx.verify_mode = ssl.CERT_REQUIRED
435+
with urllib.request.urlopen(url, timeout=timeout, context=ctx) as resp: # nosec B310
428436
return resp.read()
429437

430438
def download_ffmpeg_and_maybe_install(self):
42.2 KB
Binary file not shown.
38.8 KB
Binary file not shown.
3.18 KB
Binary file not shown.
Binary file not shown.
4.54 KB
Binary file not shown.

tests/test_command_build.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,53 @@
11
from importlib import import_module
2+
import sys
3+
import types
4+
5+
6+
def _stub_pyqt6():
7+
if 'PyQt6' in sys.modules:
8+
return
9+
qt = types.ModuleType('PyQt6')
10+
qtwidgets = types.ModuleType('PyQt6.QtWidgets')
11+
qtcore = types.ModuleType('PyQt6.QtCore')
12+
# Provide dummy classes used in import statements
13+
for name in [
14+
'QApplication','QWidget','QLabel','QLineEdit','QPushButton','QTextEdit',
15+
'QComboBox','QFileDialog','QCheckBox','QHBoxLayout','QVBoxLayout','QProgressDialog'
16+
]:
17+
setattr(qtwidgets, name, type(name, (), {}))
18+
# Minimal decorator used in the module (@QtCore.pyqtSlot)
19+
qtcore.pyqtSlot = lambda *args, **kwargs: (lambda f: f)
20+
qt.QtWidgets = qtwidgets
21+
qt.QtCore = qtcore
22+
sys.modules['PyQt6'] = qt
23+
sys.modules['PyQt6.QtWidgets'] = qtwidgets
24+
sys.modules['PyQt6.QtCore'] = qtcore
25+
26+
27+
def _stub_tkinter():
28+
if 'tkinter' in sys.modules:
29+
return
30+
tk = types.ModuleType('tkinter')
31+
# Minimal Tk class to support class definition inheritance
32+
tk.Tk = type('Tk', (), {})
33+
ttk = types.ModuleType('ttk')
34+
filedialog = types.ModuleType('filedialog')
35+
messagebox = types.ModuleType('messagebox')
36+
# Expose submodules as attributes to support `from tkinter import ...`
37+
tk.ttk = ttk
38+
tk.filedialog = filedialog
39+
tk.messagebox = messagebox
40+
# Mark as a package to be safe
41+
tk.__path__ = [] # type: ignore[attr-defined]
42+
sys.modules['tkinter'] = tk
43+
sys.modules['ttk'] = ttk
44+
sys.modules['tkinter.ttk'] = ttk
45+
sys.modules['tkinter.filedialog'] = filedialog
46+
sys.modules['tkinter.messagebox'] = messagebox
247

348

449
def test_pyqt6_build_command_list_defaults(monkeypatch):
50+
_stub_pyqt6()
551
# Avoid importing PyQt6 in CI to keep tests light.
652
# Instead, test the command assembly logic by stubbing the minimal API.
753
m = import_module('GUI_pyqt6_WINFF')
@@ -32,6 +78,7 @@ def __init__(self):
3278

3379

3480
def test_tkinter_build_command_list_defaults(monkeypatch):
81+
_stub_tkinter()
3582
m = import_module('GUI_tkinter_WINFF')
3683

3784
class Stub:

0 commit comments

Comments
 (0)