Skip to content
Merged
126 changes: 126 additions & 0 deletions .github/workflows/build-windows-release.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 59 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
10 changes: 6 additions & 4 deletions screen_recorder/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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."
)
7 changes: 6 additions & 1 deletion screen_recorder/mixins/file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
])
Expand Down
59 changes: 59 additions & 0 deletions screen_recorder/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
"""Возвращает переносимую точку запуска модульной версии."""
Expand Down
Loading