Windows: shippable cross-compiled .zip + console fix + release CI

The tree already links a working x86_64-pc-windows-gnu exe; turn that into
a shipped artifact:

- scripts/package.sh gains a `win` target (and `all` picks it up when the
  mingw toolchain is present) that cross-compiles and zips the exe +
  LICENSE + a README listing the yt-dlp/ffmpeg/mpv PATH deps.
- Forgejo release workflow installs mingw-w64 + zip + the rust target, so
  a tag push produces yt-offline-<ver>-x86_64-windows.zip from the same
  Linux container — no Windows runner needed.
- main.rs: attach_windows_console() (cfg(windows), windows-sys
  AttachConsole) reattaches a release build to the launching terminal so
  --web/CLI output is visible, while a double-click stays windowless
  (release sets windows_subsystem = "windows").
- ROADMAP 3.1 updated: Windows ships; macOS deferred (needs a Mac runner).

Native Linux build + 128 unit / 11 integration tests still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-16 02:31:38 -07:00
parent 8b2578783a
commit 207013e957
6 changed files with 187 additions and 34 deletions

View file

@ -31,12 +31,19 @@ jobs:
# libxcb + friends are needed to *link* the eframe/winit GUI even
# in headless CI. curl + file are used by the packaging script and
# appimagetool. rpm provides rpmbuild for cargo-generate-rpm's
# payload assembly on a Debian host.
# payload assembly on a Debian host. mingw-w64 + zip let the same
# script cross-compile the Windows .zip from this Linux container.
run: |
apt-get update
apt-get install -y --no-install-recommends \
libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
curl file rpm ca-certificates
curl file rpm ca-certificates mingw-w64 zip
- name: Add Windows cross-compile target
# package.sh's `all` only builds the Windows zip when this target +
# mingw + zip are all present (win_available), so this step is what
# opts the release build into producing yt-offline-*-x86_64-windows.zip.
run: rustup target add x86_64-pc-windows-gnu
- name: Cache cargo registry + build
uses: actions/cache@v4
@ -80,5 +87,7 @@ jobs:
- `.deb` — Debian / Ubuntu / Mint
- `.rpm` — Fedora / RHEL / openSUSE
- `.AppImage` — any Linux (chmod +x and run)
- `-x86_64-windows.zip` — Windows 10/11 (unzip, run yt-offline.exe)
Runtime requirements: yt-dlp, ffmpeg, mpv, xdg-utils on PATH.
Runtime requirements: yt-dlp, ffmpeg, mpv on PATH (plus
xdg-utils on Linux). The Windows zip's README lists the same.

1
Cargo.lock generated
View file

@ -5432,6 +5432,7 @@ dependencies = [
"tokio-util",
"toml",
"tower-http 0.5.2",
"windows-sys 0.59.0",
]
[[package]]

View file

@ -52,6 +52,14 @@ rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "t
[target.'cfg(not(target_os = "linux"))'.dependencies]
rfd = { version = "0.15", features = ["tokio"] }
# Release Windows builds are linked as a GUI subsystem app (no console window —
# see the windows_subsystem attribute in main.rs), which is right for the egui
# GUI but means a `--web`/CLI run launched from a terminal has nowhere to print.
# windows-sys gives us AttachConsole to reattach to the launching console at
# runtime (see attach_windows_console in main.rs).
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_System_Console"] }
[profile.release]
opt-level = 3
# Thin LTO does cross-crate inlining without the rust-lld + bundled-sqlite

View file

