mirror of
https://github.com/zhouxiaoka/autoclip.git
synced 2026-10-02 02:34:34 +08:00
Merge remote-tracking branch 'origin/main' into feat/infistar-provider
This commit is contained in:
@@ -196,10 +196,45 @@ jobs:
|
||||
src-tauri/target/release/bundle/nsis/*.sig
|
||||
retention-days: 30
|
||||
|
||||
# 没有 Windows 真机时的替代冒烟:在干净的 windows-latest 上装上一个正式版,
|
||||
# 模拟旧版后端残留锁住 _asyncio.pyd,再静默覆盖安装本次构建,然后用安装目录里的 Python
|
||||
# 跑后端 / ffmpeg / 来源守卫。界面和真实链接下载仍需人工或用户验证,见 RELEASE_CHECKLIST.md。
|
||||
smoke-windows-x64:
|
||||
needs: build-windows-x64
|
||||
if: needs.build-windows-x64.result == 'success'
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download new installer
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: autoclip-desktop-windows-x64
|
||||
path: new
|
||||
|
||||
- name: Upgrade over previous release and verify installed runtime
|
||||
run: |
|
||||
$installer = Get-ChildItem -Recurse new -Filter '*-setup.exe' | Select-Object -First 1
|
||||
./scripts/windows_upgrade_smoke.ps1 -NewInstaller $installer.FullName
|
||||
|
||||
- name: Upload smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-smoke-report
|
||||
path: windows-smoke.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Publish only after both platforms succeed; never advertise a partial release.
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/v') && needs.build-macos-arm64.result == 'success' && needs.build-windows-x64.result == 'success'
|
||||
needs: [build-macos-arm64, build-windows-x64]
|
||||
if: startsWith(github.ref, 'refs/tags/v') && needs.build-macos-arm64.result == 'success' && needs.build-windows-x64.result == 'success' && needs.smoke-windows-x64.result == 'success'
|
||||
needs: [build-macos-arm64, build-windows-x64, smoke-windows-x64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 只重跑 Windows 安装冒烟,复用某次 desktop-build 已经打好的安装包,不用再等 25 分钟构建。
|
||||
# 调冒烟脚本、复现安装问题时用。
|
||||
name: Windows Install Smoke
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_run_id:
|
||||
description: "desktop-build.yml 的 run id(其中 build-windows-x64 成功)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download installer from build run
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: autoclip-desktop-windows-x64
|
||||
path: new
|
||||
run-id: ${{ inputs.build_run_id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upgrade over previous release and verify installed runtime
|
||||
run: |
|
||||
$installer = Get-ChildItem -Recurse new -Filter '*-setup.exe' | Select-Object -First 1
|
||||
./scripts/windows_upgrade_smoke.ps1 -NewInstaller $installer.FullName
|
||||
|
||||
- name: Upload smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-smoke-report
|
||||
path: windows-smoke.json
|
||||
if-no-files-found: ignore
|
||||
@@ -15,6 +15,14 @@
|
||||
- 智能导入后台任务提交失败后可明确重试,已有方案、草稿和成片保留;修改方案失败时同步恢复偏好。
|
||||
- 时间线兼容常见时间戳格式;相邻短片段在丢弃前尝试合并,保留既有时长限制。
|
||||
- 保留的旧导入组件支持更多 YouTube 分享链接格式;当前 Studio 入口增加独立链接回归覆盖。
|
||||
- Windows 覆盖安装前会结束旧版本残留的后端进程,不再报「无法打开要写入的文件 _asyncio.pyd」;应用内更新先停后端再安装。
|
||||
- 链接导入把内置 ffmpeg 交给下载器,Windows 上合并音视频不再失败;下载阶段显示真实百分比,不再长时间停在同一个进度。
|
||||
- 测试使用临时数据库,不再清空开发者本机的 `data/autoclip.db`。
|
||||
|
||||
### 安全
|
||||
- 本地后端只接受 AutoClip 自己界面的跨域请求,其他网页不能再读取设置里的 API Key 或发起导入、发布;桌面端拒绝非本机 Host,防 DNS 重绑定。
|
||||
- 调试路由默认不再挂载(`AUTOCLIP_ENABLE_DEBUG_ROUTES=1` 开启);`python -m backend.main` 默认只监听 127.0.0.1。
|
||||
- Docker 从局域网 IP 或自定义域名打开前端时,需要在 `AUTOCLIP_ALLOWED_ORIGINS` 里加上前端地址(逗号分隔)。
|
||||
|
||||
## [1.4.0] - 2026-09-27
|
||||
|
||||
|
||||
+15
-1
@@ -55,7 +55,21 @@
|
||||
- [ ] **macOS**:同上四项(首次打开仍需右键打开,直到完成公证)
|
||||
- [ ] 本版 CHANGELOG 里每条修复,至少在一台机器上亲手确认过
|
||||
|
||||
没有干净的 Windows 机器时,至少要 Windows CI 打包冒烟全绿(待建,见 `docs/PROJECT_REVIEW_2026-09-29.md` P1-4),并在 Release 正文里写明「Windows 未经真机验收」。
|
||||
#### 没有 Windows 真机时(当前情况)
|
||||
|
||||
Windows 验收分三层,前两层必须过,第三层补上界面和真实网络:
|
||||
|
||||
1. **CI 自动冒烟(必须,tag 构建自动跑,不过就不会出 Release)**:`desktop-build.yml` 的 `smoke-windows-x64` 在干净的 windows-latest 上
|
||||
装上一个正式版 → 模拟残留后端占住 `_asyncio.pyd` → 静默覆盖安装本次构建 → 用安装目录自带的 Python 跑 `scripts/verify_windows_install.py`
|
||||
(内置 ffmpeg 出片、中文文件名、yt-dlp 拿到 ffmpeg、桌面后端启动、来源守卫)。覆盖了近两周 Windows 反馈里的主要故障点,
|
||||
但**看不到界面**,也不下载真实链接。
|
||||
2. **Mac 上的 Windows 虚拟机(必须,约 15 分钟)**:Parallels Desktop / VMware Fusion + Windows 11 ARM(x64 安装包可在 ARM 版 Windows 上仿真运行),
|
||||
或 UTM(免费)。做一次快照当「干净机器」,每次发版还原快照后走上面四项。这是界面、WebView2、真实下载唯一能亲眼看的地方。
|
||||
仿真环境里性能和个别驱动行为和真机不同,记录时注明「ARM 虚拟机」。
|
||||
3. **Pre-release 测试者(建议)**:在 Discussions 里招 3–5 位 Windows 用户(优先找报过 #163 #173 #183 这类问题的人),
|
||||
Pre-release 发出后 @ 他们装上试一条自己的视频,在观察期内回帖。有人确认通过再转正。
|
||||
|
||||
第 2 层做不了的那一次发版,要在 Release 正文顶部写明「本版 Windows 仅经 CI 冒烟验收,未经界面验收」,并把观察期延长到 48 小时。
|
||||
|
||||
### 3.4 观察期(24 小时,热修版可缩短到 4 小时)
|
||||
- [ ] Sentry:新版本的错误数 / 受影响用户数,和上一版同期对比,没有新的高频错误
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
API v1 package for FastAPI routes.
|
||||
统一管理所有API路由
|
||||
"""
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
@@ -48,7 +49,9 @@ api_router.include_router(subtitle_editor_router, prefix="/subtitle-editor", tag
|
||||
api_router.include_router(upload_router, tags=["upload"])
|
||||
api_router.include_router(progress_router, prefix="/progress", tags=["progress"])
|
||||
api_router.include_router(pipeline_control_router, prefix="/pipeline", tags=["pipeline"])
|
||||
api_router.include_router(debug_router, tags=["debug"])
|
||||
# 调试路由能直接往发布通道塞消息,只在显式开启时挂载
|
||||
if os.getenv("AUTOCLIP_ENABLE_DEBUG_ROUTES", "").lower() in ("1", "true", "yes"):
|
||||
api_router.include_router(debug_router, tags=["debug"])
|
||||
api_router.include_router(simple_progress_router, tags=["simple-progress"])
|
||||
# api_router.include_router(environment_router, tags=["environment"]) # 文件不存在,暂时注释
|
||||
api_router.include_router(settings_router, tags=["settings"])
|
||||
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
from pathlib import Path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
from ...core.config import get_data_directory
|
||||
from ...utils.ffmpeg_utils import ytdlp_ffmpeg_options
|
||||
import uuid
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
@@ -352,6 +353,11 @@ async def get_all_youtube_tasks():
|
||||
|
||||
async def update_project_download_progress(project_id: str, progress: float, message: str):
|
||||
"""更新项目下载进度"""
|
||||
save_project_download_progress(project_id, progress, message)
|
||||
|
||||
|
||||
def save_project_download_progress(project_id: str, progress: float, message: str):
|
||||
"""同步写进度,yt-dlp 的下载线程里也能直接调。"""
|
||||
try:
|
||||
from ...core.database import SessionLocal
|
||||
from ...services.project_service import ProjectService
|
||||
@@ -383,6 +389,41 @@ async def update_project_download_progress(project_id: str, progress: float, mes
|
||||
except Exception as e:
|
||||
logger.error(f"更新项目下载进度失败: {e}")
|
||||
|
||||
|
||||
# 下载阶段在总进度里占 30%→60%
|
||||
DOWNLOAD_PROGRESS_START = 30.0
|
||||
DOWNLOAD_PROGRESS_END = 60.0
|
||||
|
||||
|
||||
def make_download_progress_hook(project_id: str, task_id: str, min_interval: float = 1.0):
|
||||
"""yt-dlp progress_hooks:把真实下载百分比映射到 30%→60%。
|
||||
|
||||
以前下载期间进度从 30 直接跳到 60,大视频下载几分钟里一直不动,用户以为卡死了。
|
||||
音视频分两个文件下载时百分比会回落,只取单调递增的值;最多每秒写一次库。
|
||||
"""
|
||||
import time
|
||||
state = {"last_write": 0.0, "last_value": DOWNLOAD_PROGRESS_START}
|
||||
|
||||
def hook(d):
|
||||
if d.get("status") != "downloading":
|
||||
return
|
||||
total = d.get("total_bytes") or d.get("total_bytes_estimate")
|
||||
downloaded = d.get("downloaded_bytes") or 0
|
||||
if not total:
|
||||
return
|
||||
fraction = max(0.0, min(1.0, downloaded / total))
|
||||
value = round(DOWNLOAD_PROGRESS_START + fraction * (DOWNLOAD_PROGRESS_END - DOWNLOAD_PROGRESS_START - 1), 1)
|
||||
now = time.monotonic()
|
||||
if value <= state["last_value"] or now - state["last_write"] < min_interval:
|
||||
return
|
||||
state["last_value"], state["last_write"] = value, now
|
||||
task = download_tasks.get(task_id)
|
||||
if task is not None:
|
||||
task.progress = value
|
||||
save_project_download_progress(project_id, value, f"正在下载视频... {int(fraction * 100)}%")
|
||||
|
||||
return hook
|
||||
|
||||
async def process_youtube_download_task(task_id: str, request: YouTubeDownloadRequest, project_id: str):
|
||||
"""处理YouTube下载任务"""
|
||||
try:
|
||||
@@ -412,6 +453,8 @@ async def process_youtube_download_task(task_id: str, request: YouTubeDownloadRe
|
||||
# 设置下载选项
|
||||
ydl_opts = {
|
||||
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
|
||||
# 合并后统一成 mp4,下面按 *.mp4 找文件
|
||||
'merge_output_format': 'mp4',
|
||||
'writesubtitles': True,
|
||||
'writeautomaticsub': True, # 下载自动生成的字幕
|
||||
'subtitleslangs': get_subtitle_langs(),
|
||||
@@ -423,6 +466,8 @@ async def process_youtube_download_task(task_id: str, request: YouTubeDownloadRe
|
||||
'ignoreconfig': True,
|
||||
'config_locations': [],
|
||||
'cachedir': False,
|
||||
'progress_hooks': [make_download_progress_hook(project_id, task_id)],
|
||||
**ytdlp_ffmpeg_options(),
|
||||
}
|
||||
|
||||
if request.browser:
|
||||
@@ -696,6 +741,7 @@ async def _try_download_with_different_formats(url: str, download_dir: Path, bro
|
||||
'quiet': True,
|
||||
'ignoreconfig': True,
|
||||
'config_locations': [],
|
||||
**ytdlp_ffmpeg_options(),
|
||||
}
|
||||
|
||||
if browser:
|
||||
@@ -750,6 +796,7 @@ async def _try_download_with_different_langs(url: str, download_dir: Path, brows
|
||||
'writeautomaticsub': True,
|
||||
'subtitleslangs': langs,
|
||||
'subtitlesformat': 'srt',
|
||||
**ytdlp_ffmpeg_options(),
|
||||
'outtmpl': str(download_dir / f'lang_%(title)s.%(ext)s'),
|
||||
'noplaylist': True,
|
||||
'quiet': True,
|
||||
|
||||
@@ -14,6 +14,7 @@ from backend.core.database import engine
|
||||
from backend.models.base import Base
|
||||
from backend.core.config import get_logging_config, get_api_key
|
||||
from backend.core.error_middleware import global_exception_handler
|
||||
from backend.core.local_origin_guard import LocalOriginGuard, allowed_origins
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,10 +61,13 @@ def create_app(mode: str = "web") -> FastAPI:
|
||||
# 设置应用状态
|
||||
app.state.mode = mode
|
||||
|
||||
# 配置 CORS
|
||||
# 来源守卫在内、CORS 在外:预检由 CORS 应答,其他网页的写请求由守卫拦下。
|
||||
# 以前是 allow_origins=["*"] + allow_credentials,任意网页都能读设置里的 API Key。
|
||||
origins = allowed_origins()
|
||||
app.add_middleware(LocalOriginGuard, origins=origins, enforce_local_host=(mode == "desktop"))
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 生产环境需要配置具体域名
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
本地后端的来源守卫。
|
||||
|
||||
后端没有登录态,任何能发 HTTP 请求到它的页面都能读设置里的 API Key、触发导入和发布。
|
||||
浏览器里打开的普通网页也能向 127.0.0.1 发请求,所以要在服务端拦:
|
||||
|
||||
1. CORS 只对 AutoClip 自己的前端放行,其他网页读不到响应。
|
||||
2. 带 Origin 的写请求(POST/PUT/PATCH/DELETE)必须来自允许的来源,或与请求 Host 同源。
|
||||
表单 / multipart 这类“简单请求”不会触发预检,只靠 CORS 拦不住。
|
||||
3. 桌面模式只接受 Host 为 127.0.0.1 / localhost 的请求,挡住 DNS 重绑定。
|
||||
|
||||
CLI、curl、Tauri 的 reqwest 不带 Origin,照常放行。
|
||||
Docker 通过局域网 IP 或自定义域名访问时,用 AUTOCLIP_ALLOWED_ORIGINS 追加来源(逗号分隔)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
# Tauri 2:macOS / Linux 为 tauri://localhost,Windows 为 http(s)://tauri.localhost;3000 是 Vite 开发端口
|
||||
DEFAULT_ALLOWED_ORIGINS = (
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
)
|
||||
|
||||
LOCAL_HOSTNAMES = {"127.0.0.1", "localhost", "::1", "[::1]"}
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
|
||||
|
||||
def allowed_origins() -> list[str]:
|
||||
extra = [o.strip().rstrip("/") for o in os.getenv("AUTOCLIP_ALLOWED_ORIGINS", "").split(",") if o.strip()]
|
||||
return list(dict.fromkeys([*DEFAULT_ALLOWED_ORIGINS, *extra]))
|
||||
|
||||
|
||||
def _header(scope: Scope, name: bytes) -> str:
|
||||
for key, value in scope.get("headers") or ():
|
||||
if key == name:
|
||||
return value.decode("latin-1")
|
||||
return ""
|
||||
|
||||
|
||||
def _hostname(host: str) -> str:
|
||||
host = host.strip().lower()
|
||||
if host.startswith("["):
|
||||
return host.split("]")[0] + "]"
|
||||
return host.rsplit(":", 1)[0] if ":" in host else host
|
||||
|
||||
|
||||
def is_same_origin(origin: str, host: str) -> bool:
|
||||
"""Origin 的 host:port 与请求 Host 一致(Docker 直接访问 :8000 或反代保留 Host 的情况)。"""
|
||||
if not origin or not host:
|
||||
return False
|
||||
parsed = urlsplit(origin)
|
||||
return bool(parsed.netloc) and parsed.netloc.lower() == host.strip().lower()
|
||||
|
||||
|
||||
class LocalOriginGuard:
|
||||
def __init__(self, app: ASGIApp, origins: Iterable[str], enforce_local_host: bool) -> None:
|
||||
self.app = app
|
||||
self.origins = {o.lower() for o in origins}
|
||||
self.enforce_local_host = enforce_local_host
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] not in ("http", "websocket"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
host = _header(scope, b"host")
|
||||
if self.enforce_local_host and host and _hostname(host) not in LOCAL_HOSTNAMES:
|
||||
await self._reject(scope, receive, send, "host_not_allowed")
|
||||
return
|
||||
|
||||
origin = _header(scope, b"origin").rstrip("/").lower()
|
||||
method = scope.get("method", "GET").upper()
|
||||
cross_site_write = scope["type"] == "http" and method not in SAFE_METHODS
|
||||
if origin and (cross_site_write or scope["type"] == "websocket"):
|
||||
forwarded_host = _header(scope, b"x-forwarded-host")
|
||||
trusted = (
|
||||
origin in self.origins
|
||||
or is_same_origin(origin, host)
|
||||
or is_same_origin(origin, forwarded_host)
|
||||
)
|
||||
if not trusted:
|
||||
await self._reject(scope, receive, send, "origin_not_allowed")
|
||||
return
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
async def _reject(self, scope: Scope, receive: Receive, send: Send, code: str) -> None:
|
||||
if scope["type"] == "websocket":
|
||||
await send({"type": "websocket.close", "code": 1008})
|
||||
return
|
||||
response = JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "请求来源不被允许", "error_code": code},
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
+5
-2
@@ -25,5 +25,8 @@ if __name__ == "__main__":
|
||||
logger.error(f"无效的端口号: {sys.argv[i + 1]}")
|
||||
port = 8000
|
||||
|
||||
logger.info(f"启动服务器,端口: {port}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
# 默认只听本机;Docker / 局域网访问显式设 AUTOCLIP_HOST=0.0.0.0(Dockerfile 的 CMD 自带 --host)
|
||||
import os
|
||||
host = os.getenv("AUTOCLIP_HOST", "127.0.0.1")
|
||||
logger.info(f"启动服务器,地址: {host}:{port}")
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
+1
-3
@@ -1,14 +1,12 @@
|
||||
[tool:pytest]
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
--disable-warnings
|
||||
--color=yes
|
||||
markers =
|
||||
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||
integration: marks tests as integration tests
|
||||
|
||||
@@ -121,12 +121,36 @@ def _analyze(project_id, prefs, url, browser):
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def download_progress_hook(project_id, min_interval=1.0):
|
||||
"""把 yt-dlp 的下载百分比写进 analysis.percent。
|
||||
|
||||
以前整段下载只显示「准备素材」,几分钟不动,用户以为卡死。音视频分两个文件下载时
|
||||
百分比会回落,只写单调递增的值;最多每秒写一次。
|
||||
"""
|
||||
state = {'at': 0.0, 'percent': -1}
|
||||
def hook(d):
|
||||
if d.get('status') != 'downloading':
|
||||
return
|
||||
total = d.get('total_bytes') or d.get('total_bytes_estimate')
|
||||
if not total:
|
||||
return
|
||||
percent = min(99, int((d.get('downloaded_bytes') or 0) * 100 / total))
|
||||
now = monotonic()
|
||||
if percent <= state['percent'] or now - state['at'] < min_interval:
|
||||
return
|
||||
state.update(at=now, percent=percent)
|
||||
try:
|
||||
store.change(project_id, lambda data: data['analysis'].update(percent=percent) if data.get('analysis') else None)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return hook
|
||||
|
||||
def download(project_id, url, browser):
|
||||
import yt_dlp
|
||||
from backend.utils.ffmpeg_utils import get_ffmpeg_path
|
||||
folder = store.directory(project_id) / 'raw'
|
||||
folder.mkdir(exist_ok=True)
|
||||
options = {'format': 'bestvideo[height<=1080]+bestaudio/best[height<=1080]', 'outtmpl': str(folder / 'input.%(ext)s'), 'merge_output_format': 'mp4', 'noplaylist': True, 'quiet': True, 'ffmpeg_location': get_ffmpeg_path(), 'socket_timeout': 30, 'retries': 2}
|
||||
options = {'format': 'bestvideo[height<=1080]+bestaudio/best[height<=1080]', 'outtmpl': str(folder / 'input.%(ext)s'), 'merge_output_format': 'mp4', 'noplaylist': True, 'quiet': True, 'ffmpeg_location': get_ffmpeg_path(), 'socket_timeout': 30, 'retries': 2, 'progress_hooks': [download_progress_hook(project_id)]}
|
||||
if browser:
|
||||
options['cookiesfrombrowser'] = (browser,)
|
||||
with yt_dlp.YoutubeDL(options) as downloader:
|
||||
@@ -217,7 +241,7 @@ def _inspect(project_id, options, url, browser):
|
||||
mark_project(project_id, 'processing', awaiting_confirmation=False)
|
||||
if url:
|
||||
download(project_id, url, browser)
|
||||
store.change(project_id, lambda data:data['analysis'].update(message='快速判断适合的制作类型'))
|
||||
store.change(project_id, lambda data:(data['analysis'].pop('percent', None), data['analysis'].update(message='快速判断适合的制作类型')))
|
||||
plan = recommend(source(project_id), options)
|
||||
ensure_project_thumbnail(project_id)
|
||||
plan['id'] = uuid.uuid4().hex
|
||||
|
||||
@@ -16,6 +16,12 @@ import os
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.append(str(project_root))
|
||||
|
||||
# 测试库放临时目录。默认的 sqlite:///./data/autoclip.db 相对 cwd,
|
||||
# test_repositories 的 reset_database() 会 drop_all 掉开发者本机的库。
|
||||
# 必须在任何 backend.core.database 导入之前设置。
|
||||
_TEST_DB_DIR = tempfile.mkdtemp(prefix="autoclip-tests-")
|
||||
os.environ.setdefault("DATABASE_URL", f"sqlite:///{Path(_TEST_DB_DIR) / 'autoclip.db'}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_data_dir(tmp_path_factory):
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""链接导入:yt-dlp 要拿到打包的 ffmpeg,下载期间要有真实进度。"""
|
||||
from pathlib import Path
|
||||
|
||||
from backend.utils import ffmpeg_utils
|
||||
|
||||
|
||||
def test_ytdlp_ffmpeg_options_uses_bundled_binary(tmp_path, monkeypatch):
|
||||
binary = tmp_path / "ffmpeg.exe"
|
||||
binary.write_bytes(b"")
|
||||
monkeypatch.setenv("AUTOCLIP_FFMPEG_PATH", str(binary))
|
||||
assert ffmpeg_utils.ytdlp_ffmpeg_options() == {"ffmpeg_location": str(binary)}
|
||||
|
||||
|
||||
def test_ytdlp_ffmpeg_options_empty_when_unresolved(monkeypatch):
|
||||
monkeypatch.delenv("AUTOCLIP_FFMPEG_PATH", raising=False)
|
||||
monkeypatch.delenv("FFMPEG_PATH", raising=False)
|
||||
monkeypatch.setattr(ffmpeg_utils.shutil, "which", lambda _name: None)
|
||||
assert ffmpeg_utils.ytdlp_ffmpeg_options() == {}
|
||||
|
||||
|
||||
def test_legacy_download_hook_maps_progress_monotonically(monkeypatch):
|
||||
from backend.api.v1 import youtube
|
||||
|
||||
writes = []
|
||||
monkeypatch.setattr(youtube, "save_project_download_progress", lambda pid, value, msg: writes.append(value))
|
||||
hook = youtube.make_download_progress_hook("p1", "missing-task", min_interval=0)
|
||||
|
||||
hook({"status": "downloading", "downloaded_bytes": 0, "total_bytes": 100})
|
||||
hook({"status": "downloading", "downloaded_bytes": 50, "total_bytes": 100})
|
||||
# 视频下完换音频,百分比回落,不能倒退
|
||||
hook({"status": "downloading", "downloaded_bytes": 10, "total_bytes": 100})
|
||||
hook({"status": "downloading", "downloaded_bytes": 100, "total_bytes_estimate": 100})
|
||||
hook({"status": "finished"})
|
||||
hook({"status": "downloading", "downloaded_bytes": 5}) # 没有总大小
|
||||
|
||||
assert writes == sorted(writes)
|
||||
assert writes[0] > youtube.DOWNLOAD_PROGRESS_START
|
||||
assert writes[-1] < youtube.DOWNLOAD_PROGRESS_END
|
||||
|
||||
|
||||
def test_studio_download_hook_writes_percent(monkeypatch):
|
||||
from backend.services.studio import jobs
|
||||
|
||||
state = {"analysis": {"status": "running", "message": "准备素材"}}
|
||||
monkeypatch.setattr(jobs.store, "change", lambda _pid, mutate: mutate(state))
|
||||
hook = jobs.download_progress_hook("p1", min_interval=0)
|
||||
|
||||
hook({"status": "downloading", "downloaded_bytes": 30, "total_bytes": 100})
|
||||
assert state["analysis"]["percent"] == 30
|
||||
hook({"status": "downloading", "downloaded_bytes": 10, "total_bytes": 100})
|
||||
assert state["analysis"]["percent"] == 30
|
||||
hook({"status": "downloading", "downloaded_bytes": 100, "total_bytes": 100})
|
||||
assert state["analysis"]["percent"] == 99 # 下完还要合并,不显示 100
|
||||
|
||||
|
||||
def test_legacy_downloaders_pass_ffmpeg_and_mp4_merge():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
for rel in ("api/v1/youtube.py", "utils/bilibili_downloader.py"):
|
||||
text = (root / rel).read_text(encoding="utf-8")
|
||||
assert "ytdlp_ffmpeg_options()" in text, rel
|
||||
assert "'merge_output_format': 'mp4'" in text, rel
|
||||
@@ -0,0 +1,77 @@
|
||||
"""本地后端来源守卫:其他网页不能读 Key、不能发写请求,自家前端和 CLI 不受影响。"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.core.local_origin_guard import LocalOriginGuard, allowed_origins
|
||||
|
||||
|
||||
def make_client(enforce_local_host: bool, base_url: str = "http://127.0.0.1:8000") -> TestClient:
|
||||
app = FastAPI()
|
||||
origins = allowed_origins()
|
||||
app.add_middleware(LocalOriginGuard, origins=origins, enforce_local_host=enforce_local_host)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=origins, allow_credentials=True,
|
||||
allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
@app.get("/settings")
|
||||
def read():
|
||||
return {"key": "sk-secret"}
|
||||
|
||||
@app.post("/import")
|
||||
def write():
|
||||
return {"ok": True}
|
||||
|
||||
return TestClient(app, base_url=base_url)
|
||||
|
||||
|
||||
def test_foreign_page_cannot_read_response_via_cors():
|
||||
c = make_client(True)
|
||||
r = c.get("/settings", headers={"Origin": "https://evil.example"})
|
||||
assert "access-control-allow-origin" not in r.headers
|
||||
r = c.get("/settings", headers={"Origin": "tauri://localhost"})
|
||||
assert r.headers["access-control-allow-origin"] == "tauri://localhost"
|
||||
|
||||
|
||||
def test_foreign_page_cannot_post_simple_request():
|
||||
c = make_client(True)
|
||||
r = c.post("/import", data={"url": "x"}, headers={"Origin": "https://evil.example"})
|
||||
assert r.status_code == 403
|
||||
assert r.json()["error_code"] == "origin_not_allowed"
|
||||
r = c.post("/import", headers={"Origin": "null"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_app_origins_and_non_browser_clients_allowed():
|
||||
c = make_client(True)
|
||||
for origin in ("tauri://localhost", "http://tauri.localhost", "http://localhost:3000"):
|
||||
assert c.post("/import", headers={"Origin": origin}).status_code == 200
|
||||
# CLI / curl / Tauri reqwest 不带 Origin
|
||||
assert c.post("/import").status_code == 200
|
||||
|
||||
|
||||
def test_preflight_from_app_origin_succeeds():
|
||||
c = make_client(True)
|
||||
r = c.options("/import", headers={"Origin": "tauri://localhost", "Access-Control-Request-Method": "POST"})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_desktop_rejects_dns_rebinding_host():
|
||||
c = make_client(True, base_url="http://attacker.example:8000")
|
||||
assert c.get("/settings").status_code == 403
|
||||
assert make_client(True, base_url="http://localhost:51234").get("/settings").status_code == 200
|
||||
|
||||
|
||||
def test_web_mode_same_origin_and_extra_origins(monkeypatch):
|
||||
# Docker 从局域网 IP 直接访问 :8000 → 同源放行
|
||||
c = make_client(False, base_url="http://192.168.1.20:8000")
|
||||
assert c.post("/import", headers={"Origin": "http://192.168.1.20:8000"}).status_code == 200
|
||||
# 前端在 :3000、后端在 :8000 的局域网部署 → 需要显式加入
|
||||
assert c.post("/import", headers={"Origin": "http://192.168.1.20:3000"}).status_code == 403
|
||||
monkeypatch.setenv("AUTOCLIP_ALLOWED_ORIGINS", "http://192.168.1.20:3000")
|
||||
c = make_client(False, base_url="http://192.168.1.20:8000")
|
||||
assert c.post("/import", headers={"Origin": "http://192.168.1.20:3000"}).status_code == 200
|
||||
|
||||
|
||||
def test_debug_routes_not_mounted_by_default():
|
||||
from backend.api.v1 import api_router
|
||||
assert not any(getattr(r, "path", "").startswith("/debug") for r in api_router.routes)
|
||||
@@ -13,6 +13,8 @@ from typing import Dict, Any, Optional, Callable
|
||||
from datetime import datetime
|
||||
import yt_dlp
|
||||
|
||||
from .ffmpeg_utils import ytdlp_ffmpeg_options
|
||||
|
||||
try:
|
||||
from .error_handler import FileIOError, ValidationError, ProcessingError
|
||||
except ImportError:
|
||||
@@ -150,6 +152,8 @@ class BilibiliDownloader:
|
||||
# 设置下载选项 - 改进字幕下载策略
|
||||
ydl_opts = {
|
||||
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
|
||||
'merge_output_format': 'mp4',
|
||||
**ytdlp_ffmpeg_options(),
|
||||
'writesubtitles': True,
|
||||
'writeautomaticsub': True, # 同时尝试下载自动生成字幕
|
||||
'subtitleslangs': ['ai-zh', 'zh-Hans', 'zh', 'en'], # 多种字幕语言
|
||||
@@ -248,6 +252,7 @@ class BilibiliDownloader:
|
||||
'writeautomaticsub': True,
|
||||
'subtitleslangs': langs,
|
||||
'subtitlesformat': 'srt',
|
||||
**ytdlp_ffmpeg_options(),
|
||||
'outtmpl': str(self.download_dir / f'{safe_title}_sub.%(ext)s'),
|
||||
'noplaylist': True,
|
||||
'quiet': True,
|
||||
@@ -281,6 +286,7 @@ class BilibiliDownloader:
|
||||
'writeautomaticsub': True,
|
||||
'subtitleslangs': ['zh-Hans', 'zh', 'en'],
|
||||
'subtitlesformat': 'srt',
|
||||
**ytdlp_ffmpeg_options(),
|
||||
'outtmpl': str(self.download_dir / f'{safe_title}_nocookie.%(ext)s'),
|
||||
'noplaylist': True,
|
||||
'quiet': True,
|
||||
|
||||
@@ -59,3 +59,16 @@ def get_ffprobe_path() -> str:
|
||||
return "ffprobe"
|
||||
|
||||
|
||||
|
||||
|
||||
def ytdlp_ffmpeg_options() -> dict:
|
||||
"""给 yt-dlp 的 ffmpeg 选项。
|
||||
|
||||
yt-dlp 只在 PATH 里找 ffmpeg,不读 AUTOCLIP_FFMPEG_PATH。桌面安装包的 ffmpeg 不在 PATH 上,
|
||||
Windows 上合并 bestvideo+bestaudio、转字幕格式都会失败,导入就卡住或找不到 mp4。
|
||||
只有解析到真实文件时才传,否则交给 yt-dlp 自己找。
|
||||
"""
|
||||
path = get_ffmpeg_path()
|
||||
if os.path.isabs(path) and os.path.exists(path):
|
||||
return {"ffmpeg_location": path}
|
||||
return {}
|
||||
|
||||
@@ -48,6 +48,7 @@ services:
|
||||
- API_GEMINI_API_KEY=${API_GEMINI_API_KEY:-}
|
||||
- API_SILICONFLOW_API_KEY=${API_SILICONFLOW_API_KEY:-}
|
||||
- UPLOAD_POST_API_KEY=${UPLOAD_POST_API_KEY:-}
|
||||
- AUTOCLIP_ALLOWED_ORIGINS=${AUTOCLIP_ALLOWED_ORIGINS:-}
|
||||
- UPLOAD_POST_USER=${UPLOAD_POST_USER:-}
|
||||
- AUTOCLIP_YT_SUBTITLE_LANGS=${AUTOCLIP_YT_SUBTITLE_LANGS:-}
|
||||
depends_on:
|
||||
|
||||
@@ -68,3 +68,7 @@ LOG_FILE=backend.log
|
||||
# 环境配置
|
||||
ENVIRONMENT=development
|
||||
DEBUG=true
|
||||
|
||||
# 允许访问后端的前端来源(逗号分隔)。本机 localhost:3000 与桌面端已默认允许;
|
||||
# 从局域网 IP / 自定义域名打开前端时加上,例如 http://192.168.1.20:3000
|
||||
AUTOCLIP_ALLOWED_ORIGINS=
|
||||
|
||||
@@ -45,7 +45,21 @@ export async function checkForUpdate(): Promise<AppUpdate | null> {
|
||||
date: update.date,
|
||||
download: (onEvent) => update.download(onEvent),
|
||||
installAndRelaunch: async () => {
|
||||
await update.install()
|
||||
// 先停后端,否则 python.exe 还占着 resources\python\*.pyd,Windows 覆盖安装会失败(#224)。
|
||||
// 停不掉也继续:安装器的 PREINSTALL 钩子还会按安装目录再清一次。
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
try {
|
||||
await invoke('stop_backend_service')
|
||||
} catch (error) {
|
||||
console.warn('更新前停止后端失败,交给安装器处理', error)
|
||||
}
|
||||
try {
|
||||
await update.install()
|
||||
} catch (error) {
|
||||
// 安装没成功就把后端拉起来,别让用户停在一个没有后端的界面上
|
||||
await invoke('start_backend_service').catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process')
|
||||
await relaunch()
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function StudioResults({ project, children, onCreateCollection, o
|
||||
return <>
|
||||
{managed && <div className="studio-row"><span className="studio-muted">{sourceDuration!=null?`${t('原素材 {{duration}}', { duration: fmtDuration(sourceDuration) })} · `:''}{workspace.analysis?.status==='awaiting_confirmation'?t("新制作方案待确认,已有结果保留。"):t("制作结果与编辑")}</span><Btn size="sm" disabled={workspace.analysis?.status==='running'} onClick={()=>navigate(`/import/${project.id}`)}>{workspace.analysis?.status==='awaiting_confirmation'?t("继续确认方案"):t("调整制作方案")}</Btn></div>}
|
||||
{!(workspace.analysis?.status==='awaiting_confirmation' && !workspace.drafts.length && !project.clips?.length && !project.collections?.length) && <Section title={t("剪辑与成片")} count={workspace.drafts.length+(project.clips?.length||0)+(project.collections?.length||0)} description={visual?t("候选已整理为可编辑草稿;导出完成后,才能下载带包装的视频。"):t("切片与合集都在这里,可以直接下载,也可以另存为成片草稿。")} right={<div className="studio-actions"><Btn size="sm" onClick={()=>setHistory(true)}>{t("导出记录")}</Btn>{!managed&&<Btn size="sm" onClick={onCreateCollection}>{t("新建合集")}</Btn>}</div>}>
|
||||
{workspace.analysis?.status==='running' && <div className="ac-empty studio-processing"><b><span className="spin"/> {t(workspace.analysis.message||"分析画面中")}</b>{workspace.analysis.phase==='screening'?t("识别完成后,请确认要制作的类型;现在还不会开始剪辑。"):t("完成后草稿会出现在这里,可以先处理其他项目。")}</div>}
|
||||
{workspace.analysis?.status==='running' && <div className="ac-empty studio-processing"><b><span className="spin"/> {t(workspace.analysis.message||"分析画面中")}{typeof workspace.analysis.percent==='number' && ` · ${workspace.analysis.percent}%`}</b>{workspace.analysis.phase==='screening'?t("识别完成后,请确认要制作的类型;现在还不会开始剪辑。"):t("完成后草稿会出现在这里,可以先处理其他项目。")}</div>}
|
||||
{workspace.analysis?.status==='failed' && <div className="ac-empty"><b>{t("这次制作未完成")}</b><span className="studio-error">{t(workspace.analysis.error || '')}</span><Btn onClick={()=>navigate('/settings')}>{t("模型设置")}</Btn></div>}
|
||||
{error && <div className="ac-empty"><b>{t("成片草稿加载失败")}</b>{t(error)}<Btn onClick={refresh}>{t("重试")}</Btn></div>}
|
||||
<div className="ac-grid-3 studio-result-grid">{workspace.drafts.map(d => <DraftResultCard key={d.id} projectId={project.id} draft={d} jobs={workspace.jobs}
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface RenderJob {
|
||||
export interface Workspace {
|
||||
plan?: ImportPlan
|
||||
drafts: Draft[]; events: Scene[]; jobs: RenderJob[]
|
||||
analysis: null | { status: 'running' | 'awaiting_confirmation' | 'completed' | 'failed'; phase?: 'screening' | 'production'; message?: string; error?: string; coverage?: { duration?:number; note: string; sample_interval: number } }
|
||||
analysis: null | { status: 'running' | 'awaiting_confirmation' | 'completed' | 'failed'; phase?: 'screening' | 'production'; message?: string; percent?: number; error?: string; coverage?: { duration?:number; note: string; sample_interval: number } }
|
||||
}
|
||||
export const languages = [{ value: 'source', label: '原语言' }, { value: 'zh', label: '简体中文' }, { value: 'en', label: 'English' }, { value: 'ja', label: '日本語' }] as const
|
||||
export const emptyWorkspace: Workspace = { drafts: [], events: [], jobs: [], analysis: null }
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Windows 安装后冒烟:用安装目录里自带的 Python 跑,验证用户实际拿到的运行时。
|
||||
|
||||
不需要网络和模型 key。覆盖:
|
||||
- 后端能以桌面模式启动并通过 /health
|
||||
- 来源守卫:自家 Origin 放行,其他网页的写请求被拒
|
||||
- 内置 ffmpeg 能生成、切片、探测视频(Windows 上最常出问题的环节)
|
||||
- yt-dlp 能拿到内置 ffmpeg 的路径(链接导入合并音视频依赖它)
|
||||
|
||||
用法(CI 在 NSIS 静默安装后调用):
|
||||
"<安装目录>\\resources\\python\\python.exe" -B scripts\\verify_windows_install.py --resources "<安装目录>\\resources"
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
# CI 控制台默认 cp1252,打印中文会直接崩;桌面端由 Rust 注入 PYTHONUTF8,这里自己兜住
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--resources", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path)
|
||||
args = parser.parse_args()
|
||||
resources = args.resources.resolve(strict=True)
|
||||
if not Path(sys.executable).resolve().is_relative_to(resources / "python"):
|
||||
parser.error("必须用安装目录里的 python.exe 运行,而不是开发环境的 Python")
|
||||
|
||||
ffmpeg = resources / "ffmpeg" / "ffmpeg.exe"
|
||||
ffprobe = resources / "ffmpeg" / "ffprobe.exe"
|
||||
for required in (resources / "backend" / "desktop_main.py", ffmpeg, ffprobe):
|
||||
if not required.is_file():
|
||||
raise SystemExit(f"安装目录缺少文件: {required}")
|
||||
|
||||
root = Path(tempfile.mkdtemp(prefix="autoclip-win-smoke-"))
|
||||
os.environ.update(
|
||||
AUTOCLIP_APP_DIR=str(root / "data"), AUTOCLIP_DATA_DIR=str(root / "data"),
|
||||
AUTOCLIP_DESKTOP_MODE="true", AUTOCLIP_MODE="desktop",
|
||||
DATABASE_URL="sqlite:///" + str(root / "smoke.sqlite").replace("\\", "/"),
|
||||
SENTRY_DSN="", PYTHONUTF8="1", PYTHONIOENCODING="utf-8",
|
||||
AUTOCLIP_FFMPEG_PATH=str(ffmpeg), AUTOCLIP_FFPROBE_PATH=str(ffprobe),
|
||||
)
|
||||
(root / "data").mkdir()
|
||||
(root / "data" / "privacy.json").write_text('{"crash_reports":false,"analytics":false}', encoding="utf-8")
|
||||
os.chdir(resources)
|
||||
sys.path.insert(0, str(resources))
|
||||
report = {"python": sys.version.split()[0], "resources": str(resources)}
|
||||
|
||||
|
||||
def step(name):
|
||||
print(f"==> {name}", flush=True)
|
||||
|
||||
|
||||
step("内置 ffmpeg 生成、切片、探测")
|
||||
source = root / "fixture.mp4"
|
||||
subprocess.run([str(ffmpeg), "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=640x360:rate=30:duration=3",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=3",
|
||||
"-c:v", "libx264", "-c:a", "aac", "-shortest", str(source)], check=True, timeout=60)
|
||||
clip = root / "clip 中文 名.mp4" # 非 ASCII 文件名曾导致 500(#189)
|
||||
subprocess.run([str(ffmpeg), "-v", "error", "-ss", "1", "-i", str(source), "-t", "1", "-c", "copy", str(clip)],
|
||||
check=True, timeout=60)
|
||||
probe = json.loads(subprocess.run([str(ffprobe), "-v", "error", "-show_format", "-of", "json", str(clip)],
|
||||
check=True, capture_output=True, text=True, encoding="utf-8").stdout)
|
||||
assert 0.5 <= float(probe["format"]["duration"]) <= 1.6, probe
|
||||
report["ffmpeg_clip"] = "passed"
|
||||
|
||||
step("yt-dlp 拿到内置 ffmpeg")
|
||||
from backend.utils.ffmpeg_utils import ytdlp_ffmpeg_options # noqa: E402
|
||||
opts = ytdlp_ffmpeg_options()
|
||||
assert Path(opts.get("ffmpeg_location", "")).resolve() == ffmpeg.resolve(), opts
|
||||
report["ytdlp_ffmpeg"] = "passed"
|
||||
|
||||
step("桌面后端启动")
|
||||
proc = subprocess.Popen([sys.executable, "-B", "-m", "backend.desktop_main"], cwd=resources, env=os.environ.copy(),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8",
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
lines, port = [], {}
|
||||
|
||||
|
||||
def pump():
|
||||
for line in proc.stdout:
|
||||
lines.append(line.rstrip())
|
||||
if line.startswith("PORT=") and "port" not in port:
|
||||
port["port"] = int(line.split("=", 1)[1])
|
||||
|
||||
|
||||
threading.Thread(target=pump, daemon=True).start()
|
||||
try:
|
||||
deadline = time.time() + 90
|
||||
while "port" not in port and time.time() < deadline and proc.poll() is None:
|
||||
time.sleep(0.5)
|
||||
if "port" not in port:
|
||||
print("\n".join(lines[-60:]))
|
||||
raise SystemExit("桌面后端没有在 90 秒内输出 PORT=")
|
||||
base = f"http://127.0.0.1:{port['port']}"
|
||||
with urlopen(base + "/health", timeout=10) as r:
|
||||
assert r.status == 200
|
||||
with urlopen(base + "/api/v1/projects/", timeout=15) as r:
|
||||
assert r.status == 200
|
||||
report["backend_health"] = "passed"
|
||||
|
||||
step("来源守卫")
|
||||
def post(origin):
|
||||
req = Request(base + "/api/v1/settings/privacy", method="PUT", data=b'{"crash_reports":false}',
|
||||
headers={"Content-Type": "application/json", "Origin": origin})
|
||||
try:
|
||||
with urlopen(req, timeout=10) as r:
|
||||
return r.status
|
||||
except HTTPError as e:
|
||||
return e.code
|
||||
assert post("http://tauri.localhost") != 403, "Windows 自家界面的 Origin 被拦了"
|
||||
assert post("https://evil.example") == 403, "其他网页的写请求没被拦"
|
||||
report["origin_guard"] = "passed"
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
if args.report:
|
||||
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
@@ -0,0 +1,63 @@
|
||||
# Windows 覆盖升级 + 安装后冒烟。desktop-build.yml 的 smoke-windows-x64 和 windows-install-smoke.yml 共用。
|
||||
#
|
||||
# pwsh scripts/windows_upgrade_smoke.ps1 -NewInstaller <本次构建的 setup.exe>
|
||||
#
|
||||
# 1. 静默安装上一个正式版(GitHub latest release,需要 GH_TOKEN)
|
||||
# 2. 用安装目录里的 python.exe 起一个进程占住 _asyncio.pyd,模拟旧版残留后端(#224)
|
||||
# 3. 静默覆盖安装新包:安装器必须结束残留进程,并写入新文件
|
||||
# 4. 用安装目录自带的 Python 跑 scripts/verify_windows_install.py
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$NewInstaller,
|
||||
[string]$Report = 'windows-smoke.json'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repo = $env:GITHUB_REPOSITORY
|
||||
|
||||
function Get-InstallDir {
|
||||
$key = Get-ChildItem HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall |
|
||||
Get-ItemProperty | Where-Object DisplayName -like 'AutoClip*' | Select-Object -First 1
|
||||
if (-not $key) { throw '注册表里找不到 AutoClip 安装记录' }
|
||||
# NSIS 写的 InstallLocation 带引号,例如 "C:\Users\<user>\AppData\Local\AutoClip Desktop"
|
||||
$dir = ($key.InstallLocation -replace '"', '').TrimEnd('\')
|
||||
if (-not $dir) { $dir = Split-Path ($key.UninstallString -replace '"', '') }
|
||||
if (-not (Test-Path (Join-Path $dir 'resources\python\python.exe'))) {
|
||||
throw "安装目录里找不到 resources\python\python.exe: $dir"
|
||||
}
|
||||
return @{ Dir = $dir; Version = $key.DisplayVersion }
|
||||
}
|
||||
|
||||
Write-Host '==> 安装上一个正式版'
|
||||
$prev = gh release view --repo $repo --json tagName -q .tagName
|
||||
gh release download $prev --repo $repo --pattern '*x64-setup.exe' --dir old --clobber
|
||||
$old = Get-ChildItem old\*x64-setup.exe | Select-Object -First 1
|
||||
$p = Start-Process $old.FullName -ArgumentList '/S' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "旧版 $prev 安装失败: $($p.ExitCode)" }
|
||||
$install = Get-InstallDir
|
||||
Write-Host "旧版 $prev 安装到 $($install.Dir)($($install.Version))"
|
||||
|
||||
Write-Host '==> 模拟旧版残留后端占住 _asyncio.pyd'
|
||||
$py = Join-Path $install.Dir 'resources\python\python.exe'
|
||||
$lock = Start-Process $py -ArgumentList '-c', '"import asyncio, time; time.sleep(900)"' -PassThru -WindowStyle Hidden
|
||||
Start-Sleep 3
|
||||
if ($lock.HasExited) { throw '模拟残留进程没起来' }
|
||||
Write-Host "残留 python.exe PID=$($lock.Id)"
|
||||
|
||||
Write-Host '==> 静默覆盖安装新包'
|
||||
$p = Start-Process (Resolve-Path $NewInstaller).Path -ArgumentList '/S' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "覆盖安装失败: $($p.ExitCode)" }
|
||||
if (Get-Process -Id $lock.Id -ErrorAction SilentlyContinue) {
|
||||
Stop-Process -Id $lock.Id -Force
|
||||
throw '安装器没有结束残留的 python.exe'
|
||||
}
|
||||
$install = Get-InstallDir
|
||||
$marker = Join-Path $install.Dir 'resources\backend\core\local_origin_guard.py'
|
||||
if (-not (Test-Path $marker)) { throw '覆盖安装后仍是旧文件(新版文件没写进去)' }
|
||||
Write-Host "从 $prev 覆盖升级到 $($install.Version) 成功,残留进程已被安装器结束"
|
||||
|
||||
Write-Host '==> 安装后运行时冒烟'
|
||||
$res = Join-Path $install.Dir 'resources'
|
||||
# 和桌面端启动后端时一样:强制 UTF-8,否则中文输出在 cp1252 控制台上直接报错
|
||||
$env:PYTHONUTF8 = '1'
|
||||
$env:PYTHONIOENCODING = 'utf-8'
|
||||
& (Join-Path $res 'python\python.exe') -B scripts\verify_windows_install.py --resources $res --report $Report
|
||||
if ($LASTEXITCODE -ne 0) { throw "安装后冒烟失败: $LASTEXITCODE" }
|
||||
@@ -12,7 +12,8 @@
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"installMode": "currentUser",
|
||||
"languages": ["SimpChinese", "English"],
|
||||
"displayLanguageSelector": true
|
||||
"displayLanguageSelector": true,
|
||||
"installerHooks": "windows/installer-hooks.nsh"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
; AutoClip NSIS 钩子(tauri.windows.conf.json → bundle.windows.nsis.installerHooks)
|
||||
;
|
||||
; 覆盖安装 / 卸载前,结束安装目录里残留的 python.exe、ffmpeg.exe、ffprobe.exe。
|
||||
; 1.4.0 及更早的版本没有 Job Object,主程序退出后后端进程树可能还活着,
|
||||
; 占着 resources\python\*.pyd,安装器就会报「无法打开要写入的文件 _asyncio.pyd」(#224)。
|
||||
; 新版的 Job Object 只对新版本运行时生效,执行升级的是用户手上的旧版,所以要在安装器里兜底。
|
||||
;
|
||||
; 只按可执行文件路径匹配安装目录,不会误杀用户自己的 Python / ffmpeg。
|
||||
; 安装目录通过环境变量传给 PowerShell,避免路径里的特殊字符被 PowerShell 解析。
|
||||
; NSIS 字符串用反引号包裹;$$ 在 NSIS 里是字面量 $。
|
||||
|
||||
!macro AUTOCLIP_KILL_BUNDLED_PROCESSES
|
||||
System::Call 'Kernel32::SetEnvironmentVariable(t "AUTOCLIP_INSTDIR", t "$INSTDIR")i'
|
||||
nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$root = [IO.Path]::GetFullPath($$env:AUTOCLIP_INSTDIR).TrimEnd([char]92) + [char]92; Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -and $$_.ExecutablePath.StartsWith($$root, [StringComparison]::OrdinalIgnoreCase) -and @('python.exe','pythonw.exe','ffmpeg.exe','ffprobe.exe') -contains $$_.Name.ToLower() } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"`
|
||||
Pop $0
|
||||
; 给系统一点时间释放文件句柄
|
||||
Sleep 800
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_PREINSTALL
|
||||
!insertmacro AUTOCLIP_KILL_BUNDLED_PROCESSES
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_PREUNINSTALL
|
||||
!insertmacro AUTOCLIP_KILL_BUNDLED_PROCESSES
|
||||
!macroend
|
||||
Reference in New Issue
Block a user