Fix Windows artifact packaging and recording path

This commit is contained in:
ihmily
2026-07-06 04:50:07 +08:00
parent 3799329863
commit d709c07a59
7 changed files with 388 additions and 358 deletions

View File

@@ -1,120 +1,117 @@
name: Build Desktop Apps
on:
workflow_dispatch:
inputs:
build_windows:
description: Build the Windows package
type: boolean
default: true
build_macos:
description: Build the macOS package
type: boolean
default: true
refresh_flet:
description: Re-download the Flet desktop archive
type: boolean
default: false
permissions:
contents: read
concurrency:
group: build-desktop-${{ github.ref }}
cancel-in-progress: false
jobs:
build-windows:
name: Build Windows
if: ${{ inputs.build_windows }}
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
cache: pip
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
- name: Prepare bundled FFmpeg
run: python scripts/download_ffmpeg.py --platform windows
- name: Prepare bundled Node.js
run: python scripts/download_nodejs.py --platform windows --windows-arch x64
- name: Build app
run: |
$buildArgs = @("--platform", "windows")
if ("${{ inputs.refresh_flet }}" -eq "true") {
$buildArgs += "--refresh-flet"
}
python scripts/build.py @buildArgs
- name: Package zip
run: Compress-Archive -Path dist/StreamCap/* -DestinationPath dist/StreamCap-windows.zip -Force
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: StreamCap-windows
path: dist/StreamCap-windows.zip
if-no-files-found: error
build-macos:
name: Build macOS
if: ${{ inputs.build_macos }}
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
cache: pip
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
- name: Prepare bundled FFmpeg
run: python scripts/download_ffmpeg.py --platform macos
- name: Prepare bundled Node.js
run: python scripts/download_nodejs.py --platform macos
- name: Build app
run: |
REFRESH_ARG=""
if [ "${{ inputs.refresh_flet }}" = "true" ]; then
REFRESH_ARG="--refresh-flet"
fi
python scripts/build.py --platform macos $REFRESH_ARG
- name: Package dmg
run: |
python -c "from pathlib import Path; path = Path('scripts/create_macos_dmg.sh'); path.write_bytes(path.read_bytes().replace(b'\r\n', b'\n'))"
bash -n scripts/create_macos_dmg.sh
bash scripts/create_macos_dmg.sh "dist/StreamCap.app" "dist/StreamCap-macos.dmg"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: StreamCap-macos
path: dist/StreamCap-macos.dmg
if-no-files-found: error
name: Build Desktop Apps
on:
workflow_dispatch:
inputs:
build_windows:
description: Build the Windows package
type: boolean
default: true
build_macos:
description: Build the macOS package
type: boolean
default: true
refresh_flet:
description: Re-download the Flet desktop archive
type: boolean
default: false
permissions:
contents: read
concurrency:
group: build-desktop-${{ github.ref }}
cancel-in-progress: false
jobs:
build-windows:
name: Build Windows
if: ${{ inputs.build_windows }}
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
cache: pip
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
- name: Prepare bundled FFmpeg
run: python scripts/download_ffmpeg.py --platform windows
- name: Prepare bundled Node.js
run: python scripts/download_nodejs.py --platform windows --windows-arch x64
- name: Build app
run: |
$buildArgs = @("--platform", "windows")
if ("${{ inputs.refresh_flet }}" -eq "true") {
$buildArgs += "--refresh-flet"
}
python scripts/build.py @buildArgs
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: StreamCap-windows
path: dist/*
if-no-files-found: error
build-macos:
name: Build macOS
if: ${{ inputs.build_macos }}
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
cache: pip
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
- name: Prepare bundled FFmpeg
run: python scripts/download_ffmpeg.py --platform macos
- name: Prepare bundled Node.js
run: python scripts/download_nodejs.py --platform macos
- name: Build app
run: |
REFRESH_ARG=""
if [ "${{ inputs.refresh_flet }}" = "true" ]; then
REFRESH_ARG="--refresh-flet"
fi
python scripts/build.py --platform macos $REFRESH_ARG
- name: Package dmg
run: |
python -c "from pathlib import Path; path = Path('scripts/create_macos_dmg.sh'); path.write_bytes(path.read_bytes().replace(b'\r\n', b'\n'))"
bash -n scripts/create_macos_dmg.sh
bash scripts/create_macos_dmg.sh "dist/StreamCap.app" "dist/StreamCap-macos.dmg"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: StreamCap-macos
path: dist/StreamCap-macos.dmg
if-no-files-found: error

View File

@@ -1,183 +1,185 @@
import asyncio
import hashlib
import logging
import os
import re
import sys
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
import aiofiles
from cachetools import TTLCache
from dotenv import find_dotenv, load_dotenv
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
dotenv_path = find_dotenv()
load_dotenv(dotenv_path)
CUSTOM_VIDEO_ROOT_DIR = os.getenv("CUSTOM_VIDEO_ROOT_DIR")
VIDEO_API_PORT = os.getenv("VIDEO_API_PORT") or 6007
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
DEFAULT_VIDEO_ROOT_DIR = Path(os.path.split(os.path.realpath(sys.argv[0]))[0]).parent.parent / "downloads"
VIDEO_DIR = Path(CUSTOM_VIDEO_ROOT_DIR or DEFAULT_VIDEO_ROOT_DIR)
os.makedirs(VIDEO_DIR, exist_ok=True)
VIDEO_META_CACHE = TTLCache(maxsize=50, ttl=300)
CHUNK_CACHE = TTLCache(maxsize=25, ttl=60)
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
@asynccontextmanager
async def lifespan(_app: FastAPI):
if not VIDEO_DIR.exists():
logger.error(f"Video directory does not exist: {VIDEO_DIR}")
raise RuntimeError(f"Video directory does not exist: {VIDEO_DIR}")
_app.mount("/api/videos", StaticFiles(directory=VIDEO_DIR), name="videos")
yield
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
_app.mount("/api/videos", StaticFiles(directory=None))
logger.info("Shutting down the application.")
app = FastAPI(lifespan=lifespan)
def validate_filename(filename: str):
if re.search(r"[\\/]", filename):
raise HTTPException(status_code=400, detail="Invalid filename")
@app.get("/api/videos")
async def get_video(request: Request, filename: str = Query(...), subfolder: str | None = None):
cache_key = f"{filename}-{subfolder}"
if meta := VIDEO_META_CACHE.get(cache_key):
if_none_match = request.headers.get("If-None-Match")
if_modified_since = request.headers.get("If-Modified-Since")
if if_none_match and if_none_match == meta["etag"]:
return Response(status_code=304)
if if_modified_since:
last_modified = datetime.fromisoformat(meta["last_modified"])
if datetime.strptime(if_modified_since, "%a, %d %b %Y %H:%M:%S GMT") >= last_modified:
return Response(status_code=304)
try:
validate_filename(filename)
if subfolder:
video_path = VIDEO_DIR / subfolder / filename
else:
video_path = VIDEO_DIR / filename
except Exception as e:
logger.exception("Invalid filename or subfolder")
raise e
if not video_path.is_file():
logger.error(f"File not found: {video_path}")
raise HTTPException(status_code=404, detail="Video file not found")
# Prevent path traversal attacks
try:
video_path.relative_to(VIDEO_DIR)
except ValueError:
logger.exception(f"Path traversal attempt: {video_path}")
raise HTTPException(status_code=400, detail="Invalid file path")
stat = video_path.stat()
file_size = stat.st_size
last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat()
etag = hashlib.md5(f"{file_size}-{last_modified}".encode()).hexdigest()
VIDEO_META_CACHE[cache_key] = {"etag": etag, "last_modified": last_modified, "file_size": file_size}
# Parse Range header
range_header = request.headers.get("Range")
if range_header:
start, end = range_header.replace("bytes=", "").split("-")
start = int(start)
end = int(end) if end else file_size - 1
if start >= file_size or end >= file_size:
logger.error(f"Invalid range request: {range_header}, file size: {file_size}")
raise HTTPException(status_code=416, detail="Requested range not satisfiable")
headers = {
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(end - start + 1),
"Content-Type": "video/mp4",
}
return StreamingResponse(
file_sender_range(video_path, start, end),
status_code=206,
headers=headers,
)
# If no Range header, return the whole file
headers = {
"Content-Length": str(file_size),
"Content-Type": "video/mp4",
"Cache-Control": "public, max-age=300",
"ETag": etag,
"Last-Modified": datetime.fromisoformat(last_modified).strftime("%a, %d %b %Y %H:%M:%S GMT"),
}
try:
return StreamingResponse(file_sender(video_path), headers=headers)
except Exception:
logger.exception("Streaming error")
raise HTTPException(status_code=500, detail="Internal Server Error")
# Async file sender (full content)
async def file_sender(video_path: Path):
async with aiofiles.open(video_path, "rb") as file:
while True:
chunk = await file.read(65536)
if not chunk:
break
yield chunk
# Async file sender (range content)
async def file_sender_range(video_path: Path, start: int, end: int):
cache_key = f"{video_path.name}-{start}-{end}"
if cached := CHUNK_CACHE.get(cache_key):
yield cached
return
async with aiofiles.open(video_path, "rb") as file:
await file.seek(start)
chunks = []
while start <= end:
chunk_size = min(65536, end - start + 1)
chunk = await file.read(chunk_size)
if not chunk:
break
chunks.append(chunk)
start += len(chunk)
full_chunk = b"".join(chunks)
if len(full_chunk) < 1024 * 1024:
CHUNK_CACHE[cache_key] = full_chunk
yield full_chunk
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(VIDEO_API_PORT), log_level="debug")
import asyncio
import hashlib
import logging
import os
import re
import sys
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
import aiofiles
from cachetools import TTLCache
from dotenv import find_dotenv, load_dotenv
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from app.core.runtime.paths import default_recordings_dir
dotenv_path = find_dotenv()
load_dotenv(dotenv_path)
CUSTOM_VIDEO_ROOT_DIR = os.getenv("CUSTOM_VIDEO_ROOT_DIR")
VIDEO_API_PORT = os.getenv("VIDEO_API_PORT") or 6007
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
DEFAULT_VIDEO_ROOT_DIR = default_recordings_dir
VIDEO_DIR = Path(CUSTOM_VIDEO_ROOT_DIR or DEFAULT_VIDEO_ROOT_DIR)
os.makedirs(VIDEO_DIR, exist_ok=True)
VIDEO_META_CACHE = TTLCache(maxsize=50, ttl=300)
CHUNK_CACHE = TTLCache(maxsize=25, ttl=60)
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
@asynccontextmanager
async def lifespan(_app: FastAPI):
if not VIDEO_DIR.exists():
logger.error(f"Video directory does not exist: {VIDEO_DIR}")
raise RuntimeError(f"Video directory does not exist: {VIDEO_DIR}")
_app.mount("/api/videos", StaticFiles(directory=VIDEO_DIR), name="videos")
yield
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
_app.mount("/api/videos", StaticFiles(directory=None))
logger.info("Shutting down the application.")
app = FastAPI(lifespan=lifespan)
def validate_filename(filename: str):
if re.search(r"[\\/]", filename):
raise HTTPException(status_code=400, detail="Invalid filename")
@app.get("/api/videos")
async def get_video(request: Request, filename: str = Query(...), subfolder: str | None = None):
cache_key = f"{filename}-{subfolder}"
if meta := VIDEO_META_CACHE.get(cache_key):
if_none_match = request.headers.get("If-None-Match")
if_modified_since = request.headers.get("If-Modified-Since")
if if_none_match and if_none_match == meta["etag"]:
return Response(status_code=304)
if if_modified_since:
last_modified = datetime.fromisoformat(meta["last_modified"])
if datetime.strptime(if_modified_since, "%a, %d %b %Y %H:%M:%S GMT") >= last_modified:
return Response(status_code=304)
try:
validate_filename(filename)
if subfolder:
video_path = VIDEO_DIR / subfolder / filename
else:
video_path = VIDEO_DIR / filename
except Exception as e:
logger.exception("Invalid filename or subfolder")
raise e
if not video_path.is_file():
logger.error(f"File not found: {video_path}")
raise HTTPException(status_code=404, detail="Video file not found")
# Prevent path traversal attacks
try:
video_path.relative_to(VIDEO_DIR)
except ValueError:
logger.exception(f"Path traversal attempt: {video_path}")
raise HTTPException(status_code=400, detail="Invalid file path")
stat = video_path.stat()
file_size = stat.st_size
last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat()
etag = hashlib.md5(f"{file_size}-{last_modified}".encode()).hexdigest()
VIDEO_META_CACHE[cache_key] = {"etag": etag, "last_modified": last_modified, "file_size": file_size}
# Parse Range header
range_header = request.headers.get("Range")
if range_header:
start, end = range_header.replace("bytes=", "").split("-")
start = int(start)
end = int(end) if end else file_size - 1
if start >= file_size or end >= file_size:
logger.error(f"Invalid range request: {range_header}, file size: {file_size}")
raise HTTPException(status_code=416, detail="Requested range not satisfiable")
headers = {
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(end - start + 1),
"Content-Type": "video/mp4",
}
return StreamingResponse(
file_sender_range(video_path, start, end),
status_code=206,
headers=headers,
)
# If no Range header, return the whole file
headers = {
"Content-Length": str(file_size),
"Content-Type": "video/mp4",
"Cache-Control": "public, max-age=300",
"ETag": etag,
"Last-Modified": datetime.fromisoformat(last_modified).strftime("%a, %d %b %Y %H:%M:%S GMT"),
}
try:
return StreamingResponse(file_sender(video_path), headers=headers)
except Exception:
logger.exception("Streaming error")
raise HTTPException(status_code=500, detail="Internal Server Error")
# Async file sender (full content)
async def file_sender(video_path: Path):
async with aiofiles.open(video_path, "rb") as file:
while True:
chunk = await file.read(65536)
if not chunk:
break
yield chunk
# Async file sender (range content)
async def file_sender_range(video_path: Path, start: int, end: int):
cache_key = f"{video_path.name}-{start}-{end}"
if cached := CHUNK_CACHE.get(cache_key):
yield cached
return
async with aiofiles.open(video_path, "rb") as file:
await file.seek(start)
chunks = []
while start <= end:
chunk_size = min(65536, end - start + 1)
chunk = await file.read(chunk_size)
if not chunk:
break
chunks.append(chunk)
start += len(chunk)
full_chunk = b"".join(chunks)
if len(full_chunk) < 1024 * 1024:
CHUNK_CACHE[cache_key] = full_chunk
yield full_chunk
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(VIDEO_API_PORT), log_level="debug")

View File

@@ -1,52 +1,53 @@
from __future__ import annotations
import os
from typing import Any
class SettingsConfig:
def __init__(self, services):
self.services = services
cm = services.config_manager
self.user_config: dict = cm.load_user_config() or {}
self.default_config: dict = cm.load_default_config() or {}
self.cookies_config: dict = cm.load_cookies_config() or {}
self.accounts_config: dict = cm.load_accounts_config() or {}
self.language_option: dict = cm.load_language_config() or {}
select_language = self.user_config.get("language")
if select_language and select_language in self.language_option:
self.language_code: str = self.language_option[select_language]
elif self.language_option:
self.language_code = next(iter(self.language_option.values()))
else:
self.language_code = "zh_CN"
def get_config_value(self, key: str, default: Any = None) -> Any:
return self.user_config.get(key, self.default_config.get(key, default))
def get_cookies_value(self, key: str, default: str = "") -> str:
return self.cookies_config.get(key, default)
def get_accounts_value(self, key: str, default: Any = None) -> Any:
try:
k1, k2 = key.split("_", maxsplit=1)
except ValueError:
return default
return self.accounts_config.get(k1, {}).get(k2, default)
def get_video_save_path(self) -> str:
live_save_path = self.get_config_value("live_save_path")
if not live_save_path:
live_save_path = os.path.join(self.services.run_path, "downloads")
return live_save_path
def adopt_user_config(self, user_config: dict) -> None:
"""Replace ``user_config`` reference (used when the UI rebuilds it)."""
self.user_config = user_config
def adopt_cookies_config(self, cookies_config: dict) -> None:
self.cookies_config = cookies_config
def adopt_accounts_config(self, accounts_config: dict) -> None:
self.accounts_config = accounts_config
from __future__ import annotations
from typing import Any
from ..runtime.paths import default_recordings_dir
class SettingsConfig:
def __init__(self, services):
self.services = services
cm = services.config_manager
self.user_config: dict = cm.load_user_config() or {}
self.default_config: dict = cm.load_default_config() or {}
self.cookies_config: dict = cm.load_cookies_config() or {}
self.accounts_config: dict = cm.load_accounts_config() or {}
self.language_option: dict = cm.load_language_config() or {}
select_language = self.user_config.get("language")
if select_language and select_language in self.language_option:
self.language_code: str = self.language_option[select_language]
elif self.language_option:
self.language_code = next(iter(self.language_option.values()))
else:
self.language_code = "zh_CN"
def get_config_value(self, key: str, default: Any = None) -> Any:
return self.user_config.get(key, self.default_config.get(key, default))
def get_cookies_value(self, key: str, default: str = "") -> str:
return self.cookies_config.get(key, default)
def get_accounts_value(self, key: str, default: Any = None) -> Any:
try:
k1, k2 = key.split("_", maxsplit=1)
except ValueError:
return default
return self.accounts_config.get(k1, {}).get(k2, default)
def get_video_save_path(self) -> str:
live_save_path = self.get_config_value("live_save_path")
if not live_save_path:
live_save_path = str(default_recordings_dir)
return live_save_path
def adopt_user_config(self, user_config: dict) -> None:
"""Replace ``user_config`` reference (used when the UI rebuilds it)."""
self.user_config = user_config
def adopt_cookies_config(self, cookies_config: dict) -> None:
self.cookies_config = cookies_config
def adopt_accounts_config(self, accounts_config: dict) -> None:
self.accounts_config = accounts_config

View File

@@ -40,6 +40,11 @@ else:
resource_dir = _EXECUTABLE_DIR
user_data_dir = _EXECUTABLE_DIR
if getattr(sys, "frozen", False) and sys.platform == "win32":
default_recordings_dir = _EXECUTABLE_DIR / "downloads"
else:
default_recordings_dir = user_data_dir / "downloads"
def prepare_user_data_dir() -> None:
"""Copy bundled defaults to the writable user data directory when needed."""

View File

@@ -8,6 +8,7 @@ from ...models.media.video_format_model import VideoFormat
from ...models.media.video_quality_model import VideoQuality
from ...utils.delay import DelayedTaskExecutor
from ...utils.logger import logger
from ...core.runtime.paths import default_recordings_dir
from ..base_page import PageBase
from ..components.dialogs.help_dialog import HelpDialog
@@ -223,7 +224,7 @@ class SettingsPage(PageBase):
def get_video_save_path(self):
live_save_path = self.get_config_value("live_save_path")
if not live_save_path:
live_save_path = os.path.join(self.app.run_path, "downloads")
live_save_path = str(default_recordings_dir)
return live_save_path
@staticmethod

View File

@@ -41,9 +41,19 @@ open dist/StreamCap.app
Windows 打包完成后产物为:
```text
dist/StreamCap/StreamCap.exe
dist/StreamCap/
├─ StreamCap.exe
└─ _internal/
├─ assets/
├─ config/
├─ locales/
└─ ...
```
Windows 使用 PyInstaller one-dir 结构:外层保留 `StreamCap.exe` 作为用户入口,运行依赖、资源文件和 DLL 放在 `_internal` 目录中。
GitHub Actions 会自动把上传的 artifact 打成 zip因此工作流直接上传 `dist` 下的应用目录,不再预先生成内层 zip。下载 `StreamCap-windows.zip` 后解压一次即可得到 `StreamCap` 文件夹。
## macOS 架构
默认按当前 Python 环境和系统架构打包。Apple Silicon 机器通常会打出 arm64 包。
@@ -181,6 +191,8 @@ macOS: ~/Library/Application Support/StreamCap
Windows: %APPDATA%\StreamCap
```
Windows 默认录制保存目录为 `StreamCap.exe` 同级目录下的 `downloads`,避免大文件写入 C 盘用户数据目录。用户在设置页手动选择保存目录后,以用户设置为准。
源码运行时仍使用项目目录,方便开发调试。
## 常用命令

View File

@@ -41,9 +41,19 @@ open dist/StreamCap.app
Windows output:
```text
dist/StreamCap/StreamCap.exe
dist/StreamCap/
├─ StreamCap.exe
└─ _internal/
├─ assets/
├─ config/
├─ locales/
└─ ...
```
Windows uses the PyInstaller one-dir layout: `StreamCap.exe` stays at the top level as the user entry point, while runtime dependencies, resources, and DLLs live under `_internal`.
GitHub Actions automatically zips downloaded artifacts, so the workflow uploads the app directory under `dist` instead of creating an inner zip first. After downloading `StreamCap-windows.zip`, extracting it once gives a `StreamCap` folder.
## macOS Architecture
By default, the package uses the current Python environment and host architecture. Apple Silicon machines usually produce an arm64 package.
@@ -181,6 +191,8 @@ macOS: ~/Library/Application Support/StreamCap
Windows: %APPDATA%\StreamCap
```
On Windows, the default recording directory is `downloads` next to `StreamCap.exe`, so large videos are not written to the C drive user data directory by default. If the user chooses a save directory in Settings, that value takes precedence.
When running from source, StreamCap still uses the project directory for easier development and debugging.
## Common Commands