diff --git a/.github/workflows/build-windows-release.yml b/.github/workflows/build-windows-release.yml new file mode 100644 index 0000000..8e5271e --- /dev/null +++ b/.github/workflows/build-windows-release.yml @@ -0,0 +1,126 @@ +name: Build Windows EXE + +on: + pull_request: + branches: [main] + paths: + - "main.py" + - "Screen Recorder Pro.py" + - "screen_recorder/**" + - "requirements.txt" + - "scripts/build_windows_exe.ps1" + - ".github/workflows/build-windows-release.yml" + push: + branches: [main] + tags: + - "v*" + paths: + - ".release-binary/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: screen-recorder-windows-build-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-windows: + runs-on: windows-latest + env: + PYTHONUTF8: "1" + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install Python dependencies and PyInstaller + shell: pwsh + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install "pyinstaller==6.22.3" + + - name: Ensure FFmpeg and FFprobe are available + shell: pwsh + run: | + if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue) -or -not (Get-Command ffprobe -ErrorAction SilentlyContinue)) { + choco install ffmpeg -y --no-progress + } + ffmpeg -version + ffprobe -version + + - name: Compile Python sources + run: python -m compileall -q . + + - name: Verify project regressions + run: python verify_project.py + + - name: Build self-contained Windows EXE + shell: pwsh + run: ./scripts/build_windows_exe.ps1 + + - name: Upload Windows binary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Screen-Recorder-Pro-Windows-x64 + path: | + dist/Screen-Recorder-Pro.exe + dist/Screen-Recorder-Pro.sha256.txt + if-no-files-found: error + retention-days: 30 + + publish-release: + if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main' + needs: build-windows + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download exact binary built above + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: Screen-Recorder-Pro-Windows-x64 + path: release + + - name: Choose release identity + id: release_meta + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" + echo "title=Screen Recorder Pro $GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" + echo "prerelease=false" >> "$GITHUB_OUTPUT" + else + short_sha="${GITHUB_SHA::7}" + echo "tag=build-$short_sha" >> "$GITHUB_OUTPUT" + echo "title=Screen Recorder Pro build $short_sha" >> "$GITHUB_OUTPUT" + echo "prerelease=true" >> "$GITHUB_OUTPUT" + fi + + - name: Publish GitHub Release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release_meta.outputs.tag }} + RELEASE_TITLE: ${{ steps.release_meta.outputs.title }} + RELEASE_PRERELEASE: ${{ steps.release_meta.outputs.prerelease }} + shell: bash + run: | + set -euo pipefail + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$RELEASE_TAG" release/Screen-Recorder-Pro.exe release/Screen-Recorder-Pro.sha256.txt --repo "$GITHUB_REPOSITORY" --clobber + elif [[ "$RELEASE_PRERELEASE" == "true" ]]; then + gh release create "$RELEASE_TAG" release/Screen-Recorder-Pro.exe release/Screen-Recorder-Pro.sha256.txt --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" --title "$RELEASE_TITLE" --generate-notes --prerelease + else + gh release create "$RELEASE_TAG" release/Screen-Recorder-Pro.exe release/Screen-Recorder-Pro.sha256.txt --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" --title "$RELEASE_TITLE" --generate-notes + fi diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 2297b11..9a66ba3 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -21,10 +21,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" cache: pip diff --git a/main.py b/main.py index 6a4b074..7834932 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,70 @@ import atexit +import shutil +import subprocess import sys import tkinter as tk +from pathlib import Path from screen_recorder.app import ScreenRecorderProWin11 -from screen_recorder.shared import SingleInstanceGuard +from screen_recorder.shared import ( + APP_BUILD, + SingleInstanceGuard, + resolve_ffmpeg_path, + resolve_ffprobe_path, +) + + +def run_packaging_smoke() -> int: + """Headless smoke-check used by Windows CI against the actual packaged EXE.""" + def report(message, *, error=False): + stream = sys.stderr if error else sys.stdout + if stream is not None: + print(message, file=stream) + + tools = [ + ("ffmpeg", resolve_ffmpeg_path()), + ("ffprobe", resolve_ffprobe_path()), + ] + for name, resolved in tools: + if not resolved: + report(f"PACKAGING_SMOKE_FAIL: {name} not resolved", error=True) + return 20 + candidate = Path(str(resolved)) + executable = str(candidate) if candidate.is_file() else shutil.which(str(resolved)) + if not executable: + report(f"PACKAGING_SMOKE_FAIL: {name} missing: {resolved}", error=True) + return 21 + try: + completed = subprocess.run( + [executable, "-version"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + timeout=15, + check=False, + ) + except Exception as exc: + report(f"PACKAGING_SMOKE_FAIL: {name}: {exc!r}", error=True) + return 22 + if completed.returncode != 0: + report( + f"PACKAGING_SMOKE_FAIL: {name} exit={completed.returncode}\n" + f"{(completed.stdout or '')[-2000:]}", + error=True, + ) + return 23 + + report(f"PACKAGING_SMOKE_OK build={APP_BUILD}") + return 0 def main() -> int: + if "--packaging-smoke" in sys.argv: + return run_packaging_smoke() + single_instance_guard = SingleInstanceGuard() if not single_instance_guard.acquire(): SingleInstanceGuard.notify_already_running() diff --git a/screen_recorder/app.py b/screen_recorder/app.py index 20c8444..9b42ec7 100644 --- a/screen_recorder/app.py +++ b/screen_recorder/app.py @@ -123,7 +123,7 @@ def __init__(self, root): "exists": SETTINGS_PATH.exists(), "settings": self.settings, }) - self.ffmpeg_path = shutil.which("ffmpeg") or "ffmpeg" + self.ffmpeg_path = resolve_ffmpeg_path() self._encoder_support_cache = {} self._filter_support_cache = {} self._input_format_support_cache = {} @@ -512,9 +512,11 @@ def __init__(self, root): # остались сегменты — предложим собрать их в готовое видео. self.root.after(1500, self.recover_orphan_segments) - if shutil.which("ffmpeg") is None: + ffmpeg_file = Path(str(self.ffmpeg_path)) + if not ffmpeg_file.is_file() and shutil.which(str(self.ffmpeg_path)) is None: messagebox.showwarning( "Нужен FFmpeg", - "Программа работает через FFmpeg. Установи FFmpeg и добавь ffmpeg.exe в PATH.\n\n" - "После установки перезапусти программу." + "Программа не нашла встроенный FFmpeg и не нашла ffmpeg.exe в PATH.\n\n" + "Для portable/EXE-сборки FFmpeg должен поставляться вместе с программой. " + "При запуске из исходников установи FFmpeg и добавь его в PATH." ) diff --git a/screen_recorder/mixins/file_tools.py b/screen_recorder/mixins/file_tools.py index ddacb0d..873c229 100644 --- a/screen_recorder/mixins/file_tools.py +++ b/screen_recorder/mixins/file_tools.py @@ -197,8 +197,11 @@ def _last_output_or_warn(self): return Path(path) def get_ffprobe_path(self): - """Находит ffprobe в PATH или рядом с используемым ffmpeg/EXE.""" + """Находит bundled ffprobe, соседний с ffmpeg, либо системный ffprobe.""" candidates = [] + bundled_probe = resolve_ffprobe_path() + if bundled_probe: + candidates.append(Path(bundled_probe)) try: ffmpeg_path = Path(str(self.ffmpeg_path)) probe_name = "ffprobe.exe" if os.name == "nt" else "ffprobe" @@ -210,6 +213,8 @@ def get_ffprobe_path(self): candidates.append(Path(probe_in_path)) probe_name = "ffprobe.exe" if os.name == "nt" else "ffprobe" candidates.extend([ + BUNDLE_DIR / probe_name, + BUNDLE_DIR / "ffmpeg" / "bin" / probe_name, APP_DIR / probe_name, APP_DIR / "ffmpeg" / "bin" / probe_name, ]) diff --git a/screen_recorder/shared.py b/screen_recorder/shared.py index 7f49417..dbc9e29 100644 --- a/screen_recorder/shared.py +++ b/screen_recorder/shared.py @@ -163,6 +163,65 @@ def get_app_folder(): APP_DIR = get_app_folder() +def get_bundle_folder(): + """Каталог read-only ресурсов текущего source/PyInstaller bundle. + + В PyInstaller onefile/onedir __file__ указывает внутрь bundle. Это намеренно + отличается от APP_DIR: APP_DIR остаётся каталогом запущенного EXE и владельцем + portable runtime-данных, а BUNDLE_DIR используется только для встроенных + ресурсов и внешних tools, добавленных в bundle. + """ + try: + return Path(__file__).resolve().parent.parent + except Exception: + return APP_DIR + + +BUNDLE_DIR = get_bundle_folder() + + +def resolve_packaged_tool(tool_name): + """Находит bundled executable рядом с кодом/EXE, затем пробует PATH.""" + base_name = str(tool_name or "").strip() + if not base_name: + return None + executable = base_name + if os.name == "nt" and not executable.lower().endswith(".exe"): + executable += ".exe" + + candidates = [ + BUNDLE_DIR / executable, + BUNDLE_DIR / "ffmpeg" / "bin" / executable, + APP_DIR / executable, + APP_DIR / "ffmpeg" / "bin" / executable, + ] + seen = set() + for candidate in candidates: + try: + resolved = candidate.resolve() + except Exception: + resolved = candidate + key = str(resolved).lower() + if key in seen: + continue + seen.add(key) + try: + if resolved.is_file(): + return str(resolved) + except Exception: + continue + + found = shutil.which(executable) or shutil.which(base_name) + return str(found) if found else None + + +def resolve_ffmpeg_path(): + return resolve_packaged_tool("ffmpeg") or ("ffmpeg.exe" if os.name == "nt" else "ffmpeg") + + +def resolve_ffprobe_path(): + return resolve_packaged_tool("ffprobe") + def get_program_entry_path(): """Возвращает переносимую точку запуска модульной версии.""" diff --git a/scripts/build_windows_exe.ps1 b/scripts/build_windows_exe.ps1 new file mode 100644 index 0000000..8dabe06 --- /dev/null +++ b/scripts/build_windows_exe.ps1 @@ -0,0 +1,112 @@ +param( + [string]$OutputName = "Screen-Recorder-Pro" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$projectRoot = Split-Path -Parent $PSScriptRoot +Set-Location $projectRoot + +function Resolve-NativeFfmpegTool { + param([Parameter(Mandatory = $true)][string]$Name) + + $command = Get-Command $Name -ErrorAction Stop + $resolved = [System.IO.Path]::GetFullPath($command.Source) + + # Chocolatey exposes tiny shim executables in its global bin directory. + # Those shims work only while Chocolatey metadata is present and therefore + # must never be embedded into a portable PyInstaller EXE. + if ($env:ChocolateyInstall) { + $chocoBin = [System.IO.Path]::GetFullPath((Join-Path $env:ChocolateyInstall "bin")) + if ($resolved.StartsWith($chocoBin, [System.StringComparison]::OrdinalIgnoreCase)) { + $libRoot = Join-Path $env:ChocolateyInstall "lib" + $packageRoots = @(Get-ChildItem -LiteralPath $libRoot -Directory -Filter "ffmpeg*" -ErrorAction SilentlyContinue) + $nativeCandidates = @( + foreach ($packageRoot in $packageRoots) { + Get-ChildItem -LiteralPath $packageRoot.FullName -File -Filter "$Name.exe" -Recurse -ErrorAction SilentlyContinue + } + ) + if ($nativeCandidates.Count -gt 0) { + # Real FFmpeg binaries are much larger than Chocolatey shims. + $resolved = ($nativeCandidates | Sort-Object Length -Descending | Select-Object -First 1).FullName + } + } + } + + if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { + throw "Unable to resolve native $Name executable: $resolved" + } + return $resolved +} + +function Assert-NativeToolWorks { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Path + ) + + $output = @(& $Path -version 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "$Name failed before packaging with exit code $LASTEXITCODE. Path: $Path" + } + if ($output.Count -gt 0) { + Write-Host $output[0] + } +} + +$ffmpegPath = Resolve-NativeFfmpegTool -Name "ffmpeg" +$ffprobePath = Resolve-NativeFfmpegTool -Name "ffprobe" + +Write-Host "Source root: $projectRoot" +Write-Host "Native FFmpeg: $ffmpegPath" +Write-Host "Native FFprobe: $ffprobePath" +python --version +python -m PyInstaller --version +Assert-NativeToolWorks -Name "ffmpeg" -Path $ffmpegPath +Assert-NativeToolWorks -Name "ffprobe" -Path $ffprobePath + +Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue +Remove-Item -Force "$OutputName.spec" -ErrorAction SilentlyContinue + +$pyInstallerArgs = @( + "-m", "PyInstaller", + "--noconfirm", + "--clean", + "--onefile", + "--windowed", + "--name", $OutputName, + "--add-binary", "$ffmpegPath;.", + "--add-binary", "$ffprobePath;.", + "Screen Recorder Pro.py" +) + +Write-Host ("Build command: python " + ($pyInstallerArgs -join " ")) +& python @pyInstallerArgs +if ($LASTEXITCODE -ne 0) { + throw "PyInstaller failed with exit code $LASTEXITCODE" +} + +$exePath = Join-Path $projectRoot "dist\$OutputName.exe" +if (-not (Test-Path -LiteralPath $exePath -PathType Leaf)) { + throw "Expected EXE was not created: $exePath" +} + +Write-Host "Running packaged smoke check against the exact EXE..." +$smoke = Start-Process -FilePath $exePath -ArgumentList "--packaging-smoke" -PassThru -Wait +if ($smoke.ExitCode -ne 0) { + throw "Packaged smoke check failed with exit code $($smoke.ExitCode)" +} + +$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $exePath +$size = (Get-Item -LiteralPath $exePath).Length +$manifestPath = Join-Path $projectRoot "dist\$OutputName.sha256.txt" +@( + "artifact=$OutputName.exe" + "size_bytes=$size" + "sha256=$($hash.Hash.ToLowerInvariant())" +) | Set-Content -LiteralPath $manifestPath -Encoding utf8 + +Write-Host "PACKAGED_EXE_OK: $exePath" +Write-Host "SIZE_BYTES: $size" +Write-Host "SHA256: $($hash.Hash.ToLowerInvariant())"