diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 7eec9c2..144fcae 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -29,7 +29,7 @@ from .environments import ENVIRONMENTS, environment_names, select_layout, role_pythons, constraints_file, bootstrap_versions from . import network from . import processes -from .system import install_root, venv_python, system_memory, nvidia_smi, memory_sample +from .system import install_root, venv_python, system_memory, nvidia_smi, memory_sample, curl_executable, missing_curl_message PACKAGE = Path(__file__).resolve().parent SOURCE = PACKAGE.parent @@ -276,8 +276,9 @@ def plan(args, *, local_progress=None): for name in () if windows_target else ('git', 'compiler'): if not snapshot.get(name): errors.append('Missing %s. Run ./setup.sh interactively to install basic tools, then review the engine plan.' % name) - if not args.hardware_json and not shutil.which('curl'): - errors.append('Missing curl for bounded downloads and HTTP/SOCKS proxy support. Run ./setup.sh to install it.') + curl_env = dict(os.environ, FREEVIDEO_HOME=str(root)) + if not args.hardware_json and curl_executable(curl_env) is None: + errors.append(missing_curl_message(curl_env)) resources = None try: resources = resource_budget(hardware, vram_gib=vram_gib, ram_gib=ram_gib) @@ -389,6 +390,7 @@ def plan(args, *, local_progress=None): bootstrap_versions(json.loads((PACKAGE / 'bootstrap_versions.json').read_text(encoding='utf-8')), system), layout, mode=getattr(args, 'network', 'auto'), timeout=getattr(args, 'network_timeout', 5), offline=bool(args.hardware_json or errors), + env=dict(os.environ, FREEVIDEO_HOME=str(root)), proxy_mode=download_preferences(root / 'download-settings.json')['proxy_mode']) networking['download_settings_path'] = str(root / 'download-settings.json') prepared_missing = prepared and any( @@ -1468,6 +1470,11 @@ def main(argv=None): verbose=args.verbose, show_location=args.verbose) try: from .local_models import progress_output + if platform.system() == 'Windows' and not args.plan: + from .curl_windows import ensure + def curl_progress(message): + print(json.dumps(message), flush=True) + os.environ['FREEVIDEO_CURL'] = ensure(args.root, progress=curl_progress) value = plan(args, local_progress=progress_output if os.environ.get('FREEVIDEO_UI_EVENTS') == '1' or not args.json else None) except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as error: print('Preflight failed: ' + str(error), file=sys.stderr) diff --git a/freevideo_engine/comfy_setup.py b/freevideo_engine/comfy_setup.py index 11017db..543195e 100644 --- a/freevideo_engine/comfy_setup.py +++ b/freevideo_engine/comfy_setup.py @@ -68,6 +68,15 @@ def discover_libraries(folder_paths, environ=None): class SetupRunner(Runner): token = '' + def prepare(self, action, root, env, progress): + if os.name == 'nt' and action in ('plan', 'setup'): + from .curl_windows import ensure + executable = ensure(root, env, progress=progress, cancelled=self.cancelled.is_set) + env = dict(env, FREEVIDEO_CURL=executable) + # The PowerShell bootstrap also needs curl before Python exists. + env['PATH'] = str(Path(executable).parent) + os.pathsep + env.get('PATH', os.defpath) + return env + def command(self, root, arguments): if os.name == 'nt': return super().command(root, arguments) diff --git a/freevideo_engine/curl_windows.json b/freevideo_engine/curl_windows.json new file mode 100644 index 0000000..1ea239d --- /dev/null +++ b/freevideo_engine/curl_windows.json @@ -0,0 +1,73 @@ +{ + "version": "8.22.0_3", + "url": "https://curl.se/windows/dl-8.22.0_3/curl-8.22.0_3-win64-mingw.zip", + "bytes": 8688824, + "sha256": "f38b0ac2e3a280fd414b607c874a21e6dd4d1ed0e2ae622afbf0020917446561", + "prefix": "curl-8.22.0_3-win64-mingw/", + "files": { + "COPYING.txt": { + "bytes": 1088, + "sha256": "82f2f4427d6545ee5aaac4f0b80428da6cc8ba41c2cf5da3a03680ec327b9681" + }, + "bin/curl-ca-bundle.crt": { + "bytes": 188900, + "sha256": "1fdcf2a55c9806c4c1e8391cb283871ee3b209d68a2879ce4c0898d949de2545" + }, + "bin/curl.exe": { + "bytes": 3881064, + "sha256": "39340e791e71ee3e712c734369c777cc258991c519e42e3f99a889b4d02d1adc" + }, + "bin/libcurl-x64.dll": { + "bytes": 3646568, + "sha256": "57524379a27c2a0f5d520ef57d80f8ac4313d5e31a2208c8fc7682c68f50aaaf" + }, + "dep/brotli/LICENSE.txt": { + "bytes": 1084, + "sha256": "3d180008e36922a4e8daec11c34c7af264fed5962d07924aea928c38e8663c94" + }, + "dep/certdata/LICENSE.url": { + "bytes": 73, + "sha256": "b9954f88a27e8457cefcebd076fa533d037711383f6b28ae489d063ef8c61f79" + }, + "dep/libpsl/COPYING.txt": { + "bytes": 1126, + "sha256": "e7c9f0d8498d28a360a7e8293ea70fd7bd952955b636938322c13d60a03b5a45" + }, + "dep/libressl/COPYING.txt": { + "bytes": 6492, + "sha256": "9853507b8821c3f277aa60bf554eade41441ef06b2df538d247183ad28789b7f" + }, + "dep/libssh2/COPYING.txt": { + "bytes": 1959, + "sha256": "f7f9633cf9ff2f1333f3d7ce46973a8716a4d2a2815ad56f30d437d5fea7bafe" + }, + "dep/nghttp2/COPYING.txt": { + "bytes": 1156, + "sha256": "6b94f3abc1aabd0c72a7c7d92a77f79dda7c8a0cb3df839a97890b4116a2de2a" + }, + "dep/nghttp3/COPYING.txt": { + "bytes": 1081, + "sha256": "58cc3fc8c6d6cb2485ab35eb5918e849b5bf1e56522974e106d7a39a88d61dcd" + }, + "dep/ngtcp2/COPYING.txt": { + "bytes": 1080, + "sha256": "542dd526d2ea23565f0571cc68df6f05ed11248a90ff0f32de19d8ba589db513" + }, + "dep/psl/LICENSE.txt": { + "bytes": 16727, + "sha256": "66a3107d5ad6a058aab753eaac2047ccb2ed0e39465dd0fe5844da3e300d5172" + }, + "dep/trurl/COPYING.txt": { + "bytes": 1088, + "sha256": "4b3e3c4d0d954c3693161dbcf1bab99803c79f7ce80319e11eb406e3a3d424cf" + }, + "dep/zlibng/LICENSE.md": { + "bytes": 867, + "sha256": "6c9f0d975b41afaa34d22f55bb8986ce69e5cb7ad327cb2b28820cd425edf5ee" + }, + "dep/zstd/LICENSE.txt": { + "bytes": 1549, + "sha256": "7055266497633c9025b777c78eb7235af13922117480ed5c674677adc381c9d8" + } + } +} diff --git a/freevideo_engine/curl_windows.py b/freevideo_engine/curl_windows.py new file mode 100644 index 0000000..c0db154 --- /dev/null +++ b/freevideo_engine/curl_windows.py @@ -0,0 +1,312 @@ +"""Discover and repair Windows curl without a system install or another curl.""" +import hashlib +import base64 +import json +import os +from pathlib import Path +import platform +import re +import shutil +import socket +import ssl +import struct +import subprocess +import tempfile +import time +from urllib import error as urlerror, request +from urllib.parse import unquote, urlsplit +import uuid +import zipfile + +from . import proxy +from .system import bootstrap_root + + +_PROBES = {} + + +class CurlError(RuntimeError): + def __init__(self, code, detail, **diagnostic): + self.code, self.diagnostic = code, diagnostic + super().__init__('[' + code + '] ' + detail) + + +def catalog(): + return json.loads(Path(__file__).with_name('curl_windows.json').read_text(encoding='utf-8')) + + +def cache_root(env): + return (Path(env['FREEVIDEO_BOOTSTRAP_ROOT']).expanduser().absolute() + if env.get('FREEVIDEO_BOOTSTRAP_ROOT') else bootstrap_root(env.get('FREEVIDEO_HOME'))) + + +def error_fields(error): + # Provider messages can contain authenticated proxy URLs. Keep typed codes. + value = dict(exception=type(error).__name__) + for name in ('errno', 'winerror', 'code'): + field = getattr(error, name, None) + if isinstance(field, (int, str)): + value[name] = field + reason = getattr(error, 'reason', None) + if isinstance(reason, BaseException): + value['cause'] = error_fields(reason) + if isinstance(error, CurlError): + value.update(error.diagnostic) + return value + + +def candidates(env): + if env.get('FREEVIDEO_CURL'): + yield 'selected', Path(env['FREEVIDEO_CURL']) + system = Path(env.get('SystemRoot') or env.get('WINDIR') or r'C:\Windows') + for name in ('Sysnative', 'System32'): + yield 'windows-' + name.lower(), system / name / 'curl.exe' + yield 'managed', cache_root(env) / ('curl-' + catalog()['version']) / 'bin/curl.exe' + # Empty/relative PATH entries must not execute a curl from the working folder. + for entry in env.get('PATH', os.defpath).split(os.pathsep)[:128]: + path = Path(entry.strip('"')) + try: + if path.is_absolute() and (path / 'curl.exe').is_file(): + yield 'path', path / 'curl.exe' + except OSError: + continue + git = shutil.which('git.exe', path=env.get('PATH', os.defpath)) + roots = [Path(git).parent.parent] if git else [] + roots += [Path(env[name]) / 'Git' for name in ('ProgramW6432', 'ProgramFiles', 'ProgramFiles(x86)') if env.get(name)] + if env.get('LOCALAPPDATA'): + roots.append(Path(env['LOCALAPPDATA']) / 'Programs/Git') + for root in roots: + for folder in ('mingw64/bin', 'usr/bin'): + yield 'git', root / folder / 'curl.exe' + + +def probe(path, env): + try: + stat = path.stat() + key = (str(path), stat.st_size, stat.st_mtime_ns, env.get('PATH')) + cached = _PROBES.get(key) + if cached and time.monotonic() - cached[0] < 30: + return dict(cached[1]) + from .windows_ux import external_python, hidden_console + with external_python(): + result = subprocess.run([str(path), '--disable', '--version'], env=env, + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=3, **hidden_console()) + code = result.returncode & 0xffffffff + value = dict(status='CURL_START_FAILED', returncode=result.returncode, + windows_exit_code='0x%08X' % code) + if code in (0xc0000135, 0xc0000139): + value['status'] = 'CURL_MISSING_DLL' + elif code == 0xc000007b: + value['status'] = 'CURL_INVALID_BINARY' + elif code == 0: + output = result.stdout[:8192].decode('utf-8', errors='replace') + version = re.search(r'^curl ([0-9.]+)', output) + protocols = re.search(r'^Protocols: (.+)$', output, re.M) + if version and protocols and 'https' in protocols[1].split(): + value.update(status='ready', version=version[1]) + else: + value['status'] = 'CURL_HTTPS_UNAVAILABLE' + if len(_PROBES) > 64: + _PROBES.clear() + _PROBES[key] = (time.monotonic(), value) + return value + except FileNotFoundError: + return dict(status='CURL_NOT_FOUND') + except subprocess.TimeoutExpired: + return dict(status='CURL_VERSION_TIMEOUT') + except OSError as error: + code = ('CURL_START_DENIED' if isinstance(error, PermissionError) else + 'CURL_INVALID_BINARY' if getattr(error, 'winerror', None) in (193, 216) else 'CURL_START_FAILED') + return dict(status=code, **error_fields(error)) + + +def inspect(env=None): + env = dict(os.environ if env is None else env) + report = dict(schema='freevideo.curl-diagnostic', schema_version=1, + checked_at=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + platform=platform.platform(), process_bits=8 * struct.calcsize('P'), + candidates=[], selected=None, repair_attempts=[]) + seen = set() + for origin, path in candidates(env): + path = path.absolute() + key = os.path.normcase(str(path)) + if key in seen: + continue + seen.add(key) + value = probe(path, env) + report['candidates'].append(dict(origin=origin, path=str(path), **value)) + if value['status'] == 'ready': + report.update(selected=str(path), status='ready') + return report + failed = [row['status'] for row in report['candidates'] if row['status'] != 'CURL_NOT_FOUND'] + report['status'] = failed[0] if failed else 'CURL_NOT_FOUND' + return report + + +def failure_message(report): + causes = ', '.join(dict.fromkeys(row['status'] for row in report['candidates'])) + return ('[' + report['status'] + '] No usable Windows download tool. ' + 'Local checks: ' + causes + '. Automatic repair: ' + + str(report.get('repair', 'not run')) + + '. Retry installation or export the report; curl diagnostics are included.') + + +class HTTPSRedirect(request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + if not newurl.lower().startswith('https://'): + raise CurlError('CURL_REPAIR_REDIRECT', 'The download redirected away from HTTPS.') + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +class HTTPProxy(request.ProxyHandler): + def proxy_open(self, req, address, protocol): + # ProxyHandler normally reads global NO_PROXY and Windows registry bypass + # rules. An explicitly selected repair route must not silently bypass it. + parsed = urlsplit(address if '://' in address else 'http://' + address) + if parsed.scheme != 'http' or not parsed.hostname: + raise CurlError('CURL_REPAIR_PROXY_UNSUPPORTED', 'The bootstrap requires an HTTP proxy.') + host = ('[' + parsed.hostname + ']' if ':' in parsed.hostname else parsed.hostname) + if parsed.port: + host += ':' + str(parsed.port) + if parsed.username is not None: + auth = unquote(parsed.username) + ':' + unquote(parsed.password or '') + req.add_header('Proxy-authorization', 'Basic ' + base64.b64encode(auth.encode()).decode('ascii')) + req.set_proxy(host, 'http') + return None # HTTPSHandler performs CONNECT and verifies the target TLS. + + +def download(spec, path, env, route, mode, emit, cancelled): + routed = proxy.environment(env, route) + address = routed.get('https_proxy') or routed.get('all_proxy') + if mode == 'proxy' and not address: + raise CurlError('CURL_REPAIR_PROXY_MISSING', 'Proxy only is selected but no HTTPS download proxy is configured.') + proxies = {'https': address} if address else {} + opener = request.build_opener(HTTPProxy(proxies), HTTPSRedirect()) + started, last = time.monotonic(), 0. + digest, size = hashlib.sha256(), 0 + req = request.Request(spec['url'], headers={'User-Agent': 'FreeVideo-Setup/1', 'Accept-Encoding': 'identity'}) + with opener.open(req, timeout=10) as response, path.open('wb') as stream: + if response.status != 200: + raise CurlError('CURL_REPAIR_HTTP', 'Unexpected HTTP response: ' + str(response.status)) + declared = response.headers.get('Content-Length') + if declared and int(declared) != spec['bytes']: + raise CurlError('CURL_REPAIR_SIZE', 'The download size does not match the pinned tool.', + expected_bytes=spec['bytes'], actual_bytes=int(declared)) + while True: + if cancelled(): + raise CurlError('CURL_REPAIR_CANCELLED', 'Download tool preparation was cancelled.') + if time.monotonic() - started > 60: + raise CurlError('CURL_REPAIR_TIMEOUT', 'Download tool transfer exceeded 60 seconds.') + block = response.read1(min(128 * 1024, spec['bytes'] - size + 1)) + if not block: + break + size += len(block) + if size > spec['bytes']: + raise CurlError('CURL_REPAIR_SIZE', 'The download exceeded the pinned tool size.', + expected_bytes=spec['bytes'], actual_bytes=size) + stream.write(block) + digest.update(block) + now = time.monotonic() + if now - last >= .5: + emit('Downloading curl', done=size, total=spec['bytes']) + last = now + if size != spec['bytes'] or digest.hexdigest() != spec['sha256']: + raise CurlError('CURL_REPAIR_INTEGRITY', 'Download tool verification failed.', + expected_bytes=spec['bytes'], actual_bytes=size, + expected_sha256=spec['sha256'], actual_sha256=digest.hexdigest()) + + +def unpack(archive, destination, spec): + with zipfile.ZipFile(archive) as zipped: + for relative, expected in spec['files'].items(): + # Names come exclusively from our committed catalog, never the ZIP. + info = zipped.getinfo(spec['prefix'] + relative) + if info.file_size != expected['bytes']: + raise CurlError('CURL_REPAIR_INTEGRITY', 'Unexpected archive member size.') + content = zipped.read(info) + if hashlib.sha256(content).hexdigest() != expected['sha256']: + raise CurlError('CURL_REPAIR_INTEGRITY', 'Archive member verification failed.') + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + + +def ensure(root, env=None, *, progress=None, cancelled=lambda: False): + """Explicit setup action; read-only discovery never calls this function.""" + from .download_settings import read + from .locking import runtime_lock + from .monitoring import save + env = dict(os.environ if env is None else env) + env['FREEVIDEO_HOME'] = str(root) + cache = cache_root(env) + report = inspect(env) + receipt = cache / 'curl-diagnostic.json' + + def emit(detail, **fields): + if progress: + progress(dict(event='freevideo_ui', kind='progress', key='prepare-curl', + label='Prepare download tools', detail=detail, **fields)) + + try: + if report['selected']: + report['repair'] = 'not needed' + return report['selected'] + emit('No usable curl found; preparing a verified local copy') + cache.mkdir(parents=True, exist_ok=True) + with runtime_lock(cache / 'curl.lock', inherit=False): + spec = catalog() + mode = read(Path(root) / 'download-settings.json')['proxy_mode'] + report.update(proxy_mode=mode, repair='running') + for route in proxy.routes(env, mode=mode): + if cancelled(): + report.update(status='CURL_REPAIR_CANCELLED', repair='cancelled') + raise CurlError('CURL_REPAIR_CANCELLED', 'Download tool preparation was cancelled.') + attempt = dict(source='curl.se', route=route) + started = time.monotonic() + report['repair_attempts'].append(attempt) + try: + with tempfile.TemporaryDirectory(prefix='curl-stage-', dir=cache) as temp: + staging = Path(temp) + archive = staging / 'curl.zip' + download(spec, archive, env, route, mode, emit, cancelled) + folder = staging / 'tool' + unpack(archive, folder, spec) + check = probe(folder / 'bin/curl.exe', env) + attempt['probe'] = check + if check['status'] != 'ready': + raise CurlError(check['status'], 'The verified download tool could not start.') + target = cache / ('curl-' + spec['version']) + if target.exists(): + target.rename(cache / ('curl-rejected-' + uuid.uuid4().hex)) + folder.rename(target) + report.update(selected=str(target / 'bin/curl.exe'), status='ready', repair='complete') + attempt['status'] = 'ready' + emit('Download tool ready', done=spec['bytes'], total=spec['bytes']) + return report['selected'] + except (OSError, ValueError, KeyError, CurlError, zipfile.BadZipFile) as error: + status = (error.code if isinstance(error, CurlError) else + 'CURL_REPAIR_STORAGE' if isinstance(error, PermissionError) or getattr(error, 'errno', None) == 28 else + 'CURL_REPAIR_TLS' if isinstance(getattr(error, 'reason', error), ssl.SSLError) else + 'CURL_REPAIR_DNS' if isinstance(getattr(error, 'reason', error), socket.gaierror) else + 'CURL_REPAIR_TIMEOUT' if isinstance(getattr(error, 'reason', error), (TimeoutError, socket.timeout)) else + 'CURL_REPAIR_HTTP' if isinstance(error, urlerror.HTTPError) else 'CURL_REPAIR_FAILED') + attempt.update(status=status, **error_fields(error)) + report.update(status=status, repair='failed') + if status == 'CURL_REPAIR_CANCELLED': + break + finally: + attempt['elapsed_seconds'] = round(time.monotonic() - started, 3) + raise RuntimeError(failure_message(report)) + except (OSError, ValueError) as error: + report.update(status='CURL_REPAIR_BUSY' if isinstance(error, BlockingIOError) else 'CURL_REPAIR_STORAGE', + repair='failed', failure=error_fields(error)) + raise RuntimeError(failure_message(report)) from error + finally: + try: + save(receipt, report) + except (OSError, ValueError) as error: + report['receipt_error'] = error_fields(error) + # The log still contains the diagnostic if the selected disk is unwritable. + if progress: + progress(dict(event='curl_diagnostic', **report)) diff --git a/freevideo_engine/desktop_runtime.py b/freevideo_engine/desktop_runtime.py index 5d8ea85..832e6c2 100644 --- a/freevideo_engine/desktop_runtime.py +++ b/freevideo_engine/desktop_runtime.py @@ -236,6 +236,9 @@ def command(self, root, arguments): def environment(self, root): return dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8', NO_COLOR='1', FREEVIDEO_UI_EVENTS='1') + def prepare(self, action, root, env, progress): + return env + def _run_awake(self, action, root, arguments): run = self.logs / (time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()) + '-' + uuid.uuid4().hex[:8]) code, output, error = None, '', None @@ -247,6 +250,17 @@ def _run_awake(self, action, root, arguments): self.events.put(('started', str(run))) env = self.environment(root) with (run / 'launcher.log').open('w', encoding='utf-8', buffering=1) as log: + def progress(message): + nonlocal output + line = json.dumps(message) + '\n' + log.write(line) + output = (output + line)[-2 * 1024 * 1024:] + self.events.put(('log', line)) + if message.get('event') == 'freevideo_ui': + self.events.put(('progress', message)) + env = self.prepare(action, root, env, progress) + if self.cancelled.is_set(): + raise RuntimeError('Stopped; files retained') with self.lock: # External Python must not inherit the frozen GUI's DLL # directory. Restore it immediately for subsequent Tk loads. diff --git a/freevideo_engine/diagnostics.py b/freevideo_engine/diagnostics.py index 6cada52..449442d 100644 --- a/freevideo_engine/diagnostics.py +++ b/freevideo_engine/diagnostics.py @@ -297,6 +297,13 @@ def add_path(name, path): if not is_link(bootstrap) and (bootstrap / 'bootstrap.log').is_file(): add_path('bootstrap/bootstrap.log', bootstrap / 'bootstrap.log') if windows(): + from .curl_windows import inspect as inspect_curl + add_path('bootstrap/curl-diagnostic.json', bootstrap / 'curl-diagnostic.json') + try: + curl_state = inspect_curl(dict(os.environ, FREEVIDEO_HOME=str(root))) + except (OSError, ValueError, RuntimeError) as error: + curl_state = dict(status='CURL_DIAGNOSTIC_FAILED', exception=type(error).__name__) + add('current/curl.json', json.dumps(curl_state).encode()) from .desktop_runtime import launcher_root gui_runs = launcher_root() / 'runs' if gui_runs.is_dir() and not is_link(gui_runs): diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 7d87464..999214f 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -99,6 +99,18 @@ def launcher_failure(value, *, zh=False): ('The GPU architecture could not be read', '未能读取显卡架构'), ('Retry hardware detection. If it still fails, export the report; it includes the GPU identity and the raw driver query.', '请重试硬件检测。仍失败时导出报告,其中包含显卡信息和驱动查询的原始结果。')), + (r'CURL_REPAIR_(?:TIMEOUT|HTTP|TLS|DNS|FAILED|PROXY_UNSUPPORTED|PROXY_MISSING|REDIRECT|SIZE|INTEGRITY)', 'download-tool-network', + ('The download tool could not be prepared', '下载工具自动补齐失败'), + ('Check the connection mode in Downloads and retry. Export the report for the source, route and failure code.', + '请在“下载”中检查连接方式后重试。导出报告已包含来源、连接方式和失败码。')), + (r'CURL_START_DENIED|CURL_REPAIR_STORAGE', 'download-tool-access', + ('Windows blocked the download tool', '下载工具启动或保存被拒绝'), + ('Check folder permissions and Windows Security protection history, then retry. Export the report for the Windows error code.', + '请检查安装目录权限和 Windows 安全中心的保护历史记录后重试。导出报告包含 Windows 错误码。')), + (r'CURL_[A-Z_]+|Windows curl\.exe was not found', 'download-tool', + ('The download tool is unavailable', '下载工具不可用'), + ('Retry installation to repair the tool automatically. If it still fails, export the report for the complete tool diagnostics.', + '请重试安装,启动器会自动修复下载工具。仍失败时导出报告,其中包含完整工具诊断。')), (r'ComfyUI is still running a job', 'busy', ('A video is still generating', '还有视频正在生成'), ('The update is installed. Click Connect again after the job finishes to restart ComfyUI with it.', diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index 039e073..cf3f06b 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -15,11 +15,12 @@ import re import shutil import subprocess -from .windows_ux import hidden_console +from .windows_ux import external_python, hidden_console import threading import time from urllib.parse import quote, unquote, urlencode, urljoin, urlsplit from . import proxy +from .system import curl_executable, missing_curl_message, windows SOURCES = { @@ -120,9 +121,10 @@ def quote(value): def curl_command(timeout, env=None): - if not shutil.which('curl'): - raise RuntimeError('Network setup needs curl (Ubuntu/Debian: sudo apt install curl).') - command = ['curl', '--config', '-', '--silent', '--show-error', '--no-location-trusted', '--location', + executable = curl_executable(env) + if executable is None: + raise RuntimeError(missing_curl_message(env)) + command = [executable, '--config', '-', '--silent', '--show-error', '--no-location-trusted', '--location', '--max-redirs', '5', '--fail', '--proto', '=https,http', '--proto-redir', '=https,http', '--connect-timeout', str(timeout), '--user-agent', 'FreeVideo-Setup/1'] if (env or {}).get('FREEVIDEO_PROXY_ROUTE') == 'direct': @@ -130,6 +132,21 @@ def curl_command(timeout, env=None): return command +def curl_process(command, **kwargs): + # Match discovery's environment: the frozen GUI's DLL directory can break + # otherwise healthy external curl binaries (including Git's copy). + try: + with external_python(): + return subprocess.Popen(command, **kwargs, **hidden_console()) + except OSError as error: + if not windows(): + raise + code = 'CURL_START_DENIED' if isinstance(error, PermissionError) else 'CURL_START_FAILED' + raise RuntimeError('[%s] curl could not start; exception=%s, winerror=%s, errno=%s. ' + 'Retry installation or export the report.' % + (code, type(error).__name__, getattr(error, 'winerror', None), error.errno)) from error + + def sample(url, timeout=5, limit=SAMPLE_BYTES, *, ranged=True, env=None, headers=()): """Read at most limit bytes; a hard curl deadline bounds DNS and slow reads.""" command = curl_command(timeout, env) + ['--max-time', str(timeout)] @@ -139,8 +156,8 @@ def sample(url, timeout=5, limit=SAMPLE_BYTES, *, ranged=True, env=None, headers command += ['--range', '0-' + str(limit - 1)] config = curl_config(url, headers) started = time.monotonic() - process = subprocess.Popen(command, env=proxy_environment(env), stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, **hidden_console()) + process = curl_process(command, env=proxy_environment(env), stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) try: process.stdin.write(config) process.stdin.close() @@ -171,8 +188,8 @@ def speed_sample(url, timeout=5, limit=SPEED_SAMPLE_BYTES, duration=SPEED_SAMPLE if url.startswith('https://'): command += ['--proto-redir', '=https'] started = time.monotonic() - process = subprocess.Popen(command, env=proxy_environment(env), stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, **hidden_console()) + process = curl_process(command, env=proxy_environment(env), stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) timer = None stopped = [] @@ -746,8 +763,8 @@ def remember_source(): command += ['--max-filesize', str(max(size - offset, MIN_TRANSFER_LIMIT))] if offset: command += ['--continue-at', str(offset)] - process = subprocess.Popen(command, env=attempt_env, stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, **hidden_console()) + process = curl_process(command, env=attempt_env, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) started = time.monotonic() oversized = None stalled = False diff --git a/freevideo_engine/system.py b/freevideo_engine/system.py index 8c798cd..ac59bdc 100644 --- a/freevideo_engine/system.py +++ b/freevideo_engine/system.py @@ -10,6 +10,25 @@ def windows(): return os.name == 'nt' +def curl_executable(env=None): + """Find the same executable for setup checks and download subprocesses.""" + env = os.environ if env is None else env + if windows(): + from .curl_windows import inspect + return inspect(env)['selected'] + found = shutil.which('curl', path=env.get('PATH', os.defpath)) + return str(Path(found).absolute()) if found else None + + +def missing_curl_message(env=None): + if windows(): + from .curl_windows import inspect, failure_message + return failure_message(inspect(env)) + if platform.system() == 'Darwin': + return 'macOS curl was not found. Check /usr/bin/curl and reopen FreeVideo with curl available in PATH.' + return 'Missing curl for bounded downloads and HTTP/SOCKS proxy support. Run ./setup.sh to install it.' + + def source_root(): """Find the checkout/ZIP by package location, never by the caller's cwd. diff --git a/freevideo_engine/windows_bootstrap.ps1 b/freevideo_engine/windows_bootstrap.ps1 index 224b7af..7040f99 100644 --- a/freevideo_engine/windows_bootstrap.ps1 +++ b/freevideo_engine/windows_bootstrap.ps1 @@ -100,8 +100,18 @@ function Get-FreeVideoUv { } } } - $curl = Get-Command curl.exe -ErrorAction SilentlyContinue - if (-not $curl) { throw 'curl.exe is missing. Repair Windows curl, or install Python 3.9+ and retry.' } + $curlPath = $env:FREEVIDEO_CURL + if (-not $curlPath -or -not (Test-Path -LiteralPath $curlPath -PathType Leaf)) { + $curlCommand = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue + $curlPath = if ($curlCommand) { $curlCommand.Source } else { $null } + } + if (-not $curlPath) { + foreach ($directory in @('Sysnative', 'System32')) { + $candidate = Join-Path $env:SystemRoot ($directory + '\curl.exe') + if (Test-Path -LiteralPath $candidate -PathType Leaf) { $curlPath = $candidate; break } + } + } + if (-not $curlPath) { throw '[CURL_NOT_FOUND] No Windows curl.exe found in PATH or the system directory. Open FreeVideo.exe and retry installation to repair the tool automatically.' } $common = @('--disable', '--fail', '--location', '--silent', '--show-error', '--proto', '=https', '--proto-redir', '=https', '--connect-timeout', '5') $candidates = @() @@ -117,7 +127,7 @@ function Get-FreeVideoUv { $watch = [Diagnostics.Stopwatch]::StartNew() $probeArgs = $common + @('--range', '0-262143', '--max-filesize', '1048576', '--max-time', '5', '--output', $sample, $source.Value) - $code = Invoke-FreeVideoBootstrapCommand -Executable $curl.Source -Arguments $probeArgs ` + $code = Invoke-FreeVideoBootstrapCommand -Executable $curlPath -Arguments $probeArgs ` -LogPath $LogPath -Label ("Probe uv: {0}, {1}" -f $source.Name, $route) -Direct:($route -eq 'direct') $watch.Stop() if ($code -eq 0 -and (Test-Path -LiteralPath $sample)) { @@ -160,7 +170,7 @@ function Get-FreeVideoUv { '--max-filesize', [string]($artifact.Bytes - $offset), '--output', $partial) if ($offset -gt 0) { $downloadArgs += @('--continue-at', [string]$offset) } $downloadArgs += $candidate.Url - $code = Invoke-FreeVideoBootstrapCommand -Executable $curl.Source -Arguments $downloadArgs ` + $code = Invoke-FreeVideoBootstrapCommand -Executable $curlPath -Arguments $downloadArgs ` -LogPath $LogPath -Label ("Download uv: {0}, {1}" -f $candidate.Source, $candidate.Route) -Direct:($candidate.Route -eq 'direct') if (Test-FreeVideoUvArchive $partial $artifact.Hash $artifact.Bytes) { Move-Item -LiteralPath $partial -Destination $archive