@ -126,25 +126,41 @@ instead of cascading one handler's panic into a dead server.
Once we're at parity, we push past Tartube on its own ground.
### 3.1 Cross-compile macOS + Windows binaries — GROUNDWORK DONE
### 3.1 Cross-compile macOS + Windows binaries — WINDOWS SHIPS; macOS DEFERRED
The compile blockers are cleared: `cargo check --target
x86_64-pc-windows-gnu` is green (only the upstream egui `f32: From<f64>`
warnings). The Linux-only deps are now target-gated in `Cargo.toml`
`ksni` and `rfd`'s `xdg-portal` backend are `cfg(target_os = "linux")`
only, with `rfd` falling back to its native Win32/AppKit backend
elsewhere. `tray::start` is `cfg`-split: the SNI/ksni implementation on
Linux, a no-op `None` stub on other OSes (windowed-only, exactly the
no-SNI-host behavior). The rest was already portable — `disk_space`
(`statvfs`), `plex` (symlinks; now with a Windows `symlink_file` path),
the mpv IPC `UnixStream`, and the `chmod`/`PermissionsExt` guards are all
`cfg(unix)`-gated. macOS is Unix, so those paths apply there unchanged.
**Windows is now a shippable artifact.** `cargo build --release --target
x86_64-pc-windows-gnu` doesn't just check — it *links* a working 22 MB
`yt-offline.exe` via mingw-w64 (only the upstream egui `f32: From<f64>`
warnings). `scripts/package.sh win` (and `all`, when the cross toolchain
is present) cross-compiles and bundles it into
`yt-offline-<ver>-x86_64-windows.zip` (exe + LICENSE + a README listing
the yt-dlp/ffmpeg/mpv PATH deps). The Forgejo release workflow installs
`mingw-w64` + `zip` + the rust target so a tag push produces the Windows
zip alongside the Linux packages — all from the one Linux container, no
Windows runner needed.
Remaining for a shipped binary: a real per-OS tray backend (e.g.
`tray-icon`) if the tray is wanted off Linux; a CI matrix that actually
*links* (mingw-w64 / an osxcross or macOS runner) and produces artifacts;
and runtime testing on each OS. The hard part — making the tree compile
off Linux — is done.
One Windows-specific runtime fix landed with it: release builds set
`windows_subsystem = "windows"` (no console window — right for the GUI),
which would leave a `--web`/CLI run silent. `attach_windows_console()`
(`main.rs`, `cfg(windows)`, via `windows-sys` `AttachConsole`) reattaches
to the launching terminal so headless mode prints its logs there, while a
double-click stays windowless.
The portability groundwork that made this possible: Linux-only deps are
target-gated in `Cargo.toml``ksni` and `rfd`'s `xdg-portal` backend
are `cfg(target_os = "linux")` only, `rfd` falling back to its native
Win32/AppKit backend elsewhere; `tray::start` is a no-op `None` stub off
Linux; `disk_space` (`statvfs`), `plex` (symlinks, with a Windows
`symlink_file` path), the mpv IPC `UnixStream`, and the
`chmod`/`PermissionsExt` guards are all `cfg(unix)`-gated.
**macOS is deferred, not blocked.** The code is Unix and cfg-gated, so it
*should* compile on a Mac unchanged, but it can't be linked from the
Linux host (no osxcross/SDK) and Codeberg has no macOS runners — so a real
tested `.app`/binary needs either a GitHub Actions `macos-latest` runner
(mirror CI there) or a local osxcross setup. Remaining for either OS: a
real per-OS tray backend (e.g. `tray-icon`) if the tray is wanted off
Linux, and on-hardware runtime testing.
### 3.2 Android client
@ -319,14 +335,14 @@ Roughly reverse-chronological. Items that closed out a roadmap line.
error recovery (2.3), restore (2.4), stability hardening (2.5).
- **Phase 3** is the "surpass" work now that we're at parity. Shipped so
far: WebSocket progress (3.3), smart auto-tagging (3.4), federation
(3.5), comment viewer (3.6), perceptual-hash dedup (3.7), and
full-text search + transcript tooling (3.9). Still open: shipped
Windows/macOS binaries (3.1; compile groundwork is done), Android
(3.2), and plugin hooks (3.8).
(3.5), comment viewer (3.6), perceptual-hash dedup (3.7), full-text
search + transcript tooling (3.9), and the **Windows** half of 3.1
(cross-compiled .zip + release CI). Still open: a **macOS** binary
(3.1; needs a Mac runner), Android (3.2), and plugin hooks (3.8).
- **Phase 4** items might be valuable, but commit to nothing.
Items inside a phase are loosely ordered by user-visible impact, not strict
prerequisite. With the easy "surpass" wins banked, the highest-leverage
remaining moves are **3.1 (Windows/macOS release artifacts)** for reach,
**3.8 (plugin hooks)** for extensibility, and **3.2 (Android client)** for
mobile-first consumption.
prerequisite. With Windows banked, the highest-leverage remaining moves are
**3.8 (plugin hooks)** for extensibility, **3.2 (Android client)** for
mobile-first consumption, and the **macOS** half of 3.1 for reach (a Mac
runner or osxcross).

