feat(desktop): bundle ffmpeg and run external tools without console windows (#1735)

* feat(cli): locate ffmpeg via ffmpeg_path / host / PATH and start child processes without console windows

- New `tools` module in both crates. `tools::command()` starts external
  programs with CREATE_NO_WINDOW on Windows; every ffmpeg, streamlink,
  yt-dlp, ytarchive and hook shell process now goes through it.
- ffmpeg is looked up in this order: the new optional `ffmpeg_path`
  setting, then `ServeOptions::ffmpeg` (for an ffmpeg shipped by the
  embedding host), then `ffmpeg` on PATH. Unset keeps today's behaviour.
- `ffmpeg_path` is global (not overridable per streamer) and visible in
  the redacted configuration.

* feat(api): GET /v1/tools reports ffmpeg availability, version and license

Read-only, allowed for every logged-in role. Returns whether the ffmpeg
that would be used runs, where it came from (config / bundled / path),
the first line of `ffmpeg -version` (cached per program; failures are
retried) and the license derived from its configure flags. Errors say
what to fix.

* feat(desktop): bundle ffmpeg in the Windows installer, same BtbN build as Docker

- desktop-publish.yml downloads the BtbN win64 GPL static zip of the tag
  and version the Dockerfile pins (read by .github/scripts/ffmpeg-version.sh),
  checks its SHA-256 and ships only ffmpeg.exe, LICENSE.txt and a README
  with the source links as bundle resources (tauri.ffmpeg.conf.json),
  installed under ffmpeg\.
- The shell passes resource_dir()/ffmpeg/ffmpeg.exe to ServeOptions::ffmpeg
  when the file exists. The ffmpeg_path setting still wins; builds without
  the overlay keep using PATH.

* ci(desktop): attach the FFmpeg corresponding source and download notes to releases

- New ffmpeg-source job: FFmpeg at the pinned commit, the BtbN build
  scripts at the pinned tag, and the source of every library those
  scripts build (fetched by their own download.sh at the commits pinned
  in scripts.d/), plus a README and SHA256SUMS. Uploaded on release
  events only; manual runs just list the files.
- On release events, append .github/desktop-release-notes.md to the
  release body once: setup.exe is the recommended download, and how to
  get past the browser warning and SmartScreen while it is unsigned.

* fix(cli): only use CREATE_NO_WINDOW when biliup has no console

`biliup server` in a Windows terminal keeps sharing its console with the
programs it starts, as before: Ctrl+C and their output still reach the
terminal. Only a process without a console window (the desktop app)
starts them with CREATE_NO_WINDOW. The choice is logged at startup and
covered by a Windows-only test that runs itself as a child with and
without a console.

* ci(desktop): link to the upstream FFmpeg source instead of uploading it to releases

Drop the ffmpeg-source job that packed about 2 GB of FFmpeg and dependency
source into every release. The installer's ffmpeg/README.txt and the release
notes now point to the FFmpeg commit and the BtbN/FFmpeg-Builds tree of the
bundled build (full commit hashes, resolved at build time), whose scripts.d/
pins the version and source URL of every library. The Docker image uses the
same BtbN tag, so the same links cover it.
This commit is contained in:
Noah authored and GitHub committed 2026-09-24 09:50:42 +00:00
1 parent 024254a288
commit e4fb9c01df
33 files changed
+815 -28

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
<!-- biliup-desktop-notes:desktop-publish.yml 在 Release 创建时把本段追加到正文末尾,其中的占位符由 workflow 替换 -->
## Windows 桌面版
- **推荐下载 `biliup_*_x64-setup.exe`**:双击安装,默认装在当前用户目录下,不需要管理员权限,也不需要另装 Python 或 FFmpeg。装在系统盘时,第一次打开会询问数据和录像放在哪里。`.msi` 适合批量部署。
- 安装包暂未签名,Windows 可能会拦一下:
- 浏览器提示「不常下载」「可能有害」时,在下载列表里点「…」→「保留」(Edge 还要再点「显示详细信息」→「仍然保留」)。
- 第一次运行出现蓝色的「Windows 已保护你的电脑」(SmartScreen)时,点「更多信息」→「仍要运行」。
- 安装包内置 FFmpeg `{{FFMPEG_VERSION}}`(BtbN/FFmpeg-Builds `{{FFMPEG_TAG}}` 的 win64 GPL 静态构建),在安装目录的 `ffmpeg\` 下,按 GPLv3 分发,许可文本见同目录 `LICENSE.txt`。Docker 镜像用的是同一 tag 的 linux64 / linuxarm64 构建。对应源码由上游托管:
- FFmpeg 源码:<{{FFMPEG_SOURCE_URL}}>
- BtbN 构建脚本与补丁:<{{FFMPEG_BUILD_SCRIPTS_URL}}>(`scripts.d/` 固定了各依赖库的版本与源码地址,`download.sh` 可一次拉取全部依赖源码)
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Prints the BtbN FFmpeg build that the Dockerfile pins, as KEY=value lines
# (for $GITHUB_ENV), so the desktop installer bundles the same build:
# FFMPEG_TAG BtbN release tag, e.g. autobuild-2026-08-31-13-27
# FFMPEG_VERSION FFmpeg version, e.g. n8.1.2-50-g1a748fe2cd
# FFMPEG_BRANCH FFmpeg release branch of the build, e.g. 8.1
# FFMPEG_COMMIT abbreviated FFmpeg commit, e.g. 1a748fe2cd
set -euo pipefail
dockerfile="${1:-Dockerfile}"
url=$(grep -o "https://github.com/BtbN/FFmpeg-Builds/releases/download/[^']*" "$dockerfile" | head -n 1 || true)
asset=$(grep -o "linux64-gpl-[0-9.]*\.tar\.xz" "$dockerfile" | head -n 1 || true)
url_re='/download/(autobuild-[^/]+)/ffmpeg-(.+-g([0-9a-f]+))-$'
asset_re='^linux64-gpl-([0-9.]+)\.tar\.xz$'
if [[ ! $url =~ $url_re ]]; then
echo "no BtbN FFmpeg URL found in $dockerfile" >&2
exit 1
fi
echo "FFMPEG_TAG=${BASH_REMATCH[1]}"
echo "FFMPEG_VERSION=${BASH_REMATCH[2]}"
echo "FFMPEG_COMMIT=${BASH_REMATCH[3]}"
if [[ ! $asset =~ $asset_re ]]; then
echo "no linux64 GPL asset found in $dockerfile" >&2
exit 1
fi
echo "FFMPEG_BRANCH=${BASH_REMATCH[1]}"
+75
View File
@@ -9,6 +9,11 @@ on:
permissions:
contents: read
env:
# BtbN win64 GPL 静态构建 zip 的 SHA-256。tag 与版本号取自 Dockerfile(与 Docker 镜像同一构建),
# 见 .github/scripts/ffmpeg-version.sh;Dockerfile 升级 FFmpeg 时同步更新这里。
FFMPEG_WIN64_SHA256: 273abb45f3f9f76c303e35ff39f5bb6c23c163ae65f6244a32b7d4a7f6cf0616
jobs:
build:
permissions:
@@ -24,6 +29,62 @@ jobs:
run: |
npm install
npm run build
# 只取 ffmpeg.exe 和许可文本,由 tauri.ffmpeg.conf.json 作为资源打进安装包(安装目录下的 ffmpeg\)。
# GPL 对应源码不由我们托管:README.txt 和 Release 说明链接到上游固定 commit 的 FFmpeg 源码
# 与 BtbN 构建脚本(scripts.d/ 固定了各依赖的版本与源码地址)。
- name: Download FFmpeg
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
eval "$(bash .github/scripts/ffmpeg-version.sh)"
# tag 可以被移动,链接用完整 commit
ffmpeg_sha=$(gh api "repos/FFmpeg/FFmpeg/commits/${FFMPEG_COMMIT}" --jq .sha)
btbn_sha=$(gh api "repos/BtbN/FFmpeg-Builds/commits/${FFMPEG_TAG}" --jq .sha)
[[ $ffmpeg_sha == "${FFMPEG_COMMIT}"* && ${#btbn_sha} == 40 ]] || {
echo "cannot resolve FFmpeg ${FFMPEG_COMMIT} / BtbN ${FFMPEG_TAG}: '${ffmpeg_sha}' '${btbn_sha}'" >&2
exit 1
}
{
echo "FFMPEG_TAG=${FFMPEG_TAG}"
echo "FFMPEG_VERSION=${FFMPEG_VERSION}"
echo "FFMPEG_SOURCE_URL=https://github.com/FFmpeg/FFmpeg/tree/${ffmpeg_sha}"
echo "FFMPEG_BUILD_SCRIPTS_URL=https://github.com/BtbN/FFmpeg-Builds/tree/${btbn_sha}"
} >> "$GITHUB_ENV"
asset="ffmpeg-${FFMPEG_VERSION}-win64-gpl-${FFMPEG_BRANCH}"
curl -fsSL --retry 3 -o ffmpeg.zip \
"https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_TAG}/${asset}.zip"
echo "${FFMPEG_WIN64_SHA256} ffmpeg.zip" | sha256sum -c -
dest=tauri-app/src-tauri/ffmpeg
mkdir -p "$dest"
unzip -j -o ffmpeg.zip "${asset}/bin/ffmpeg.exe" "${asset}/LICENSE.txt" -d "$dest"
cat > "$dest/README.txt" <<EOF
FFmpeg ${FFMPEG_VERSION} (win64, GPL, static), built by BtbN/FFmpeg-Builds ${FFMPEG_TAG}.
The biliup Docker image uses the linux64 / linuxarm64 builds of the same tag.
FFmpeg is licensed under the GNU General Public License version 3 or later
(see LICENSE.txt in this folder). biliup runs it as a separate program.
Corresponding source code, hosted upstream:
FFmpeg source at commit ${ffmpeg_sha}:
https://github.com/FFmpeg/FFmpeg/tree/${ffmpeg_sha}
Build scripts and patches, BtbN/FFmpeg-Builds ${FFMPEG_TAG} (commit ${btbn_sha}):
https://github.com/BtbN/FFmpeg-Builds/tree/${btbn_sha}
scripts.d/ pins the version and source URL of every library FFmpeg is
built with; download.sh in the same tree fetches all of them.
EOF
ls -l "$dest"
rm ffmpeg.zip
- name: Render download notes
shell: bash
run: |
sed -e "s|{{FFMPEG_VERSION}}|${FFMPEG_VERSION}|g" \
-e "s|{{FFMPEG_TAG}}|${FFMPEG_TAG}|g" \
-e "s|{{FFMPEG_SOURCE_URL}}|${FFMPEG_SOURCE_URL}|g" \
-e "s|{{FFMPEG_BUILD_SCRIPTS_URL}}|${FFMPEG_BUILD_SCRIPTS_URL}|g" \
.github/desktop-release-notes.md > desktop-notes.md
if grep -n '{{' desktop-notes.md; then exit 1; fi
cat desktop-notes.md
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
@@ -41,11 +102,25 @@ jobs:
# 用 releaseId 而不是 tagName:v1 找到已发布的 Release 时会用 releaseName/releaseBody 覆盖它的标题和正文
releaseId: ${{ github.event.release.id }}
projectPath: tauri-app
args: --config src-tauri/tauri.ffmpeg.conf.json
- name: Add download notes to the release
if: github.event_name == 'release'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
run: |
body=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json body --jq .body)
if ! grep -q 'biliup-desktop-notes' <<<"$body"; then
printf '%s\n\n%s\n' "$body" "$(cat desktop-notes.md)" > notes.md
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --notes-file notes.md
fi
- name: Build
if: github.event_name != 'release'
uses: tauri-apps/tauri-action@v1
with:
projectPath: tauri-app
args: --config src-tauri/tauri.ffmpeg.conf.json
- uses: actions/upload-artifact@v7
if: github.event_name != 'release'
with:
+2
View File
@@ -82,6 +82,8 @@ RUN set -eux; \
# latest 是滚动 tag,资产每日重建,既不可复现也无法防篡改。
# 只能固定到「每月最后一天」的 autobuild:BtbN 长期保留月末构建,
# 其余每日构建约两周后删除,固定到它们会让镜像构建 404。
# Windows 桌面版打包同一构建的 win64 版:.github/scripts/ffmpeg-version.sh 从下面这行读取
# tag 与版本号;改版本时同步更新 desktop-publish.yml 里的 FFMPEG_WIN64_SHA256。
url='https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-31-13-27/ffmpeg-n8.1.2-50-g1a748fe2cd-'; \
case "$arch" in \
'amd64') \
+5
View File
@@ -227,6 +227,7 @@ pub async fn dispatch(cli: Cli, log_handle: LogHandle) -> AppResult<()> {
log_handle,
listener: None,
shutdown: None,
ffmpeg: None,
})
.await?
}
@@ -273,6 +274,9 @@ pub struct ServeOptions {
/// Stop the server when this future completes. When set, the server does
/// not install its own Ctrl+C / SIGTERM handlers; the host owns signals.
pub shutdown: Option<BoxFuture<'static, ()>>,
/// An ffmpeg shipped with the host (the desktop app passes the one in its
/// installer). Used when the `ffmpeg_path` setting is empty, before `PATH`.
pub ffmpeg: Option<PathBuf>,
}
/// Runs the Web server until shutdown. It only needs a tokio runtime, so an
@@ -285,6 +289,7 @@ pub async fn serve(opts: ServeOptions) -> AppResult<()> {
.change_context(AppError::Unknown)
.attach_with(|| format!("could not switch to work dir {}", dir.display()))?;
}
crate::tools::set_bundled_ffmpeg(opts.ffmpeg);
let listener = match opts.listener {
Some(listener) => listener
.set_nonblocking(true)
+8
View File
@@ -3,6 +3,7 @@ pub mod downloader;
pub mod entry;
pub mod season_cli;
pub mod server;
pub mod tools;
pub mod upload_lock;
pub mod uploader;
pub mod web_user_cli;
@@ -129,6 +130,13 @@ pub async fn serve_on(
} else {
repositories::get_config(&conn_pool).await?
};
tools::set_configured_ffmpeg(loaded_config.ffmpeg_path.as_deref());
if cfg!(windows) {
tracing::info!(
create_no_window = biliup::tools::hides_console_windows(),
"child processes get CREATE_NO_WINDOW only when this process has no console"
);
}
let config = Arc::new(RwLock::new(loaded_config));
let download_manager = DownloadManager::new(
@@ -208,6 +208,7 @@ mod tests {
("POST", "/v1/login_by_qrcode"),
("GET", "/v1/videos"),
("GET", "/v1/status"),
("GET", "/v1/tools"),
("POST", "/v1/uploads"),
("GET", "/static/ds_update.log"),
("GET", "/static/a.flv"),
@@ -241,6 +242,7 @@ mod tests {
| ("GET", "/v1/upload/streamers/1")
| ("GET", "/v1/videos")
| ("GET", "/v1/status")
| ("GET", "/v1/tools")
| ("GET", "/static/ds_update.log")
| ("GET", "/static/a.flv")
| ("GET", "/v1/ws/logs") => view,
@@ -294,6 +296,7 @@ mod tests {
.route("/v1/login_by_qrcode", post(|| async { StatusCode::OK }))
.route("/v1/videos", ok())
.route("/v1/status", ok())
.route("/v1/tools", ok())
.route("/v1/uploads", post(|| async { StatusCode::OK }))
.route("/static/{path}", ok())
.route("/v1/ws/logs", ok())
@@ -365,6 +365,7 @@ pub async fn put_configuration(
saved_config
.validate_segment_limits()
.map_err(report_to_response)?;
crate::tools::set_configured_ffmpeg(saved_config.ffmpeg_path.as_deref());
*config.write().unwrap() = saved_config;
let guard = config.read().unwrap();
if let Some(loggers_level) = &guard.loggers_level {
@@ -717,6 +718,11 @@ pub async fn get_videos() -> Result<Json<Vec<serde_json::Value>>, Response> {
Ok(Json(file_list))
}
/// 外部工具是否可用(目前只有 ffmpeg),前端据此决定依赖 ffmpeg 的功能能否使用。
pub async fn get_tools() -> Json<serde_json::Value> {
Json(json!({ "ffmpeg": crate::tools::ffmpeg_status().await }))
}
// #[axum::debug_handler(state = ServiceRegister)]
pub async fn get_status(
caller: Caller,
@@ -9,6 +9,7 @@ use serde_json::Value;
const VISIBLE_CONFIG_KEYS: &[&str] = &[
"downloader",
"sync_save_dir",
"ffmpeg_path",
"file_size",
"segment_time",
"filtering_threshold",
+6
View File
@@ -31,6 +31,12 @@ pub struct Config {
#[serde(default)]
pub sync_save_dir: Option<String>,
/// ffmpeg 可执行文件路径。不填时先用桌面版安装包自带的,再到 PATH 里找 `ffmpeg`。
/// 全局生效,主播覆写里不能改。
#[patch(skip)]
#[serde(default)]
pub ffmpeg_path: Option<String>,
/// 文件大小限制(字节)
///
/// 主播覆写里这个字段的 `null` 是“显式清除、按主播关闭大小分段”,与其它字段不同。
@@ -5,13 +5,13 @@ use crate::server::core::downloader::{
DownloadConfig, DownloadStatus, DownloaderType, SegmentEvent, SegmentInfo,
};
use crate::server::errors::{AppError, AppResult};
use crate::tools;
use biliup::downloader::util::ByteCounter;
use error_stack::{ResultExt, bail};
use std::path::PathBuf;
use std::process::{ExitStatus, Stdio};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::RwLock;
use tracing::{debug, info};
@@ -209,7 +209,7 @@ impl FfmpegDownloader {
let output_file = download_config.generate_output_filename(&download_config.suffix);
let part_file = format!("{}.part", output_file.display());
let mut cmd = Command::new("ffmpeg");
let mut cmd = tools::ffmpeg_command();
cmd.args(&args)
.arg(&part_file)
.stdin(Stdio::null())
@@ -258,7 +258,7 @@ impl FfmpegDownloader {
) -> AppResult<DownloadStatus> {
let args = self.build_ffmpeg_args_internal_segment(&download_config);
let mut cmd = Command::new("ffmpeg");
let mut cmd = tools::ffmpeg_command();
cmd.args(&args)
.arg(format!(
"{}.{}.part",
@@ -2,13 +2,14 @@ use crate::server::common::throughput::SubprocessProgress;
use crate::server::common::util::redact_process_debug;
use crate::server::core::downloader::{DownloadConfig, DownloadStatus, SegmentEvent, SegmentInfo};
use crate::server::errors::{AppError, AppResult};
use crate::tools;
use biliup::downloader::util::ByteCounter;
use error_stack::ResultExt;
use std::collections::HashMap;
use std::process::{ExitStatus, Stdio};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, ChildStdout, Command};
use tokio::process::{Child, ChildStdout};
use tokio::sync::RwLock;
use tokio::time::Duration;
use tracing::{debug, info};
@@ -63,7 +64,7 @@ impl Streamlink {
.streamlink_downloader
.build_file_args(&download_config, &part_file)?;
let mut cmd = Command::new("streamlink");
let mut cmd = tools::command("streamlink");
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
@@ -197,7 +198,7 @@ impl StreamlinkDownloader {
/// 启动streamlink进程
pub fn start(&mut self) -> AppResult<StreamOutput> {
let mut cmd = Command::new("streamlink");
let mut cmd = tools::command("streamlink");
cmd.args(self.build_base_args()?);
@@ -1,6 +1,7 @@
use crate::server::core::downloader::DownloadConfig;
use crate::server::common::util::redact_process_debug;
use crate::server::errors::{AppError, AppResult};
use crate::tools;
use bytes::Bytes;
use error_stack::ResultExt;
use std::collections::HashMap;
@@ -9,7 +10,7 @@ use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdout, Command};
use tokio::process::{Child, ChildStdout};
use tokio::sync::RwLock;
use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
@@ -120,7 +121,7 @@ impl SyncDownloader {
pipe_input: bool,
) -> AppResult<Child> {
let args = build_ffmpeg_args(download_config, max_file_size, pipe_input);
let mut cmd = Command::new("ffmpeg");
let mut cmd = tools::ffmpeg_command();
cmd.args(&args)
.stdin(if pipe_input {
Stdio::piped()
@@ -132,7 +133,8 @@ impl SyncDownloader {
.kill_on_drop(true);
info!(cmd = %redact_process_debug(&cmd), "Starting sync-downloader ffmpeg");
cmd.spawn().change_context(AppError::Custom(
"未安装 FFmpeg 或不在 PATH 中,边录边传无法启动".into(),
"找不到 FFmpeg,边录边传无法启动:请安装 FFmpeg 并加入 PATH,或在配置里填写 ffmpeg_path"
.into(),
))
}
@@ -142,7 +144,7 @@ impl SyncDownloader {
max_file_size: u64,
) -> AppResult<Child> {
let sl_args = build_streamlink_args(download_config);
let mut sl_cmd = Command::new("streamlink");
let mut sl_cmd = tools::command("streamlink");
sl_cmd
.args(&sl_args)
.stdin(Stdio::null())
@@ -397,7 +399,7 @@ fn format_ffmpeg_headers(headers: &HashMap<String, String>) -> String {
}
fn command_exists(name: &str, arg: &str) -> bool {
std::process::Command::new(name)
tools::std_command(name)
.arg(arg)
.stdout(Stdio::null())
.stderr(Stdio::null())
@@ -3,6 +3,7 @@ use crate::server::core::downloader::{
};
use crate::server::common::util::redact_process_debug;
use crate::server::errors::{AppError, AppResult};
use crate::tools;
use error_stack::{ResultExt, bail};
use std::{
path::{Path, PathBuf},
@@ -12,7 +13,7 @@ use std::{
},
time::Duration,
};
use tokio::{fs, process::Command, time::timeout};
use tokio::{fs, time::timeout};
use tracing::{debug, info, warn};
#[derive(Clone, Debug)]
@@ -214,7 +215,7 @@ impl YouTubeDownloader {
"best".to_string()
};
let mut cmd = Command::new(&self.cfg.ytdlp_bin);
let mut cmd = tools::command(&self.cfg.ytdlp_bin);
cmd.arg("--outtmpl")
.arg(format!(
"{}/{}.%(ext)s",
@@ -321,7 +322,7 @@ impl YouTubeDownloader {
)))?;
// 在缓存目录中执行 ytarchive
let mut cmd = Command::new(&self.cfg.ytarchive_bin);
let mut cmd = tools::command(&self.cfg.ytarchive_bin);
cmd.current_dir(&cache_dir)
.arg(&self.cfg.webpage_url)
.arg("best")
@@ -1,11 +1,11 @@
use crate::server::errors::{AppError, AppResult};
use crate::tools;
use error_stack::{ResultExt, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::fs;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::process::Command;
use tracing::{error, info};
/// 钩子步骤枚举:支持多种操作格式
@@ -121,7 +121,7 @@ impl HookStep {
async fn ffmpeg_remux_to_mp4(src: &Path, dst: &Path) -> AppResult<()> {
info!("remux ts→mp4: {} → {}", src.display(), dst.display());
let started = std::time::Instant::now();
let status = Command::new("ffmpeg")
let status = tools::ffmpeg_command()
.args([
"-hide_banner",
"-loglevel",
@@ -214,7 +214,7 @@ impl HookStep {
// 执行自定义命令
// 解析命令和参数
// 启动子进程,配置标准输入管道
let mut process = Command::new(shell)
let mut process = tools::command(shell)
.arg(flag)
.arg(cmd)
.stdin(std::process::Stdio::piped())
@@ -161,6 +161,8 @@ pub fn required_permission(method: &Method, route: &str, raw_path: &str) -> Opti
"/v1/uploads" if method == Method::POST => UploadSubmit,
"/v1/videos" if get => FileView,
"/v1/status" if get => StreamerView,
// 三个角色都有 StreamerView,等于登录即可
"/v1/tools" if get => StreamerView,
"/v1/ws/logs" if get => LogView,
"/static/{path}" if get => {
let name = raw_path.rsplit('/').next().unwrap_or_default();
+2 -1
View File
@@ -4,7 +4,7 @@ use crate::server::api::bilibili_endpoints::{
use crate::server::api::endpoints::{
add_upload_streamer_endpoint, add_user_endpoint, delete_streamers_endpoint,
delete_template_endpoint, delete_user_endpoint, get_configuration, get_qrcode, get_status,
get_streamer_info, get_streamer_info_files, get_streamers_endpoint,
get_streamer_info, get_streamer_info_files, get_streamers_endpoint, get_tools,
get_upload_streamer_endpoint, get_upload_streamers_endpoint, get_users_endpoint, get_videos,
login_by_qrcode, pause_streamers_endpoint, post_streamers_endpoint, post_uploads,
put_configuration, put_streamers_endpoint,
@@ -83,6 +83,7 @@ pub fn router(service_register: ServiceRegister) -> Router<()> {
// 视频文件管理路由
.route("/v1/videos", get(get_videos)) // 获取视频列表
.route("/v1/status", get(get_status))
.route("/v1/tools", get(get_tools)) // ffmpeg 是否可用、版本与许可
.route("/v1/uploads", post(post_uploads))
.route("/static/{path}", get(using_serve_file_from_a_route))
.with_state(service_register) // 注入服务注册器状态
+363
View File
@@ -0,0 +1,363 @@
//! External programs: where ffmpeg is, and how child processes are started.
//!
//! ffmpeg is looked up in this order: the `ffmpeg_path` setting, then the one
//! the embedding host ships (the desktop app passes the ffmpeg bundled in its
//! installer through `ServeOptions::ffmpeg`), then `ffmpeg` on `PATH`.
use serde::Serialize;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Mutex, PoisonError, RwLock};
use std::time::Duration;
pub use biliup::tools::{command, std_command};
const FFMPEG: &str = "ffmpeg";
const VERSION_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FfmpegSource {
/// The `ffmpeg_path` setting
Config,
/// Shipped with the embedding host (the desktop installer)
Bundled,
/// `ffmpeg` on `PATH`
Path,
}
struct Locations {
configured: Option<PathBuf>,
bundled: Option<PathBuf>,
}
static LOCATIONS: RwLock<Locations> = RwLock::new(Locations {
configured: None,
bundled: None,
});
/// Successful `ffmpeg -version` results, keyed by the program that was run.
static VERSION_CACHE: Mutex<Option<(PathBuf, FfmpegVersion)>> = Mutex::new(None);
/// Applies the `ffmpeg_path` setting; blank means unset.
pub fn set_configured_ffmpeg(path: Option<&str>) {
let path = path
.map(str::trim)
.filter(|p| !p.is_empty())
.map(PathBuf::from);
LOCATIONS
.write()
.unwrap_or_else(PoisonError::into_inner)
.configured = path;
}
/// Sets the ffmpeg shipped with the embedding host.
pub fn set_bundled_ffmpeg(path: Option<PathBuf>) {
LOCATIONS
.write()
.unwrap_or_else(PoisonError::into_inner)
.bundled = path;
}
/// The ffmpeg program to run and where it came from.
pub fn locate_ffmpeg() -> (PathBuf, FfmpegSource) {
let locations = LOCATIONS.read().unwrap_or_else(PoisonError::into_inner);
resolve(
locations.configured.as_deref(),
locations.bundled.as_deref(),
)
}
/// The ffmpeg program to run.
pub fn ffmpeg() -> PathBuf {
locate_ffmpeg().0
}
/// A [`command`] that runs ffmpeg.
pub fn ffmpeg_command() -> tokio::process::Command {
command(ffmpeg())
}
fn resolve(configured: Option<&Path>, bundled: Option<&Path>) -> (PathBuf, FfmpegSource) {
if let Some(path) = configured {
(path.to_path_buf(), FfmpegSource::Config)
} else if let Some(path) = bundled {
(path.to_path_buf(), FfmpegSource::Bundled)
} else {
(PathBuf::from(FFMPEG), FfmpegSource::Path)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FfmpegVersion {
/// First line of `ffmpeg -version`
pub version: String,
/// SPDX id of the license the build is under, derived from its configure flags
pub license: Option<&'static str>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FfmpegStatus {
pub available: bool,
pub source: FfmpegSource,
/// The program that is run; `None` when it is not found on `PATH`
pub path: Option<PathBuf>,
pub version: Option<String>,
pub license: Option<&'static str>,
pub error: Option<String>,
}
/// Runs `ffmpeg -version` on the ffmpeg that would be used. Successful results
/// are cached per program, failures are retried on the next call.
pub async fn ffmpeg_status() -> FfmpegStatus {
let (program, source) = locate_ffmpeg();
let path = match source {
FfmpegSource::Path => find_in_path(FFMPEG, std::env::var_os("PATH").as_deref()),
_ => Some(program.clone()),
};
let cached = VERSION_CACHE
.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
.filter(|(cached, _)| *cached == program)
.map(|(_, version)| version.clone());
let result = match cached {
Some(version) => Ok(version),
None => probe_version(&program, source).await.inspect(|version| {
tracing::info!(
program = %program.display(),
?source,
version = %version.version,
"ffmpeg -version succeeded"
);
*VERSION_CACHE.lock().unwrap_or_else(PoisonError::into_inner) =
Some((program.clone(), version.clone()));
}),
};
match result {
Ok(version) => FfmpegStatus {
available: true,
source,
path,
version: Some(version.version),
license: version.license,
error: None,
},
Err(error) => FfmpegStatus {
available: false,
source,
path,
version: None,
license: None,
error: Some(error),
},
}
}
async fn probe_version(program: &Path, source: FfmpegSource) -> Result<FfmpegVersion, String> {
let output = command(program)
.arg("-version")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.output();
let output = match tokio::time::timeout(VERSION_TIMEOUT, output).await {
Ok(Ok(output)) => output,
Ok(Err(err)) if err.kind() == std::io::ErrorKind::NotFound => {
let hint = match source {
FfmpegSource::Config => "请检查配置里的 ffmpeg_path",
_ => "请安装 FFmpeg 并加入 PATH,或在配置里填写 ffmpeg_path",
};
return Err(format!("找不到 {}:{hint}", program.display()));
}
Ok(Err(err)) => return Err(format!("无法运行 {}:{err}", program.display())),
Err(_) => {
return Err(format!(
"{} -version 在 {} 秒内没有返回",
program.display(),
VERSION_TIMEOUT.as_secs()
));
}
};
if !output.status.success() {
return Err(format!(
"{} -version 退出码 {}",
program.display(),
output.status
));
}
parse_version_output(&String::from_utf8_lossy(&output.stdout))
.ok_or_else(|| format!("{} -version 的输出无法识别", program.display()))
}
fn parse_version_output(stdout: &str) -> Option<FfmpegVersion> {
let version = stdout.lines().next()?.trim();
if !version.starts_with("ffmpeg version") {
return None;
}
let license = stdout
.lines()
.find_map(|line| line.trim().strip_prefix("configuration:"))
.map(license_from_configuration);
Some(FfmpegVersion {
version: version.to_string(),
license,
})
}
/// Same rules as FFmpeg's `configure` uses to pick the license it reports.
fn license_from_configuration(configuration: &str) -> &'static str {
let enabled = |flag: &str| configuration.split_whitespace().any(|f| f == flag);
match (
enabled("--enable-nonfree"),
enabled("--enable-gpl"),
enabled("--enable-version3"),
) {
(true, _, _) => "LicenseRef-nonfree",
(false, true, true) => "GPL-3.0-or-later",
(false, true, false) => "GPL-2.0-or-later",
(false, false, true) => "LGPL-3.0-or-later",
(false, false, false) => "LGPL-2.1-or-later",
}
}
/// Where the OS would find `program` on `path_var`, for display only.
fn find_in_path(program: &str, path_var: Option<&OsStr>) -> Option<PathBuf> {
let names: Vec<String> = if cfg!(windows) {
vec![format!("{program}.exe"), program.to_string()]
} else {
vec![program.to_string()]
};
std::env::split_paths(path_var?)
.flat_map(|dir| names.iter().map(move |name| dir.join(name)))
.find(|candidate| candidate.is_file())
}
#[cfg(test)]
mod tests {
use super::*;
const BTBN_WIN64: &str = "ffmpeg version n8.1.2-50-g1a748fe2cd-20260831 Copyright (c) 2000-2026 the FFmpeg developers\n\
built with gcc 15.2.0 (crosstool-NG 1.28.0.21_3c5cc17)\n\
configuration: --prefix=/ffbuild/prefix --pkg-config-flags=--static --enable-gpl --enable-version3 --disable-debug --enable-libx264 --extra-version=20260831\n\
libavutil 60. 26.100 / 60. 26.100\n";
#[test]
fn setting_wins_over_bundled_and_path() {
let configured = Path::new("/opt/ffmpeg/bin/ffmpeg");
let bundled = Path::new("C:/biliup/ffmpeg/ffmpeg.exe");
assert_eq!(
resolve(Some(configured), Some(bundled)),
(configured.to_path_buf(), FfmpegSource::Config)
);
assert_eq!(
resolve(None, Some(bundled)),
(bundled.to_path_buf(), FfmpegSource::Bundled)
);
assert_eq!(
resolve(None, None),
(PathBuf::from("ffmpeg"), FfmpegSource::Path)
);
}
#[test]
fn version_line_and_license_are_read_from_the_output() {
assert_eq!(
parse_version_output(BTBN_WIN64),
Some(FfmpegVersion {
version: "ffmpeg version n8.1.2-50-g1a748fe2cd-20260831 Copyright (c) 2000-2026 the FFmpeg developers".into(),
license: Some("GPL-3.0-or-later"),
})
);
assert_eq!(parse_version_output("not ffmpeg\n"), None);
assert_eq!(parse_version_output(""), None);
}
#[test]
fn license_follows_configure() {
assert_eq!(
license_from_configuration("--enable-gpl --enable-libx264"),
"GPL-2.0-or-later"
);
assert_eq!(
license_from_configuration("--enable-version3"),
"LGPL-3.0-or-later"
);
assert_eq!(
license_from_configuration("--prefix=/usr"),
"LGPL-2.1-or-later"
);
assert_eq!(
license_from_configuration("--enable-gpl --enable-nonfree"),
"LicenseRef-nonfree"
);
// A flag that merely starts with --enable-gpl is not --enable-gpl.
assert_eq!(
license_from_configuration("--enable-gpl-foo"),
"LGPL-2.1-or-later"
);
}
#[test]
fn path_lookup_finds_the_first_match() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let name = if cfg!(windows) {
"ffmpeg.exe"
} else {
"ffmpeg"
};
std::fs::write(second.path().join(name), b"").unwrap();
let path_var = std::env::join_paths([first.path(), second.path()]).unwrap();
assert_eq!(
find_in_path("ffmpeg", Some(&path_var)),
Some(second.path().join(name))
);
assert_eq!(find_in_path("ffmpeg", Some(OsStr::new(""))), None);
assert_eq!(find_in_path("ffmpeg", None), None);
}
#[cfg(unix)]
#[tokio::test]
async fn probe_runs_the_program() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("ffmpeg");
std::fs::write(
&fake,
"#!/bin/sh\n[ \"$1\" = -version ] || exit 2\nprintf 'ffmpeg version 7.0-test\\nconfiguration: --enable-gpl\\n'\n",
)
.unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(
probe_version(&fake, FfmpegSource::Config).await,
Ok(FfmpegVersion {
version: "ffmpeg version 7.0-test".into(),
license: Some("GPL-2.0-or-later"),
})
);
let missing = dir.path().join("missing");
let err = probe_version(&missing, FfmpegSource::Config)
.await
.unwrap_err();
assert!(
err.contains("找不到") && err.contains("请检查配置"),
"{err}"
);
let err = probe_version(Path::new("ffmpeg-not-on-path"), FfmpegSource::Path)
.await
.unwrap_err();
assert!(err.contains("PATH"), "{err}");
let failing = dir.path().join("failing");
std::fs::write(&failing, "#!/bin/sh\nexit 3\n").unwrap();
std::fs::set_permissions(&failing, std::fs::Permissions::from_mode(0o755)).unwrap();
let err = probe_version(&failing, FfmpegSource::Config)
.await
.unwrap_err();
assert!(err.contains("退出码"), "{err}");
}
}
@@ -42,6 +42,7 @@ async fn serves_on_prebound_listener_until_host_shutdown() {
shutdown: Some(Box::pin(async move {
let _ = stopped.await;
})),
ffmpeg: None,
}));
let mut status = None;
+126
View File
@@ -0,0 +1,126 @@
//! `GET /v1/tools` follows where ffmpeg comes from: the `ffmpeg_path` setting,
//! then the host-provided one, then `PATH`. Kept in its own test binary because
//! `work_dir` switches the working directory of the whole process.
#![cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use biliup_cli::entry::{ServeOptions, init_tracing_with, serve};
use serde_json::Value;
fn fake_ffmpeg(dir: &Path, version: &str) -> PathBuf {
std::fs::create_dir_all(dir).unwrap();
let path = dir.join("ffmpeg");
std::fs::write(
&path,
format!(
"#!/bin/sh\nprintf 'ffmpeg version {version}\\nconfiguration: --enable-gpl --enable-version3\\n'\n"
),
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
async fn get_json(client: &reqwest::Client, url: &str) -> Value {
let response = client.get(url).send().await.unwrap();
assert!(
response.status().is_success(),
"GET {url}: {}",
response.status()
);
serde_json::from_slice(&response.bytes().await.unwrap()).unwrap()
}
async fn set_ffmpeg_path(client: &reqwest::Client, base: &str, path: Value) {
let mut config = get_json(client, &format!("{base}/v1/configuration")).await;
config["ffmpeg_path"] = path;
let response = client
.put(format!("{base}/v1/configuration"))
.header("content-type", "application/json")
.body(serde_json::to_vec(&config).unwrap())
.send()
.await
.unwrap();
assert!(
response.status().is_success(),
"PUT /v1/configuration: {}",
response.status()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn tools_report_follows_setting_then_host_ffmpeg() {
let work_dir = tempfile::tempdir().unwrap();
let bundled = fake_ffmpeg(&work_dir.path().join("bundled"), "bundled-test");
let configured = fake_ffmpeg(&work_dir.path().join("configured"), "configured-test");
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (stop, stopped) = tokio::sync::oneshot::channel::<()>();
let logging = init_tracing_with("info", false);
let server = tokio::spawn(serve(ServeOptions {
bind: "127.0.0.1".into(),
port: 1,
auth: false,
secure_session_cookie: false,
config: None,
user_cookie: "cookies.json".into(),
work_dir: Some(work_dir.path().to_path_buf()),
log_handle: logging.handle(),
listener: Some(listener),
shutdown: Some(Box::pin(async move {
let _ = stopped.await;
})),
ffmpeg: Some(bundled.clone()),
}));
let client = reqwest::Client::new();
let mut ready = false;
for _ in 0..100 {
if client.get(format!("{base}/")).send().await.is_ok() {
ready = true;
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(ready, "server never answered");
let tools = format!("{base}/v1/tools");
let ffmpeg = get_json(&client, &tools).await["ffmpeg"].clone();
assert_eq!(ffmpeg["available"], true, "{ffmpeg}");
assert_eq!(ffmpeg["source"], "bundled");
assert_eq!(ffmpeg["path"], bundled.to_str().unwrap());
assert_eq!(ffmpeg["version"], "ffmpeg version bundled-test");
assert_eq!(ffmpeg["license"], "GPL-3.0-or-later");
set_ffmpeg_path(&client, &base, configured.to_str().unwrap().into()).await;
let ffmpeg = get_json(&client, &tools).await["ffmpeg"].clone();
assert_eq!(ffmpeg["source"], "config", "{ffmpeg}");
assert_eq!(ffmpeg["path"], configured.to_str().unwrap());
assert_eq!(ffmpeg["version"], "ffmpeg version configured-test");
let missing = work_dir.path().join("missing/ffmpeg");
set_ffmpeg_path(&client, &base, missing.to_str().unwrap().into()).await;
let ffmpeg = get_json(&client, &tools).await["ffmpeg"].clone();
assert_eq!(ffmpeg["available"], false, "{ffmpeg}");
assert_eq!(ffmpeg["source"], "config");
assert!(
ffmpeg["error"].as_str().unwrap().contains("找不到"),
"{ffmpeg}"
);
set_ffmpeg_path(&client, &base, " ".into()).await;
let ffmpeg = get_json(&client, &tools).await["ffmpeg"].clone();
assert_eq!(ffmpeg["source"], "bundled", "{ffmpeg}");
assert_eq!(ffmpeg["version"], "ffmpeg version bundled-test");
stop.send(()).unwrap();
tokio::time::timeout(Duration::from_secs(10), server)
.await
.expect("server did not stop")
.unwrap()
.unwrap();
}
+3 -3
View File
@@ -2,13 +2,13 @@ use super::{
DownloaderHint, LiveError, LivePlugin, LiveRequest, LiveResult, LiveStatus, LiveStream,
RuntimeOptions, YtDlpBackend, YtDlpOptions, media_ext_from_url,
};
use crate::tools;
use async_trait::async_trait;
use chrono::Utc;
use regex::Regex;
use serde_json::Value;
use std::collections::HashMap;
use std::process::Stdio;
use tokio::process::Command;
pub struct General {
re: Regex,
@@ -128,7 +128,7 @@ impl GeneralLive {
}
async fn extract_info(&self, url: &str) -> LiveResult<Option<Value>> {
let output = Command::new("yt-dlp")
let output = tools::command("yt-dlp")
.stdin(Stdio::null())
.arg("--dump-single-json")
.arg("--skip-download")
@@ -151,7 +151,7 @@ impl GeneralLive {
}
async fn streamlink_url(&self) -> Option<String> {
let output = Command::new("streamlink")
let output = tools::command("streamlink")
.stdin(Stdio::null())
.arg("--stream-url")
.arg(&self.url)
@@ -2,6 +2,7 @@ use super::{
DownloaderHint, LiveError, LivePlugin, LiveRequest, LiveResult, LiveStatus, LiveStream,
RuntimeOptions, StreamlinkOptions, StreamlinkPlatform,
};
use crate::tools;
use async_trait::async_trait;
use chrono::Utc;
use regex::Regex;
@@ -100,7 +101,7 @@ impl NiconicoLive {
}
async fn streamlink_available(&self) -> LiveResult<bool> {
let mut command = Command::new("streamlink");
let mut command = tools::command("streamlink");
command.stdin(Stdio::null()).arg("--stream-url");
self.apply_streamlink_args(&mut command);
let output = command
+2 -2
View File
@@ -3,6 +3,7 @@ use super::{
LiveResult, LiveStatus, LiveStream, RuntimeOptions, StreamlinkOptions, StreamlinkPlatform,
YtDlpOptions, media_ext_from_url,
};
use crate::tools;
use async_trait::async_trait;
use chrono::Utc;
use regex::Regex;
@@ -18,7 +19,6 @@ use std::process::Stdio;
use std::sync::{OnceLock, RwLock};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::sync::Mutex;
use tokio::time::Duration;
use tracing::warn;
@@ -423,7 +423,7 @@ impl TwitchVideosLive {
Some(token) => write_cookie_file(token).await,
None => None,
};
let mut command = Command::new("yt-dlp");
let mut command = tools::command("yt-dlp");
command
.stdin(Stdio::null())
.arg("--dump-single-json")
+2 -2
View File
@@ -2,6 +2,7 @@ use super::{
DanmakuSource, DownloaderHint, LiveError, LivePlugin, LiveRequest, LiveResult, LiveStatus,
LiveStream, RuntimeOptions, YtDlpBackend, YtDlpOptions, media_ext_from_url,
};
use crate::tools;
use async_trait::async_trait;
use chrono::Utc;
use regex::Regex;
@@ -11,7 +12,6 @@ use std::future::Future;
use std::pin::Pin;
use std::process::Stdio;
use tokio::fs;
use tokio::process::Command;
pub struct Youtube {
re: Regex,
@@ -171,7 +171,7 @@ impl YoutubeLive {
}
async fn extract_info(&self, url: &str, flat: bool) -> LiveResult<Option<Value>> {
let mut command = Command::new("yt-dlp");
let mut command = tools::command("yt-dlp");
command
.stdin(Stdio::null())
.arg("--dump-single-json")
+1
View File
@@ -7,6 +7,7 @@ use tracing::info;
pub mod client;
pub mod downloader;
pub mod error;
pub mod tools;
pub mod uploader;
pub use uploader::bilibili;
+50
View File
@@ -0,0 +1,50 @@
//! Starting external programs (ffmpeg, streamlink, yt-dlp, hook commands).
use std::ffi::OsStr;
/// Without this, a host that has no console (the desktop app is a
/// GUI-subsystem program) gets a new console window for every console program
/// it starts. A host running in a terminal must not use it: its children would
/// get their own hidden console instead of sharing the terminal, so Ctrl+C and
/// their output would no longer reach it.
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
#[cfg(windows)]
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetConsoleWindow() -> *mut std::ffi::c_void;
}
/// Whether child processes are started with `CREATE_NO_WINDOW`: on Windows when
/// this process has no console window, never elsewhere.
pub fn hides_console_windows() -> bool {
#[cfg(windows)]
{
// SAFETY: takes no arguments and only reads this process's console state.
unsafe { GetConsoleWindow() }.is_null()
}
#[cfg(not(windows))]
{
false
}
}
/// [`std::process::Command::new`] that opens no console window on Windows when
/// this process has no console of its own.
pub fn std_command(program: impl AsRef<OsStr>) -> std::process::Command {
#[cfg_attr(not(windows), allow(unused_mut))]
let mut command = std::process::Command::new(program);
#[cfg(windows)]
if hides_console_windows() {
use std::os::windows::process::CommandExt;
command.creation_flags(CREATE_NO_WINDOW);
}
command
}
/// [`tokio::process::Command::new`] that opens no console window on Windows
/// when this process has no console of its own.
pub fn command(program: impl AsRef<OsStr>) -> tokio::process::Command {
tokio::process::Command::from(std_command(program))
}
+36
View File
@@ -0,0 +1,36 @@
//! Runs this test binary again as a child process with different consoles and
//! lets the child report what `hides_console_windows()` sees.
#![cfg(windows)]
use std::os::windows::process::CommandExt;
const PROBE: &str = "BILIUP_CONSOLE_PROBE";
const TEST: &str = "children_share_the_console_only_when_there_is_one";
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_CONSOLE: u32 = 0x0000_0010;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
// Distinct from the test harness's own exit codes.
const HIDES: i32 = 21;
const SHARES: i32 = 20;
#[test]
fn children_share_the_console_only_when_there_is_one() {
if std::env::var_os(PROBE).is_some() {
let hides = biliup::tools::hides_console_windows();
std::process::exit(if hides { HIDES } else { SHARES });
}
let run = |flags: u32| {
std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", TEST, "--test-threads=1"])
.env(PROBE, "1")
.creation_flags(flags)
.status()
.unwrap()
.code()
};
// Like the desktop app: no console at all, or a console without a window.
assert_eq!(run(DETACHED_PROCESS), Some(HIDES));
assert_eq!(run(CREATE_NO_WINDOW), Some(HIDES));
// Like `biliup server` in a terminal.
assert_eq!(run(CREATE_NEW_CONSOLE), Some(SHARES));
}
+1
View File
@@ -8,6 +8,7 @@ Tauri 壳:在本进程内启动 biliup 服务(`biliup_cli::entry::serve`)
- 其他平台:`app_data_dir()`,不询问(`desktop.json` 里写了 `data_dir` 则用它)。
- 所选目录还没有 `data/` 时,会把旧版留下的 `data/` 复制过去,原处保留。旧版的位置依次找:安装目录、Windows 上旧 `bbup-app` 安装程序记录的安装目录(注册表)和 `%LOCALAPPDATA%\bbup-app`。
- `static/index.html` 只是启动页,显示「正在启动」或启动失败的原因。
- ffmpeg:发布 workflow 下载与 Dockerfile 同一构建的 BtbN win64 GPL 静态版,把 `ffmpeg.exe`、`LICENSE.txt` 和说明放进 `src-tauri/ffmpeg/`,构建时用 `--config src-tauri/tauri.ffmpeg.conf.json` 作为资源打进安装包。程序启动时若安装目录下有 `ffmpeg/ffmpeg.exe` 就交给服务使用(配置里填了 `ffmpeg_path` 时以配置为准),否则到 PATH 里找。本地构建不加这个参数即可,不需要下载 ffmpeg。
构建(需要先在仓库根目录 `npm run build` 生成 `out/`,WebUI 会被编进程序):
+3
View File
@@ -5,3 +5,6 @@
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# ffmpeg downloaded by the release workflow (see tauri.ffmpeg.conf.json)
/ffmpeg/
+1
View File
@@ -666,6 +666,7 @@ name = "biliup-desktop"
version = "0.1.0"
dependencies = [
"biliup-cli",
"dunce",
"serde",
"serde_json",
"tauri",
+1
View File
@@ -21,6 +21,7 @@ tauri = { version = "2", features = [] }
tauri-plugin-single-instance = "2"
tauri-plugin-dialog = "2"
biliup-cli = { path = "../../crates/biliup-cli" }
dunce = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["io-util", "net", "sync", "time"] }
+46 -2
View File
@@ -26,6 +26,12 @@ const WINDOW_LABEL: &str = "main";
const STARTUP_PAGE: &str = "http://tauri.localhost/";
#[cfg(not(windows))]
const STARTUP_PAGE: &str = "tauri://localhost/";
/// Where the installer puts ffmpeg under the resource directory; the release
/// workflow adds it through `tauri.ffmpeg.conf.json`.
#[cfg(windows)]
const BUNDLED_FFMPEG: [&str; 2] = ["ffmpeg", "ffmpeg.exe"];
#[cfg(not(windows))]
const BUNDLED_FFMPEG: [&str; 2] = ["ffmpeg", "ffmpeg"];
struct Server {
stop: Mutex<Option<oneshot::Sender<()>>>,
@@ -58,7 +64,10 @@ pub fn run() {
};
let legacy_roots = data_dir::legacy_roots(&install_dir);
match data_dir::resolve(&app, &window, &install_dir, &legacy_roots) {
Ok(Some(data_dir)) => start(&app, &window, &data_dir, &legacy_roots),
Ok(Some(data_dir)) => {
let ffmpeg = bundled_ffmpeg(app.path().resource_dir().ok());
start(&app, &window, &data_dir, &legacy_roots, ffmpeg)
}
Ok(None) => app.exit(0),
Err(message) => show_error(&window, &message),
}
@@ -82,7 +91,13 @@ pub fn run() {
/// Moves into the data directory, migrates legacy data and starts the server.
/// Failures are shown in the window instead of aborting the app.
fn start(app: &AppHandle, window: &WebviewWindow, data_dir: &Path, legacy_roots: &[PathBuf]) {
fn start(
app: &AppHandle,
window: &WebviewWindow,
data_dir: &Path,
legacy_roots: &[PathBuf],
ffmpeg: Option<PathBuf>,
) {
// The server keeps data/, ds_update.log and recordings relative to the
// working directory, and the log file is opened relative to it as well.
if let Err(err) =
@@ -100,6 +115,10 @@ fn start(app: &AppHandle, window: &WebviewWindow, data_dir: &Path, legacy_roots:
true,
);
tracing::info!(data_dir = %data_dir.display(), "biliup desktop starting");
match &ffmpeg {
Some(path) => tracing::info!(path = %path.display(), "bundled ffmpeg found"),
None => tracing::info!("no bundled ffmpeg; using the ffmpeg_path setting or PATH"),
}
if let Some(legacy_dir) = data_dir::find_legacy_root(legacy_roots) {
match data_dir::migrate_legacy_data(legacy_dir, data_dir) {
@@ -156,6 +175,7 @@ fn start(app: &AppHandle, window: &WebviewWindow, data_dir: &Path, legacy_roots:
user_cookie: PathBuf::from("cookies.json"),
work_dir: None,
log_handle: logging.handle(),
ffmpeg,
listener: Some(listener),
shutdown: Some(Box::pin(async move {
let _ = stopped.await;
@@ -284,6 +304,13 @@ async fn web_ui_ready(port: u16) -> bool {
)
}
fn bundled_ffmpeg(resource_dir: Option<PathBuf>) -> Option<PathBuf> {
// On Windows resource_dir() is a `\\?\` path; this one ends up in logs and GET /v1/tools.
let mut path = dunce::simplified(&resource_dir?).to_path_buf();
path.extend(BUNDLED_FFMPEG);
Some(path).filter(|path| path.is_file())
}
/// Where this executable lives. The old PyInstaller sidecar used it as its
/// working directory, so an older install may have left data/ there.
fn install_dir() -> Option<PathBuf> {
@@ -328,4 +355,21 @@ mod tests {
fn percent_encoding_round_trips_through_decode_uri_component() {
assert_eq!(percent_encode("a b/ç%"), "a%20b%2F%C3%A7%25");
}
#[test]
fn bundled_ffmpeg_is_used_only_when_installed() {
let resources = std::env::temp_dir().join(format!(
"biliup-desktop-test-resources-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&resources);
fs::create_dir_all(&resources).unwrap();
assert_eq!(bundled_ffmpeg(Some(resources.clone())), None);
assert_eq!(bundled_ffmpeg(None), None);
let ffmpeg = resources.join("ffmpeg").join(BUNDLED_FFMPEG[1]);
fs::create_dir_all(ffmpeg.parent().unwrap()).unwrap();
fs::write(&ffmpeg, b"").unwrap();
assert_eq!(bundled_ffmpeg(Some(resources.clone())), Some(ffmpeg));
fs::remove_dir_all(&resources).unwrap();
}
}
@@ -0,0 +1,10 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"resources": {
"ffmpeg/ffmpeg.exe": "ffmpeg/ffmpeg.exe",
"ffmpeg/LICENSE.txt": "ffmpeg/LICENSE.txt",
"ffmpeg/README.txt": "ffmpeg/README.txt"
}
}
}