This commit is contained in:
nvrl committed 2026-08-02 19:37:03 +02:00
commit a1b345a029
80 files changed
+28033

No files matched your search

+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env bash
#
# Windows release build + packaging, hosted on Linux. No Windows, no Wine.
#
# scripts/build-windows.sh --fetch-mpv # everything, fetching libmpv
# scripts/build-windows.sh --mpv-dev ~/mpv-dev # everything, using a local copy
# scripts/build-windows.sh --skip-build msi # repackage what is already staged
#
# Artifacts land in dist/:
# dist/windows/ the portable payload
# dist/proton-stream-<version>-x86_64-windows.zip
# dist/proton-stream-<version>-x64.msi
#
# This is the cross-compiled counterpart to scripts/build.ps1, which needs MSVC
# and WiX and therefore a real Windows box. Two things differ, and both are
# deliberate:
#
# * The target is x86_64-pc-windows-gnu, not -msvc. mingw-w64 is a real cross
# compiler; MSVC is not one you can install here. The ABI difference is
# invisible to this application — everything it links is either Rust or
# compiled from source by a build script (aws-lc-sys, libsqlite3-sys), and
# the one prebuilt dependency, libmpv, is loaded through a DLL import
# library that mingw ships an equivalent of.
#
# * The .msi is built by wixl (msitools), not WiX, from
# packaging/windows/proton-stream.wixl.wxs. WiX cannot run off Windows at
# all; that file's header explains exactly where it breaks. The cost is the
# installer's dialog UI. See the same header.
#
# Prerequisites (Arch names; every distro has them):
# mingw-w64-gcc msitools zip pacman -S mingw-w64-gcc msitools zip
# rustup target add x86_64-pc-windows-gnu
# osslsigncode only for --sign
set -euo pipefail
readonly ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly DIST="$ROOT/dist"
readonly OUTDIR="$DIST/windows"
readonly PKG="$ROOT/packaging/windows"
readonly PKGNAME="proton-stream"
readonly GUI_BIN="proton-stream.exe"
readonly CLI_BIN="pstr.exe"
# The mpv-dev archive: libmpv-2.dll plus the import library to link against.
# mpv's *player* build ships neither.
readonly MPV_INDEX="https://sourceforge.net/projects/mpv-player-windows/rss?path=/libmpv"
VERSION="$(grep -m1 '^version' "$ROOT/Cargo.toml" | cut -d'"' -f2)"
readonly VERSION
# ---------------------------------------------------------------- options ---
TARGET="x86_64-pc-windows-gnu"
MPV_DEV="${MPV_DEV_DIR:-}"
FETCH_MPV=0
SKIP_BUILD=0
SIGN_CERT=""
SIGN_KEY=""
TIMESTAMP_URL="http://timestamp.digicert.com"
TARGETS=()
usage() {
cat <<EOF
usage: scripts/build-windows.sh [options] [artifact ...]
artifacts
binaries cargo build --release --target ${TARGET}, then stage dist/windows
zip dist/${PKGNAME}-${VERSION}-x86_64-windows.zip
msi dist/${PKGNAME}-${VERSION}-x64.msi (needs wixl)
all all of the above (the default)
options
--mpv-dev <dir> directory holding libmpv-2.dll and libmpv.dll.a (or mpv.def)
--fetch-mpv download the latest mpv-dev archive into dist/mpv-dev
--target <triple> default ${TARGET}
--skip-build reuse target/<triple>/release from a previous run
--sign <cert.pem> Authenticode-sign the binaries and the .msi (needs --sign-key)
--sign-key <key> the private key for --sign
--timestamp <url> RFC 3161 timestamp server, default ${TIMESTAMP_URL}
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--mpv-dev) MPV_DEV="$2"; shift 2 ;;
--fetch-mpv) FETCH_MPV=1; shift ;;
--target) TARGET="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
--sign) SIGN_CERT="$2"; shift 2 ;;
--sign-key) SIGN_KEY="$2"; shift 2 ;;
--timestamp) TIMESTAMP_URL="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
-*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
*) TARGETS+=("$1"); shift ;;
esac
done
[[ ${#TARGETS[@]} -eq 0 ]] && TARGETS=(all)
for t in "${TARGETS[@]}"; do
case "$t" in
binaries|zip|msi|all) ;;
*) echo "unknown artifact: $t" >&2; usage >&2; exit 2 ;;
esac
done
if [[ " ${TARGETS[*]} " == *" all "* ]]; then
TARGETS=(binaries zip msi)
fi
log() { printf '\033[35m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
# ------------------------------------------------------------------ libmpv ---
# Newest x86_64 mpv-dev build. The "v3" variants are x86-64-v3 microarchitecture
# builds and would refuse to load on older CPUs, so they are filtered out.
fetch_mpv() {
local dest="$DIST/mpv-dev"
if [[ -f "$dest/libmpv-2.dll" ]]; then
log "reusing $dest"
MPV_DEV="$dest"
return
fi
have curl || die "--fetch-mpv needs curl"
have 7z || die "--fetch-mpv needs 7z (p7zip)"
local url
url="$(curl -sL --max-time 60 "$MPV_INDEX" \
| grep -o '<link>[^<]*mpv-dev-x86_64-2[^<]*\.7z/download</link>' \
| sed 's|<link>||; s|</link>||' | head -1)"
[[ -n "$url" ]] || die "could not find an mpv-dev build in the SourceForge feed"
log "downloading $(basename "$(dirname "$url")")"
mkdir -p "$dest"
curl -L --max-time 600 -o "$dest.7z" "$url"
7z x -y -o"$dest" "$dest.7z" >/dev/null
rm -f "$dest.7z"
MPV_DEV="$dest"
}
# Resolves MPV_DEV to a directory containing both libmpv-2.dll and an import
# library the linker will find as -lmpv.
resolve_mpv() {
[[ $FETCH_MPV -eq 1 ]] && fetch_mpv
[[ -n "$MPV_DEV" ]] || die "$(cat <<EOF
no libmpv development files. Either pass --fetch-mpv, or download the mpv-dev
archive from
https://sourceforge.net/projects/mpv-player-windows/files/libmpv/
unpack it, and pass --mpv-dev <dir> or set MPV_DEV_DIR.
EOF
)"
[[ -d "$MPV_DEV" ]] || die "--mpv-dev path does not exist: $MPV_DEV"
local dll
dll="$(find "$MPV_DEV" -name libmpv-2.dll -print -quit)"
[[ -n "$dll" ]] || die "libmpv-2.dll not found under $MPV_DEV"
MPV_DIR="$(dirname "$dll")"
MPV_DLL="$dll"
# The archive normally ships libmpv.dll.a, which is exactly what
# `-lmpv` resolves to under mingw. Older ones ship only the .def.
if [[ ! -f "$MPV_DIR/libmpv.dll.a" ]]; then
[[ -f "$MPV_DIR/mpv.def" ]] \
|| die "neither libmpv.dll.a nor mpv.def in $MPV_DIR — not an mpv-dev build"
have x86_64-w64-mingw32-dlltool || die "need x86_64-w64-mingw32-dlltool to build the import library"
log 'generating libmpv.dll.a from mpv.def'
# -D matters: without it the import library would reference mpv.dll, and the
# file that actually ships is libmpv-2.dll.
x86_64-w64-mingw32-dlltool -d "$MPV_DIR/mpv.def" -D libmpv-2.dll \
-l "$MPV_DIR/libmpv.dll.a"
fi
}
# ------------------------------------------------------------------- build ---
build() {
if [[ $SKIP_BUILD -eq 1 ]]; then log 'skipping cargo build'; return; fi
have x86_64-w64-mingw32-gcc || die 'no mingw-w64 cross compiler (pacman -S mingw-w64-gcc)'
rustup target list --installed | grep -qx "$TARGET" \
|| die "rust target $TARGET not installed — rustup target add $TARGET"
# The dev-only path patch; say so plainly rather than deep in a resolve error.
if grep -q '^\[patch\.crates-io\]' "$ROOT/Cargo.toml" && [[ ! -d "$ROOT/../proton-sdk-rs" ]]; then
die 'Cargo.toml still carries [patch.crates-io] -> ../proton-sdk-rs, but that checkout is missing.'
fi
log "cargo build --release --target $TARGET"
# libmpv2-sys emits `cargo:rustc-link-lib=mpv` and nothing else, so the search
# path has to come from outside. Appended rather than assigned: a RUSTFLAGS
# already in the environment is the caller's, and dropping it silently changes
# what gets built.
RUSTFLAGS="${RUSTFLAGS:-} -L native=$MPV_DIR" \
cargo build --release --locked --target "$TARGET" --bin proton-stream --bin pstr \
--manifest-path "$ROOT/Cargo.toml"
}
stage() {
log 'staging into dist/windows'
local rel="$ROOT/target/$TARGET/release"
for f in "$GUI_BIN" "$CLI_BIN"; do
[[ -f "$rel/$f" ]] || die "$f missing from $rel — drop --skip-build"
done
rm -rf "$OUTDIR"
mkdir -p "$OUTDIR"
install -m755 "$rel/$GUI_BIN" "$rel/$CLI_BIN" "$MPV_DLL" "$OUTDIR/"
install -m644 "$ROOT/README.md" "$OUTDIR/"
if [[ -n "$SIGN_CERT" ]]; then
sign "$OUTDIR/$GUI_BIN"
sign "$OUTDIR/$CLI_BIN"
fi
}
# signtool.exe has no Linux build; osslsigncode produces the same Authenticode
# signature from a PEM certificate and key.
sign() {
local path="$1"
have osslsigncode || die 'signing needs osslsigncode'
[[ -n "$SIGN_KEY" ]] || die '--sign needs --sign-key'
log "signing $(basename "$path")"
osslsigncode sign -certs "$SIGN_CERT" -key "$SIGN_KEY" \
-h sha256 -ts "$TIMESTAMP_URL" \
-n 'Proton Stream' -i 'https://github.com/narrrl/proton-stream' \
-in "$path" -out "$path.signed" >/dev/null
mv "$path.signed" "$path"
}
# ---------------------------------------------------------------- portable ---
make_zip() {
local out="$DIST/${PKGNAME}-${VERSION}-x86_64-windows.zip"
log "zip -> $(basename "$out")"
have zip || die 'no zip on PATH'
rm -f "$out"
(cd "$OUTDIR" && zip -qr9 "$out" .)
}
# --------------------------------------------------------------------- msi ---
make_msi() {
have wixl || die 'the .msi needs wixl (pacman -S msitools / apt install msitools)'
local out="$DIST/${PKGNAME}-${VERSION}-x64.msi"
log "msi -> $(basename "$out")"
rm -f "$out"
# wixl reads the payload out of dist/windows, so a --sign run signs the
# binaries before they are packed rather than only the .msi around them.
wixl --arch x64 \
-D "Version=$VERSION" \
-D "BinDir=$OUTDIR" \
-D "MpvDll=$OUTDIR/libmpv-2.dll" \
-D "IconFile=$PKG/proton-stream.ico" \
-D "ReadMe=$OUTDIR/README.md" \
-o "$out" "$PKG/proton-stream.wixl.wxs"
[[ -n "$SIGN_CERT" ]] && sign "$out"
return 0
}
# -------------------------------------------------------------------- main ---
mkdir -p "$DIST"
# Only the compile and the staging copy need libmpv; repackaging out of
# dist/windows does not, so `--skip-build msi` works with no mpv-dev at hand.
[[ " ${TARGETS[*]} " == *" binaries "* ]] && resolve_mpv
for t in "${TARGETS[@]}"; do
case "$t" in
binaries) build; stage ;;
zip) [[ -d "$OUTDIR" ]] || die 'nothing staged — run the binaries artifact first'; make_zip ;;
msi) [[ -d "$OUTDIR" ]] || die 'nothing staged — run the binaries artifact first'; make_msi ;;
esac
done
log 'done — dist/'
find "$DIST" -maxdepth 1 -type f -printf ' %10s bytes %f\n' | sort -k3
+265
View File
@@ -0,0 +1,265 @@
<#
.SYNOPSIS
Release build + packaging for Windows: a portable .exe and an .msi installer.
.DESCRIPTION
The Unix side is scripts/build.sh; this is the half that needs MSVC and WiX.
libmpv is the one thing that makes a Windows build of this repo different from
`cargo build`. libmpv2-sys emits `cargo:rustc-link-lib=mpv`, so the linker
wants an import library called mpv.lib, and the process wants libmpv-2.dll at
run time. Neither ships with mpv's Windows *player* build — they come from the
separate "mpv-dev" archive:
https://sourceforge.net/projects/mpv-player-windows/files/libmpv/
Unpack it somewhere and point this script at it with -MpvDev or $env:MPV_DEV_DIR.
If it contains mpv.def but no mpv.lib, the import library is generated here
with lib.exe.
There is no static libmpv worth shipping, so "standalone .exe" means the exe
plus libmpv-2.dll next to it — which is what the portable .zip contains.
.EXAMPLE
scripts\build.ps1 -MpvDev C:\mpv-dev
scripts\build.ps1 -Artifacts msi -SkipBuild
#>
[CmdletBinding()]
param(
# Directory holding libmpv-2.dll plus mpv.lib or mpv.def.
[string] $MpvDev = $env:MPV_DEV_DIR,
# exe | zip | msi | all
[ValidateSet('exe', 'zip', 'msi', 'all')]
[string[]] $Artifacts = @('all'),
# Reuse target\release from a previous run.
[switch] $SkipBuild,
# Rust target triple; the default is the host.
[string] $Target = 'x86_64-pc-windows-msvc',
# Authenticode signing, if you have a certificate. Both or neither.
[string] $SignCertThumbprint,
[string] $TimestampUrl = 'http://timestamp.digicert.com'
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$Root = Split-Path -Parent $PSScriptRoot
$Dist = Join-Path $Root 'dist'
$OutDir = Join-Path $Dist 'windows'
$Pkg = Join-Path $Root 'packaging\windows'
$Version = (Select-String -Path (Join-Path $Root 'Cargo.toml') -Pattern '^version\s*=\s*"([^"]+)"' `
| Select-Object -First 1).Matches[0].Groups[1].Value
function Log ($m) { Write-Host "==> $m" -ForegroundColor Magenta }
function Warn ($m) { Write-Host "warning: $m" -ForegroundColor Yellow }
function Die ($m) { Write-Host "error: $m" -ForegroundColor Red; exit 1 }
if ($Artifacts -contains 'all') { $Artifacts = @('exe', 'zip', 'msi') }
# ------------------------------------------------------------------ libmpv ---
function Resolve-MpvDev {
if (-not $MpvDev) {
Die @"
no libmpv development files. Download the mpv-dev archive from
https://sourceforge.net/projects/mpv-player-windows/files/libmpv/
unpack it, and pass -MpvDev <dir> or set MPV_DEV_DIR.
"@
}
if (-not (Test-Path $MpvDev)) { Die "-MpvDev path does not exist: $MpvDev" }
$dll = Get-ChildItem -Path $MpvDev -Filter 'libmpv-2.dll' -Recurse -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $dll) { Die "libmpv-2.dll not found under $MpvDev" }
$dir = $dll.DirectoryName
$lib = Join-Path $dir 'mpv.lib'
if (-not (Test-Path $lib)) {
$def = Join-Path $dir 'mpv.def'
if (-not (Test-Path $def)) {
Die "neither mpv.lib nor mpv.def in $dir — the archive is not an mpv-dev build"
}
Log 'generating mpv.lib from mpv.def'
Ensure-MsvcTools
# /NAME matters: without it the import library would reference mpv.dll,
# and the file that actually ships is libmpv-2.dll.
& lib.exe /def:"$def" /name:libmpv-2.dll /out:"$lib" /machine:x64 | Out-Null
if ($LASTEXITCODE -ne 0) { Die 'lib.exe failed to build mpv.lib' }
}
[pscustomobject]@{ Dir = $dir; Dll = $dll.FullName; Lib = $lib }
}
# lib.exe and link.exe come from a Developer prompt. If this is a plain
# PowerShell session, find the toolchain with vswhere and import its environment.
function Ensure-MsvcTools {
if (Get-Command lib.exe -ErrorAction SilentlyContinue) { return }
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
if (-not (Test-Path $vswhere)) { Die 'lib.exe not on PATH and vswhere.exe not found — run from a Developer PowerShell' }
$vsPath = & $vswhere -latest -products * `
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
if (-not $vsPath) { Die 'no Visual Studio C++ toolchain found' }
$devCmd = Join-Path $vsPath 'Common7\Tools\VsDevCmd.bat'
Log 'importing the MSVC environment'
cmd /c "`"$devCmd`" -arch=amd64 -host_arch=amd64 >nul && set" | ForEach-Object {
if ($_ -match '^([^=]+)=(.*)$') { Set-Item -Path "env:$($Matches[1])" -Value $Matches[2] }
}
if (-not (Get-Command lib.exe -ErrorAction SilentlyContinue)) { Die 'still no lib.exe after importing VsDevCmd' }
}
# --------------------------------------------------------------------- build ---
function Invoke-Build ($mpv) {
if ($SkipBuild) { Log 'skipping cargo build'; return }
# The dev-only path patch; say so plainly rather than deep in a resolve error.
$manifest = Get-Content (Join-Path $Root 'Cargo.toml') -Raw
if ($manifest -match '(?m)^\[patch\.crates-io\]' -and -not (Test-Path (Join-Path $Root '..\proton-sdk-rs'))) {
Die 'Cargo.toml still carries [patch.crates-io] -> ../proton-sdk-rs, but that checkout is missing.'
}
# MSVC reads the library search path out of LIB.
$env:LIB = "$($mpv.Dir);$env:LIB"
Log "cargo build --release --target $Target"
Push-Location $Root
try {
& cargo build --release --locked --target $Target --bin proton-stream --bin pstr
if ($LASTEXITCODE -ne 0) { Die 'cargo build failed' }
} finally { Pop-Location }
}
function Stage ($mpv) {
Log 'staging into dist\windows'
$rel = Join-Path $Root "target\$Target\release"
foreach ($f in 'proton-stream.exe', 'pstr.exe') {
if (-not (Test-Path (Join-Path $rel $f))) { Die "$f missing from $rel — drop -SkipBuild" }
}
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
Copy-Item (Join-Path $rel 'proton-stream.exe') $OutDir -Force
Copy-Item (Join-Path $rel 'pstr.exe') $OutDir -Force
Copy-Item $mpv.Dll $OutDir -Force
Copy-Item (Join-Path $Root 'README.md') $OutDir -Force
if ($SignCertThumbprint) {
foreach ($f in 'proton-stream.exe', 'pstr.exe') {
Invoke-Sign (Join-Path $OutDir $f)
}
}
}
function Invoke-Sign ($path) {
Log "signing $(Split-Path -Leaf $path)"
$cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My |
Where-Object Thumbprint -eq $SignCertThumbprint | Select-Object -First 1
if (-not $cert) { Die "no certificate with thumbprint $SignCertThumbprint" }
Set-AuthenticodeSignature -FilePath $path -Certificate $cert `
-TimestampServer $TimestampUrl -HashAlgorithm SHA256 | Out-Null
}
# ----------------------------------------------------------------- portable ---
function New-Zip {
$out = Join-Path $Dist "proton-stream-$Version-x86_64-windows.zip"
Log "zip -> $(Split-Path -Leaf $out)"
if (Test-Path $out) { Remove-Item $out }
Compress-Archive -Path (Join-Path $OutDir '*') -DestinationPath $out
}
# ---------------------------------------------------------------------- msi ---
function Ensure-Wix {
if (Get-Command wix -ErrorAction SilentlyContinue) { return }
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
Die 'the .msi needs the WiX toolset: install the .NET SDK, then `dotnet tool install --global wix`'
}
Log 'installing the WiX toolset'
& dotnet tool install --global wix | Out-Null
$env:PATH = "$env:USERPROFILE\.dotnet\tools;$env:PATH"
if (-not (Get-Command wix -ErrorAction SilentlyContinue)) { Die 'wix still not on PATH after install' }
}
function New-Msi ($mpv) {
Ensure-Wix
# WixUI_InstallDir lives in the UI extension, which is per-installation state
# rather than per-project; adding it twice is a no-op.
& wix extension add -g WixToolset.UI.wixext 2>&1 | Out-Null
# The RTF the license dialog shows. Prefer the repository's own LICENSE, so
# the installer cannot drift from it; fall back to the checked-in copy.
$rtf = Join-Path $Pkg 'license.rtf'
$repoLicense = Join-Path $Root 'LICENSE'
if (Test-Path $repoLicense) {
$rtf = Join-Path $Dist 'license.rtf'
# RTF escapes are backslash, brace-open and brace-close; paragraphs are
# \par. Done per line, because a lookbehind over already-rewritten line
# endings is a good way to emit \par twice.
$body = ((Get-Content $repoLicense) | ForEach-Object {
($_ -replace '\\', '\\') -replace '([{}])', '\$1'
}) -join "\par`r`n"
@"
{\rtf1\ansi\ansicpg1252\deff0\nouicompat{\fonttbl{\f0\fnil\fcharset0 Segoe UI;}}
\viewkind4\uc1\pard\f0\fs20
$body
}
"@ | Set-Content -Path $rtf -Encoding ASCII
} else {
Warn 'no LICENSE in the repo root; the installer shows packaging\windows\license.rtf'
}
$out = Join-Path $Dist "proton-stream-$Version-x64.msi"
Log "msi -> $(Split-Path -Leaf $out)"
# Every -d value is built into a string first: PowerShell does not evaluate a
# parenthesised expression glued to the middle of a bare argument token, so
# `-d MpvDll=(Join-Path ...)` would be passed through literally.
$wxs = Join-Path $Pkg 'proton-stream.wxs'
$mpvDll = Join-Path $OutDir (Split-Path -Leaf $mpv.Dll)
$icon = Join-Path $Pkg 'proton-stream.ico'
$readme = Join-Path $Root 'README.md'
$wixArgs = @(
'build', '-arch', 'x64', '-ext', 'WixToolset.UI.wixext', $wxs,
'-d', "Version=$Version",
'-d', "BinDir=$OutDir",
'-d', "MpvDll=$mpvDll",
'-d', "IconFile=$icon",
'-d', "LicenseRtf=$rtf",
'-d', "ReadMe=$readme",
'-o', $out
)
& wix @wixArgs
if ($LASTEXITCODE -ne 0) { Die 'wix build failed' }
if ($SignCertThumbprint) { Invoke-Sign $out }
}
# --------------------------------------------------------------------- main ---
New-Item -ItemType Directory -Force -Path $Dist | Out-Null
$mpv = Resolve-MpvDev
Invoke-Build $mpv
Stage $mpv
foreach ($a in $Artifacts) {
switch ($a) {
'exe' { } # Stage already produced dist\windows\proton-stream.exe
'zip' { New-Zip }
'msi' { New-Msi $mpv }
}
}
Log 'done — dist\'
Get-ChildItem $Dist -File | ForEach-Object {
' {0,10:N0} KB {1}' -f ($_.Length / 1KB), $_.Name
}
+473
View File
@@ -0,0 +1,473 @@
#!/usr/bin/env bash
#
# Release build + packaging driver for Linux and macOS.
#
# One cargo build feeds every artifact: the binaries are compiled once, staged
# into a filesystem image under dist/stage, and each packager copies out of that
# image. So a .deb and a .rpm from the same run are the same bytes, and neither
# needs a Rust toolchain or libmpv headers on the packaging host.
#
# scripts/build.sh # everything this host can produce
# scripts/build.sh deb rpm # just those
# scripts/build.sh --skip-build deb # repackage without recompiling
# scripts/build.sh --target aarch64-unknown-linux-gnu tarball
#
# Windows is scripts/build.ps1 — the .exe and the .msi need MSVC and WiX, and
# neither is reachable from here. Arch is packaging/PKGBUILD via `makepkg -fi`,
# which compiles in place by Arch convention rather than consuming dist/stage;
# `scripts/build.sh arch` runs it for you.
#
# Artifacts land in dist/.
set -euo pipefail
readonly ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly DIST="$ROOT/dist"
readonly STAGE="$DIST/stage"
readonly APP_ID="io.narl.proton-stream"
readonly PKGNAME="proton-stream"
readonly GUI_BIN="proton-stream"
readonly CLI_BIN="pstr"
VERSION="$(grep -m1 '^version' "$ROOT/Cargo.toml" | cut -d'"' -f2)"
readonly VERSION
# ---------------------------------------------------------------- options ---
TARGET="" # cargo --target; empty means host
SKIP_BUILD=0
BUNDLE_LIBS=1 # macOS: copy libmpv & friends into the .app
TARGETS=()
usage() {
cat <<EOF
usage: scripts/build.sh [options] [artifact ...]
artifacts
binaries cargo build --release, then stage a filesystem image
tarball dist/${PKGNAME}-${VERSION}-<triple>.tar.gz (linux, macos)
deb dist/${PKGNAME}_${VERSION}-1_<arch>.deb (linux)
rpm dist/${PKGNAME}-${VERSION}-1.<arch>.rpm (linux, needs rpmbuild)
arch packaging/PKGBUILD via makepkg (linux, needs makepkg)
app dist/Proton Stream.app (macos)
dmg dist/${PKGNAME}-${VERSION}-<arch>.dmg (macos)
all every artifact this host can produce (the default)
options
--target <triple> cross-compile; also names the tarball
--skip-build reuse whatever is already in dist/stage
--no-bundle-libs macOS: link against the host's libmpv instead of copying it
-h, --help this
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--target) TARGET="$2"; shift 2 ;;
--target=*) TARGET="${1#*=}"; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
--no-bundle-libs) BUNDLE_LIBS=0; shift ;;
-h|--help) usage; exit 0 ;;
-*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
*) TARGETS+=("$1"); shift ;;
esac
done
case "$(uname -s)" in
Linux) HOST_OS=linux ;;
Darwin) HOST_OS=macos ;;
*) echo "error: $(uname -s) is not handled here — Windows is scripts/build.ps1" >&2; exit 1 ;;
esac
readonly HOST_OS
[[ ${#TARGETS[@]} -gt 0 ]] || TARGETS=(all)
# Where cargo puts the binaries, which --target moves.
if [[ -n "$TARGET" ]]; then
RELDIR="$ROOT/target/$TARGET/release"
else
RELDIR="$ROOT/target/release"
fi
readonly RELDIR
log() { printf '\033[1;35m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
# ------------------------------------------------------------------ build ---
build() {
[[ $SKIP_BUILD -eq 0 ]] || { log "skipping cargo build"; return; }
# The dev-only path patch. Without the sibling checkout the resolve fails a
# hundred lines deep in cargo output; say so up front instead.
if grep -q '^\[patch\.crates-io\]' "$ROOT/Cargo.toml" && [[ ! -d "$ROOT/../proton-sdk-rs" ]]; then
die "Cargo.toml still carries [patch.crates-io] -> ../proton-sdk-rs, but that checkout is missing.
Clone it beside this repo, or drop the patch block once proton-sdk is published."
fi
log "cargo build --release${TARGET:+ --target $TARGET}"
( cd "$ROOT" && cargo build --release --locked \
${TARGET:+--target "$TARGET"} \
--bin "$GUI_BIN" --bin "$CLI_BIN" )
}
# `install -D` is a GNU extension and macOS does not have it, so the staging
# below goes through mkdir + cp + chmod instead.
put() {
local mode="$1" src="$2" dst="$3"
mkdir -p "$(dirname "$dst")"
cp "$src" "$dst"
chmod "$mode" "$dst"
}
# Filesystem image every Linux packager copies out of. Rooted so that
# stage/usr/bin/... is exactly where the file lands on the target system.
stage() {
log "staging into dist/stage"
rm -rf "$STAGE"
put 755 "$RELDIR/$GUI_BIN" "$STAGE/usr/bin/$GUI_BIN"
put 755 "$RELDIR/$CLI_BIN" "$STAGE/usr/bin/$CLI_BIN"
put 644 "$ROOT/packaging/$APP_ID.desktop" \
"$STAGE/usr/share/applications/$APP_ID.desktop"
put 644 "$ROOT/packaging/$APP_ID.svg" \
"$STAGE/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg"
put 644 "$ROOT/README.md" "$STAGE/usr/share/doc/$PKGNAME/README.md"
# Both packagers list these as directories, so they have to exist even when
# the checkout has no LICENSE yet.
mkdir -p "$STAGE/usr/share/licenses/$PKGNAME"
if [[ -f "$ROOT/LICENSE" ]]; then
put 644 "$ROOT/LICENSE" "$STAGE/usr/share/licenses/$PKGNAME/LICENSE"
else
warn "no LICENSE in the repo root; Cargo.toml declares MIT — packages ship without the text"
fi
}
need_stage() {
[[ -x "$STAGE/usr/bin/$GUI_BIN" ]] || die "dist/stage is empty — drop --skip-build"
}
# ---------------------------------------------------------------- helpers ---
# Debian and RPM each spell the machine differently from the Rust triple.
deb_arch() {
case "${TARGET:-$(uname -m)}" in
x86_64*|amd64) echo amd64 ;;
aarch64*|arm64) echo arm64 ;;
*) die "no Debian architecture mapped for ${TARGET:-$(uname -m)}" ;;
esac
}
rpm_arch() {
case "${TARGET:-$(uname -m)}" in
x86_64*|amd64) echo x86_64 ;;
aarch64*|arm64) echo aarch64 ;;
*) die "no RPM architecture mapped for ${TARGET:-$(uname -m)}" ;;
esac
}
triple() {
if [[ -n "$TARGET" ]]; then echo "$TARGET"
else rustc -vV | awk '/^host:/ {print $2}'
fi
}
# ---------------------------------------------------------------- tarball ---
do_tarball() {
need_stage
local name="$PKGNAME-$VERSION-$(triple)"
local out="$DIST/$name.tar.gz"
log "tarball -> ${out#$ROOT/}"
local tmp; tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
mkdir -p "$tmp/$name"
cp "$STAGE/usr/bin/$GUI_BIN" "$STAGE/usr/bin/$CLI_BIN" "$tmp/$name/"
cp "$ROOT/README.md" "$tmp/$name/"
cp "$ROOT/packaging/$APP_ID.svg" "$tmp/$name/"
if [[ -f "$ROOT/LICENSE" ]]; then cp "$ROOT/LICENSE" "$tmp/$name/"; fi
tar -C "$tmp" -czf "$out" "$name"
}
# -------------------------------------------------------------------- deb ---
do_deb() {
# GNU tar's --owner/--group and md5sum below; not worth emulating for a
# platform that cannot install the result anyway.
[[ "$HOST_OS" == linux ]] || die "the .deb can only be built on Linux"
need_stage
local arch; arch="$(deb_arch)"
local dir="$DIST/deb/${PKGNAME}_${VERSION}-1_${arch}"
local out="$DIST/${PKGNAME}_${VERSION}-1_${arch}.deb"
log "deb -> ${out#$ROOT/}"
rm -rf "$dir"
mkdir -p "$dir"
cp -a "$STAGE/." "$dir/"
# Debian keeps the copyright under doc/, not licenses/.
rm -rf "$dir/usr/share/licenses"
if [[ -f "$ROOT/LICENSE" ]]; then
put 644 "$ROOT/LICENSE" "$dir/usr/share/doc/$PKGNAME/copyright"
fi
local size; size="$(du -sk "$dir" | cut -f1)"
# libmpv2 is trixie and later; libmpv1 is bookworm. Neither pulls the mpv
# binary in, which is right — nothing here shells out to mpv(1).
# rusqlite is `bundled` and TLS is rustls, so no libsqlite3 and no libssl.
mkdir -p "$dir/DEBIAN"
cat > "$dir/DEBIAN/control" <<EOF
Package: $PKGNAME
Version: $VERSION-1
Section: video
Priority: optional
Architecture: $arch
Maintainer: Nils Pukropp <contact@narl.io>
Installed-Size: $size
Depends: libc6, libmpv2 | libmpv1, libsecret-1-0, libgl1, libxkbcommon0, libx11-6, libwayland-client0
Recommends: gnome-keyring
Homepage: https://github.com/narrrl/proton-stream
Description: Netflix-style desktop client for Proton Drive public links
Paste a Proton Drive share URL and its password, and get a browsable,
streamable library: a poster wall, a page per title with seasons and
episodes, resume-where-you-left-off, and an embedded libmpv player.
.
No Proton account, no server and no download step - a file's content blocks
each decrypt on their own, so seeking costs the blocks the seek lands on
rather than a re-stream.
EOF
# md5sums over everything but the control archive itself.
( cd "$dir" && find . -path ./DEBIAN -prune -o -type f -print0 \
| xargs -0 md5sum | sed 's|\./||' > DEBIAN/md5sums )
if have dpkg-deb; then
dpkg-deb --root-owner-group --build "$dir" "$out" >/dev/null
else
# No dpkg on Arch without the AUR, so assemble the ar archive by hand. A
# .deb is three members in this order: debian-binary, control, data.
have ar || die "need either dpkg-deb or binutils' ar to build a .deb"
local tmp; tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
echo "2.0" > "$tmp/debian-binary"
tar -C "$dir/DEBIAN" --owner=0 --group=0 -czf "$tmp/control.tar.gz" .
tar -C "$dir" --owner=0 --group=0 --exclude=./DEBIAN -cJf "$tmp/data.tar.xz" .
rm -f "$out"
( cd "$tmp" && ar rcD "$out" debian-binary control.tar.gz data.tar.xz )
fi
}
# -------------------------------------------------------------------- rpm ---
do_rpm() {
[[ "$HOST_OS" == linux ]] || die "the .rpm can only be built on Linux"
need_stage
have rpmbuild || die "rpmbuild not found (Arch: pacman -S rpm-tools; Fedora: dnf install rpm-build)"
local arch; arch="$(rpm_arch)"
log "rpm -> dist/${PKGNAME}-${VERSION}-1.${arch}.rpm"
local top="$DIST/rpmbuild"
rm -rf "$top"
mkdir -p "$top"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
# rpmbuild narrates every shell line it runs; keep the log and only show it
# when something actually went wrong.
local logf="$DIST/rpmbuild.log"
if ! rpmbuild -bb "$ROOT/packaging/$PKGNAME.spec" \
--define "_topdir $top" \
--define "version $VERSION" \
--define "stagedir $STAGE" \
--define "dist %{nil}" \
--target "$arch" >"$logf" 2>&1; then
cat "$logf" >&2
die "rpmbuild failed (log: ${logf#$ROOT/})"
fi
find "$top/RPMS" -name '*.rpm' -exec cp {} "$DIST/" \;
}
# ------------------------------------------------------------------- arch ---
do_arch() {
have makepkg || die "makepkg not found — this artifact is Arch-only"
# makepkg compiles in place from the checkout (Arch convention, and what the
# sibling repo does), so it ignores dist/stage entirely.
log "makepkg (packaging/PKGBUILD)"
( cd "$ROOT/packaging" && makepkg -f --noconfirm )
find "$ROOT/packaging" -maxdepth 1 -name "$PKGNAME-*.pkg.tar.zst" -newermt '-5 minutes' \
-exec cp {} "$DIST/" \;
}
# ------------------------------------------------------------------ macOS ---
# Copy a dylib's non-system dependency closure into the bundle and rewrite every
# reference to it. Homebrew's libmpv pulls in ~40 of these; without this the
# .app only runs on a machine that already did `brew install mpv`.
bundle_dylibs() {
local app="$1" bin="$2"
local fw="$app/Contents/Frameworks"
mkdir -p "$fw"
# An index cursor and a newline-delimited seen-set rather than array slicing
# and an associative array: /bin/bash on macOS is still 3.2, which has
# neither.
local queue=("$bin")
local seen=$'\n'
local i=0
while [[ $i -lt ${#queue[@]} ]]; do
local item="${queue[$i]}"
i=$((i + 1))
local dep
while read -r dep; do
# System libraries stay put; they exist on every macOS.
case "$dep" in
/usr/lib/*|/System/*|@*) continue ;;
esac
local base; base="$(basename "$dep")"
if [[ "$seen" != *$'\n'"$base"$'\n'* ]]; then
seen="$seen$base"$'\n'
if [[ ! -f "$dep" ]]; then
warn "dependency not found on disk: $dep"
continue
fi
cp -f "$dep" "$fw/$base"
chmod u+w "$fw/$base"
install_name_tool -id "@rpath/$base" "$fw/$base" 2>/dev/null || true
queue+=("$fw/$base")
fi
install_name_tool -change "$dep" "@rpath/$base" "$item" 2>/dev/null || true
done < <(otool -L "$item" | tail -n +2 | awk '{print $1}')
done
install_name_tool -add_rpath "@executable_path/../Frameworks" "$bin" 2>/dev/null || true
# Ad-hoc re-sign: every install_name_tool edit invalidates the signature, and
# an unsigned-but-modified binary is killed on arm64.
codesign --force --deep --sign - "$app" 2>/dev/null \
|| warn "codesign failed; the .app may not launch on Apple silicon"
}
make_icns() {
local out="$1"
local svg="$ROOT/packaging/$APP_ID.svg"
have iconutil || { warn "iconutil missing; .app ships without an icon"; return 1; }
local rasterize=""
have rsvg-convert && rasterize=rsvg-convert
[[ -z "$rasterize" ]] && have magick && rasterize=magick
[[ -z "$rasterize" ]] && { warn "no rsvg-convert or magick; .app ships without an icon"; return 1; }
local iconset; iconset="$(mktemp -d)/icon.iconset"
mkdir -p "$iconset"
local s
for s in 16 32 64 128 256 512 1024; do
if [[ "$rasterize" == rsvg-convert ]]; then
rsvg-convert -w "$s" -h "$s" "$svg" -o "$iconset/icon_${s}x${s}.png"
else
magick -background none "$svg" -resize "${s}x${s}" "$iconset/icon_${s}x${s}.png"
fi
done
# Retina names iconutil expects.
for s in 16 32 128 256 512; do
cp "$iconset/icon_$((s * 2))x$((s * 2)).png" "$iconset/icon_${s}x${s}@2x.png"
done
iconutil -c icns "$iconset" -o "$out"
}
do_app() {
[[ "$HOST_OS" == macos ]] || die "the .app bundle can only be built on macOS"
need_stage
local app="$DIST/Proton Stream.app"
log "app bundle -> dist/Proton Stream.app"
rm -rf "$app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp "$STAGE/usr/bin/$GUI_BIN" "$app/Contents/MacOS/$GUI_BIN"
# The CLI rides along inside the bundle rather than in /usr/local/bin: a .dmg
# drag-install has no business writing outside /Applications.
cp "$STAGE/usr/bin/$CLI_BIN" "$app/Contents/MacOS/$CLI_BIN"
sed -e "s/@VERSION@/$VERSION/g" -e "s/@BIN@/$GUI_BIN/g" -e "s/@APP_ID@/$APP_ID/g" \
"$ROOT/packaging/macos/Info.plist.in" > "$app/Contents/Info.plist"
make_icns "$app/Contents/Resources/$PKGNAME.icns" || true
if [[ $BUNDLE_LIBS -eq 1 ]]; then
log "bundling dylibs into the .app"
bundle_dylibs "$app" "$app/Contents/MacOS/$GUI_BIN"
bundle_dylibs "$app" "$app/Contents/MacOS/$CLI_BIN"
else
warn "--no-bundle-libs: the .app needs \`brew install mpv\` on the target machine"
fi
}
do_dmg() {
[[ "$HOST_OS" == macos ]] || die "the .dmg can only be built on macOS"
local app="$DIST/Proton Stream.app"
[[ -d "$app" ]] || do_app
local out="$DIST/$PKGNAME-$VERSION-$(uname -m).dmg"
log "dmg -> ${out#$ROOT/}"
local tmp; tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
cp -R "$app" "$tmp/"
ln -s /Applications "$tmp/Applications"
rm -f "$out"
hdiutil create -volname "Proton Stream" -srcfolder "$tmp" \
-ov -format UDZO "$out" >/dev/null
}
# ------------------------------------------------------------------- main ---
mkdir -p "$DIST"
expand_all() {
case "$HOST_OS" in
linux) echo "binaries tarball deb rpm" ;;
macos) echo "binaries tarball app dmg" ;;
esac
}
# `all` expands in place; `binaries` is implied by everything else, so it runs
# once at the front rather than per artifact.
want=()
for t in "${TARGETS[@]}"; do
if [[ "$t" == all ]]; then
for e in $(expand_all); do want+=("$e"); done
else
want+=("$t")
fi
done
# `arch` is the one artifact that never reads dist/stage — makepkg compiles in
# place. Asking for nothing but that skips the cargo build here.
needs_stage=0
for t in "${want[@]}"; do
[[ "$t" == arch ]] || needs_stage=1
done
if [[ $needs_stage -eq 1 ]]; then
build
stage
fi
for t in "${want[@]}"; do
case "$t" in
binaries) ;; # already done above
tarball) do_tarball ;;
deb) do_deb ;;
rpm) do_rpm ;;
arch) do_arch ;;
app) do_app ;;
dmg) do_dmg ;;
*) die "unknown artifact: $t" ;;
esac
done
log "done — dist/"
find "$DIST" -maxdepth 1 \( -type f -o -name '*.app' \) -exec du -sh {} + \
| sed "s|$DIST/| |"