View file

@ -2,7 +2,7 @@
# Build distributable packages for yt-offline.
#
# Usage:
# scripts/package.sh [deb|rpm|appimage|all]
# scripts/package.sh [deb|rpm|appimage|win|all]
#
# With no argument, builds everything that's buildable on the current host.
# Output lands in dist/. Each format is independent — a failure in one
@ -12,8 +12,12 @@
# - .deb → cargo-deb (cargo install cargo-deb)
# - .rpm → cargo-generate-rpm (cargo install cargo-generate-rpm)
# - AppImage → appimagetool (downloaded to dist/tools/ if missing)
# - win .zip → x86_64-pc-windows-gnu target + mingw-w64 gcc + zip
#
# The release binary is built once up front and reused by every format.
# The Linux release binary is built once up front and reused by the Linux
# formats; the Windows .zip cross-compiles a separate target on demand.
# `all` skips the Windows zip unless the cross toolchain is present, so a
# plain `all` on a stock Linux box still succeeds.
set -uo pipefail
@ -135,22 +139,110 @@ APPRUN
rm -rf "$appdir"
}
# ── Windows .zip (cross-compiled via mingw-w64) ──────────────────────────────
# Like the AppImage we don't bundle the subprocess deps (yt-dlp / ffmpeg /
# mpv) — on Windows they're expected on PATH, and the README in the zip says
# so. The release build links a GUI-subsystem exe (no console window); the
# binary reattaches to the parent console at runtime for `--web`/CLI use
# (see attach_windows_console in main.rs).
WIN_TARGET="x86_64-pc-windows-gnu"
build_win() {
say "building Windows .zip ($WIN_TARGET)"
if ! rustup target list --installed 2>/dev/null | grep -qx "$WIN_TARGET"; then
RESULT[win]="skip rust target $WIN_TARGET not installed (rustup target add $WIN_TARGET)"
return
fi
if ! command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1; then
RESULT[win]="skip mingw-w64 gcc not found (install mingw-w64)"
return
fi
if ! command -v zip >/dev/null 2>&1; then
RESULT[win]="skip zip not found"
return
fi
if ! cargo build --release --target "$WIN_TARGET"; then
RESULT[win]="fail cargo build --target $WIN_TARGET returned nonzero"
return
fi
local exe="target/$WIN_TARGET/release/$PKG.exe"
if [[ ! -f "$exe" ]]; then
RESULT[win]="fail expected $exe after build"
return
fi
local staging="$DIST/${PKG}-win"
rm -rf "$staging"
mkdir -p "$staging"
install -m755 "$exe" "$staging/$PKG.exe"
install -m644 LICENSE "$staging/LICENSE.txt"
cat > "$staging/README.txt" <<README
yt-offline ${VERSION} — Windows build
Run the GUI by double-clicking yt-offline.exe.
To run the headless web server, open PowerShell or Command Prompt in this
folder and run:
.\\yt-offline.exe --web 8080
then browse to http://localhost:8080. (Launched from a terminal the server
prints its logs to that terminal; double-clicked it runs windowless.)
Runtime requirements — these external tools must be installed and on your
PATH (the .exe shells out to them, it does not bundle them):
yt-dlp https://github.com/yt-dlp/yt-dlp (the downloader engine)
ffmpeg https://ffmpeg.org (muxing / transcode / dedup)
mpv https://mpv.io (desktop playback; optional)
config.toml and cookies.txt are read from the working directory, the same
as on Linux. See the project README for configuration details.
Licensed under the GNU Affero General Public License v3 or later; see
LICENSE.txt. Source: https://codeberg.org/${PKG}
README
local out="$DIST/${PKG}-${VERSION}-x86_64-windows.zip"
rm -f "$out"
# -j junks paths so the zip has the three files at its root.
if (cd "$staging" && zip -q -j "$out" "$PKG.exe" LICENSE.txt README.txt); then
RESULT[win]="ok $out"
else
RESULT[win]="fail zip returned nonzero"
fi
rm -rf "$staging"
}
# Whether to fold the Windows zip into a bare `all` run: only when the cross
# toolchain is actually present, so `all` on a stock Linux box still passes.
win_available() {
rustup target list --installed 2>/dev/null | grep -qx "$WIN_TARGET" \
&& command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 \
&& command -v zip >/dev/null 2>&1
}
# ── Dispatch ─────────────────────────────────────────────────────────────────
# The Windows zip doesn't need the Linux release binary, so skip that slow
# build when Windows is all that was asked for.
if [[ "$WANT" != "win" ]]; then
build_binary
fi
case "$WANT" in
deb) build_deb ;;
rpm) build_rpm ;;
appimage) build_appimage ;;
all) build_deb; build_rpm; build_appimage ;;
*) err "unknown target '$WANT' (want: deb|rpm|appimage|all)"; exit 2 ;;
win) build_win ;;
all) build_deb; build_rpm; build_appimage
if win_available; then build_win; fi ;;
*) err "unknown target '$WANT' (want: deb|rpm|appimage|win|all)"; exit 2 ;;
esac
# ── Summary ──────────────────────────────────────────────────────────────────
echo
say "package summary"
status=0
for fmt in deb rpm appimage; do
for fmt in deb rpm appimage win; do
[[ -v RESULT[$fmt] ]] || continue
state="${RESULT[$fmt]%% *}"
detail="${RESULT[$fmt]#* }"

