From 207013e95738170d9faed7bb8055a9fee730736a Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 16 Jun 2026 02:31:38 -0700 Subject: [PATCH] Windows: shippable cross-compiled .zip + console fix + release CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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--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 --- .forgejo/workflows/release.yml | 15 ++++- Cargo.lock | 1 + Cargo.toml | 8 +++ ROADMAP.md | 66 +++++++++++++-------- scripts/package.sh | 104 +++++++++++++++++++++++++++++++-- src/main.rs | 27 +++++++++ 6 files changed, 187 insertions(+), 34 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 5c15e1f..460d5a7 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 2e8b9df..7241da1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5432,6 +5432,7 @@ dependencies = [ "tokio-util", "toml", "tower-http 0.5.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 01dc3ca..3a13ca0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 4b0c0f2..dfbcdbc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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` -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` +warnings). `scripts/package.sh win` (and `all`, when the cross toolchain +is present) cross-compiles and bundles it into +`yt-offline--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). diff --git a/scripts/package.sh b/scripts/package.sh index c7ce697..5c193dc 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -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" </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 ───────────────────────────────────────────────────────────────── -build_binary +# 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]#* }" diff --git a/src/main.rs b/src/main.rs index b4ca3b8..33290e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 = std::env::args().collect(); // Load the config first — both modes share the channels_root path,