View file

@ -65,7 +65,34 @@ fn renderer_from_args(args: &[String]) -> eframe::Renderer {
}
}
/// Reattach stdout/stderr to the parent console on Windows.
///
/// Release builds set `windows_subsystem = "windows"`, so the process starts
/// detached from any console — correct for the GUI (no flashing console
/// window), but it means a `--web` or other CLI invocation launched from a
/// terminal would print nothing. `AttachConsole(ATTACH_PARENT_PROCESS)` hooks
/// us back onto the launching terminal's console when there is one (e.g. run
/// from PowerShell/cmd). If the process has no parent console (double-clicked
/// from Explorer) the call fails harmlessly and we stay silent, which is the
/// desired GUI behavior. No-op on every non-Windows platform.
#[cfg(windows)]
fn attach_windows_console() {
use windows_sys::Win32::System::Console::{AttachConsole, ATTACH_PARENT_PROCESS};
// SAFETY: AttachConsole takes no pointers and is safe to call with an
// invalid/absent parent console — it just returns 0. We don't touch the
// returned handles; the CRT picks up the now-attached console on the next
// stdout/stderr write.
unsafe {
AttachConsole(ATTACH_PARENT_PROCESS);
}
}
#[cfg(not(windows))]
fn attach_windows_console() {}
fn main() -> eframe::Result<()> {
attach_windows_console();
let args: Vec<String> = std::env::args().collect();
// Load the config first — both modes share the channels_root path,