# LTStream bootstrap installer (Windows) — LTS-21 # # Version : 1.0.0 # Repo : LTStream (lttech) — docs/superpowers/specs/2026-08-17-unbundled-ffmpeg-design.md (mục 3.3) # Purpose : 1 lệnh cài LTStream app + ffmpeg/ffprobe, idempotent, không elevation, không telemetry. # License : Script này là code của LTStream. Binary ffmpeg/ffprobe (nếu tải static) thuộc # license upstream: gyan.dev builds (GPL). Xem THIRDPARTY.md trong repo. # # Usage: # iex ((irm https://dl.workvps.com/ltstream/install.ps1) -replace '^\uFEFF','') # powershell -ExecutionPolicy Bypass -File .\install.ps1 [-DryRun] [-Version 0.3.0] [-Next] # # (One-liner strip UTF-8 BOM tại client — file vẫn giữ BOM cho PS 5.1 -File; #202) # # Flags (ADR-0017 — điền cuối lệnh khi chạy qua -File): # -Version 0.3.0 pin version app — bỏ qua latest.json; thắng env + -Next # -Next cài kênh next (canary, ADR-0016) — đọc latest-next.json; thua pin # -DryRun in plan install, KHÔNG đổi máy # # Env (khi chạy qua irm|iex — param không truyền được qua pipe; param thắng env): # LTSTREAM_VERSION pin version app — như -Version # LTSTREAM_SKIP_APP=1 chỉ ensure ffmpeg/ffprobe, không cài app # LTSTREAM_BASE_URL override public base (mặc định https://dl.workvps.com/ltstream) — cho test # LTSTREAM_NEXT=1 kênh next — như -Next (pin vẫn thắng: ADR-0016) # # Tương thích Windows PowerShell 5.1 + PowerShell 7+. Trên OS khác chỉ -DryRun có nghĩa # (WARN + chạy tiếp để test được cross-platform; mọi thao tác thật cần Windows). <# .SYNOPSIS LTStream bootstrap installer (Windows) — cài LTStream app + ffmpeg/ffprobe. .DESCRIPTION Idempotent: binaries đã resolve (theo resolver spec 3.2) thì skip; app đã ở version >= target thì skip (không downgrade). Verify SHA256 mọi artifact trước khi execute. Không telemetry. .PARAMETER DryRun In plan install, KHÔNG đổi máy. .PARAMETER Version Pin version app (vd 0.2.0) — bỏ qua latest.json, tải trực tiếp từ /ltstream//. Thắng -Next (ADR-0017). .PARAMETER Next Cài kênh next (canary, ADR-0016) — đọc latest-next.json. Thua -Version pin (pin vẫn thắng). #> param( [switch]$DryRun, [string]$Version, [switch]$Next ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 # Windows PowerShell 5.1 mặc định không bật TLS 1.2 if ($PSVersionTable.PSVersion.Major -lt 6) { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } # Progress bar làm Invoke-WebRequest chậm đáng kể trên 5.1 — tắt, khôi phục ở finally main. $script:PrevProgressPreference = $ProgressPreference $ProgressPreference = 'SilentlyContinue' $SCRIPT_VERSION = '1.1.0' function Get-EnvValue([string]$Name, [string]$Default) { $v = [Environment]::GetEnvironmentVariable($Name) if ([string]::IsNullOrEmpty($v)) { return $Default } else { return $v } } $BASE_URL = Get-EnvValue 'LTSTREAM_BASE_URL' 'https://dl.workvps.com/ltstream' $TARGET_VERSION = if ($Version) { $Version } else { Get-EnvValue 'LTSTREAM_VERSION' '' } $SKIP_APP = (Get-EnvValue 'LTSTREAM_SKIP_APP' '') -eq '1' $NEXT_CHANNEL = $Next -or ((Get-EnvValue 'LTSTREAM_NEXT' '') -eq '1') $JSON_KEY = 'windows-x86_64' # $env:USERPROFILE/$env:LOCALAPPDATA chỉ có trên Windows — fallback HOME/empty để -DryRun # chạy được trên máy dev macOS/Linux (chỉ in plan, không đụng gì). $UserProfile = Get-EnvValue 'USERPROFILE' (Get-EnvValue 'HOME' '') $LocalAppData = Get-EnvValue 'LOCALAPPDATA' '' $BIN_DIR = if ($UserProfile) { Join-Path $UserProfile '.ltstream\bin' } else { '.ltstream\bin' } $WINGET_LINKS = if ($LocalAppData) { Join-Path $LocalAppData 'Microsoft\WinGet\Links' } else { '' } # --------------------------------------------------------------------------- # Pinned static ffmpeg upstream (fallback khi KHÔNG có winget) — gyan.dev # "release essentials" zip (GPL), FFmpeg + ffprobe cùng 1 zip. # # Nguồn pin (VERIFY 2026-08-17 TỪ MÁY IMPLEMENT — xem task-11-report): # URL : https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip # (rolling — thay nội dung mỗi release FFmpeg, hiện ffmpeg 9.0.1) # .sha256 : https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip.sha256 # (URL ổn định, format bare hash — đã fetch verify: fec81ae0…da2e9) # Cấu trúc zip (verify qua range-request parse central directory của chính zip): # ffmpeg-9.0.1-essentials_build/bin/ffmpeg.exe # ffmpeg-9.0.1-essentials_build/bin/ffprobe.exe (cùng dir bin/, KHÔNG ở root zip) # → script dò ffmpeg.exe recursive + yêu cầu ffprobe.exe cùng dir. # # Vì URL rolling, SHA256 pin cứng sẽ stale ngay khi upstream đổi bản — mặc định verify qua # .sha256 sidecar CỦA UPSTREAM tải runtime (giống nhánh johnvansickle .md5 của install.sh). # Muốn khóa tuyệt đối: điền $FFMPEG_SHA256_WINDOWS hoặc export LTSTREAM_FFMPEG_SHA256 # (env thắng pin trong script — hướng dẫn lấy hash): # irm -OutFile ff.zip https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip # (Get-FileHash ff.zip -Algorithm SHA256).Hash $FFMPEG_SHA256_WINDOWS = '' $GYAN_ZIP_URL = 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip' $GYAN_ZIP_SHA_URL = 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip.sha256' $script:TmpRoot = $null # temp root — dọn trong finally main # --------------------------------------------------------------------------- function Log([string]$Message) { Write-Host "==> $Message" } function Warn([string]$Message) { Write-Host "WARN: $Message" -ForegroundColor Yellow } function Die([string]$Message) { # throw (thay vì exit) để `irm | iex` không đóng session mà vẫn thấy message; # chạy qua -File thì unhandled exception → exit code 1. Write-Host "ERROR: $Message" -ForegroundColor Red throw "LTStream installer: $Message" } function New-TempDir { if (-not $script:TmpRoot) { $script:TmpRoot = Join-Path ([IO.Path]::GetTempPath()) ('ltstream-' + [IO.Path]::GetRandomFileName()) New-Item -ItemType Directory -Path $script:TmpRoot | Out-Null } $p = Join-Path $script:TmpRoot ([IO.Path]::GetRandomFileName()) New-Item -ItemType Directory -Path $p | Out-Null return $p } function Save-File([string]$Url, [string]$OutFile) { Log "Tải: $Url" try { Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $OutFile } catch { Die "Không tải được: $Url ($($_.Exception.Message))" } } function Get-FileSHA256([string]$Path) { try { return (Get-FileHash -Path $Path -Algorithm SHA256).Hash } catch { Die "Không tính được SHA256: $Path ($($_.Exception.Message))" } } function Assert-SHA256([string]$Path, [string]$Expected, [string]$SourceNote) { $actual = Get-FileSHA256 $Path if ($actual -ne $Expected) { # -ne PowerShell case-insensitive Die "SHA256 mismatch: $Path (expected $Expected, got $actual)" } Log "SHA256 OK [$SourceNote]: $actual" } # --------------------------------------------------------------------------- # Idempotency binaries (resolver spec 3.2: env > settings > ~/.ltstream/bin > # OS-native). Installer chỉ cần biết "đã resolve chưa" để skip. function Test-BinaryOnPath([string]$Name) { $candidates = @() if ($BIN_DIR) { $candidates += (Join-Path $BIN_DIR "$Name.exe") } if ($WINGET_LINKS) { $candidates += (Join-Path $WINGET_LINKS "$Name.exe") } $candidates += "C:\ffmpeg\bin\$Name.exe" foreach ($c in $candidates) { if (Test-Path -LiteralPath $c -PathType Leaf) { return $true } } if ($null -ne (Get-Command $Name -ErrorAction SilentlyContinue)) { return $true } return $false } function Test-BinariesResolved { return ((Test-BinaryOnPath 'ffmpeg') -and (Test-BinaryOnPath 'ffprobe')) } # winget: Start-Process -Wait để không dính quirk NativeCommandError của PS 5.1 # ($ErrorActionPreference='Stop' + native stderr trong try/catch). function Ensure-BinariesWinget { if ($null -eq (Get-Command 'winget' -ErrorAction SilentlyContinue)) { return $false } Log 'Plan: winget install --id Gyan.FFmpeg --exact --silent (ffmpeg + ffprobe)' if ($DryRun) { return $true } $proc = Start-Process -FilePath 'winget' -Wait -PassThru -ArgumentList @( 'install', '--id', 'Gyan.FFmpeg', '--exact', '--silent', '--accept-package-agreements', '--accept-source-agreements' ) if ($proc.ExitCode -ne 0) { Warn "winget exit $($proc.ExitCode) — fallback sang static gyan.dev pinned" return $false } return $true } function Ensure-StaticWindows { Log 'Tải static ffmpeg/ffprobe (gyan.dev essentials):' Log " Zip : $GYAN_ZIP_URL" Log " Sha256 : $GYAN_ZIP_SHA_URL (upstream sidecar)" if ($DryRun) { return } $tmp = New-TempDir $zipPath = Join-Path $tmp 'ffmpeg-essentials.zip' # Checksum: env override > pin trong script > .sha256 sidecar upstream tải runtime. $pinned = Get-EnvValue 'LTSTREAM_FFMPEG_SHA256' $FFMPEG_SHA256_WINDOWS if ($pinned) { $expected = $pinned $note = 'SHA256 pin' } else { $shaPath = Join-Path $tmp 'ffmpeg-essentials.zip.sha256' Save-File -Url $GYAN_ZIP_SHA_URL -OutFile $shaPath $line = Get-Content -Path $shaPath | Where-Object { $_ -match '\S' } | Select-Object -First 1 if (-not $line -or $line -notmatch '([0-9a-fA-F]{64})') { Die "Không parse được hash từ $GYAN_ZIP_SHA_URL (line: $line). Pin thủ công: xem hướng dẫn ở đầu script (LTSTREAM_FFMPEG_SHA256)." } $expected = $Matches[1] $note = 'upstream .sha256' } Save-File -Url $GYAN_ZIP_URL -OutFile $zipPath Assert-SHA256 -Path $zipPath -Expected $expected -SourceNote $note $extractDir = Join-Path $tmp 'extract' Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir # Zip gyan: /bin/{ffmpeg,ffprobe}.exe (verify 2026-08-17, xem comment pin) — # dò recursive để không phụ thuộc tên topdir theo version; ffprobe phải cùng dir ffmpeg. $ff = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'ffmpeg.exe' | Select-Object -First 1 if ($null -eq $ff) { Die 'Không tìm thấy ffmpeg.exe trong zip gyan.dev (cấu trúc zip đã đổi?)' } $fp = Join-Path $ff.DirectoryName 'ffprobe.exe' if (-not (Test-Path -LiteralPath $fp -PathType Leaf)) { Die "Không tìm thấy ffprobe.exe cạnh ffmpeg.exe ($($ff.DirectoryName))" } New-Item -ItemType Directory -Path $BIN_DIR -Force | Out-Null Copy-Item -LiteralPath $ff.FullName -Destination (Join-Path $BIN_DIR 'ffmpeg.exe') -Force Copy-Item -LiteralPath $fp -Destination (Join-Path $BIN_DIR 'ffprobe.exe') -Force Log "Đã cài ffmpeg/ffprobe vào $BIN_DIR" } function Ensure-Binaries { if (Test-BinariesResolved) { Log 'ffmpeg/ffprobe đã resolve — skip bước binaries' return } Log 'Plan: ensure ffmpeg/ffprobe (chưa resolve)' if (Ensure-BinariesWinget) { return } Warn 'Không có winget (hoặc winget thất bại) — dùng static upstream gyan.dev pinned' Ensure-StaticWindows } # --------------------------------------------------------------------------- # Version app đã cài: đọc registry Uninstall (NSIS của Tauri ghi DisplayVersion). # HKCU (installMode currentUser mặc định) + HKLM + WOW6432Node. function Get-InstalledAppVersion { $roots = @( 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' ) foreach ($root in $roots) { if (-not (Test-Path -LiteralPath $root)) { continue } foreach ($k in (Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue)) { if ("$($k.GetValue('DisplayName'))" -like '*LTStream*') { $dv = "$($k.GetValue('DisplayVersion'))" if ($dv) { return $dv } } } } return '' } # #232 (ADR-0016) kênh next: phiên bản có suffix "-next". Dùng chung cho # parser và luật skip/guard của Install-App. function Test-IsNext([string]$Version) { return ($Version -like '*-next') } # true nếu $Installed >= $Target theo SemVer có kênh next (#232): tách suffix # "-next" khỏi core x.y.z, so core bằng [version] ("0.10.0" > "0.9.0" đúng); # core bằng → stable (không suffix) >= next, next KHÔNG >= stable cùng core. # Core không parse được → $false (caller sẽ install lại — an toàn, như cũ). function Test-VersionAtLeast([string]$Installed, [string]$Target) { $a = $null; $b = $null if (-not [version]::TryParse((($Installed -split '-next')[0]), [ref]$a)) { return $false } if (-not [version]::TryParse((($Target -split '-next')[0]), [ref]$b)) { return $false } if ($a -ne $b) { return ($a -gt $b) } if (Test-IsNext $Installed) { return (Test-IsNext $Target) } return $true } function Install-App { $tmp = $null if ($NEXT_CHANNEL -and $TARGET_VERSION) { # ADR-0017: log ở mọi tổ hợp pin thắng next (đối xứng resolve_pin_over_next của install.sh) Log "Pin $TARGET_VERSION thắng kênh next — bỏ qua -Next/LTSTREAM_NEXT" } if ($TARGET_VERSION) { $targetVersion = $TARGET_VERSION # pin version: artifact name chuẩn theo layout bucket (mục 3.3) $artifact = "$BASE_URL/$targetVersion/LTStream_${targetVersion}_x64-setup.exe" } else { $feed = 'latest.json' # #232/ADR-0016: kênh next — feed riêng. $NEXT_CHANNEL đã resolve ở top # (-Next | env LTSTREAM_NEXT); pin $TARGET_VERSION (nhánh trên) vẫn thắng. if ($NEXT_CHANNEL) { $feed = 'latest-next.json' } Log "Fetch $BASE_URL/$feed" if ($DryRun) { return } $tmp = New-TempDir $latestPath = Join-Path $tmp $feed Save-File -Url "$BASE_URL/$feed" -OutFile $latestPath try { # Invoke-RestMethod parse JSON tự nhiên — KHÔNG sed/grep string $latest = Get-Content -Raw -Path $latestPath | ConvertFrom-Json } catch { Die "Không parse được ${feed}: $($_.Exception.Message)" } $versionProp = $latest.PSObject.Properties['version'] if (-not $versionProp -or -not "$($versionProp.Value)") { Die "Không parse được version từ $feed" } $targetVersion = "$($versionProp.Value)" $platformsProp = $latest.PSObject.Properties['platforms'] if (-not $platformsProp) { Die "$feed không có trường platforms" } $entry = $platformsProp.Value.PSObject.Properties[$JSON_KEY] if (-not $entry) { Die "Không có artifact cho platform $JSON_KEY trong $feed (build Windows chưa publish?)" } $urlProp = $entry.Value.PSObject.Properties['url'] if (-not $urlProp -or -not "$($urlProp.Value)") { Die "$feed thiếu url cho platform $JSON_KEY" } $artifact = "$($urlProp.Value)" } $installed = Get-InstalledAppVersion # #232 (ADR-0016) kênh next: bỏ idempotency-skip lẫn no-downgrade guard # khi target HOẶC installed là next — next rolling force-re-tag (cùng # version string, bytes khác) phải cài lại được; stable luôn cài đè # được next (next không được bảo vệ khỏi việc về stable). if (-not ((Test-IsNext $targetVersion) -or (Test-IsNext $installed))) { if ($installed -and $installed -eq $targetVersion) { Log "LTStream $installed đã cài đúng version — skip app" return } if ($installed -and ($installed -ne $targetVersion) -and (Test-VersionAtLeast $installed $targetVersion)) { Log "LTStream $installed đã MỚI HƠN target $targetVersion — skip app (installer không downgrade)" return } } Log "Plan: cài LTStream $targetVersion (hiện tại: $(if ($installed) { $installed } else { 'chưa cài' }))" Log " Artifact : $artifact" Log " Checksums: $BASE_URL/$targetVersion/SHA256SUMS" if ($DryRun) { return } if (-not $tmp) { $tmp = New-TempDir } $fname = ($artifact -split '/')[-1] $setup = Join-Path $tmp $fname Save-File -Url $artifact -OutFile $setup $sumsPath = Join-Path $tmp 'SHA256SUMS' Save-File -Url "$BASE_URL/$targetVersion/SHA256SUMS" -OutFile $sumsPath $want = $null foreach ($line in (Get-Content -Path $sumsPath)) { $parts = $line -split '\s+', 2 if ($parts.Count -eq 2 -and (($parts[1] -replace '^\*', '').Trim() -eq $fname)) { $want = $parts[0].Trim() break } } if (-not $want) { Die "$fname không có trong SHA256SUMS" } Assert-SHA256 -Path $setup -Expected $want -SourceNote 'SHA256SUMS' # NSIS silent install (Tauri installMode currentUser — không elevation) Log "Chạy NSIS silent install: $fname /S" $proc = Start-Process -FilePath $setup -ArgumentList '/S' -Wait -PassThru if ($proc.ExitCode -ne 0) { Die "setup.exe exit code $($proc.ExitCode) (≠ 0) — cài app thất bại" } Log "Đã cài LTStream $targetVersion" } # --------------------------------------------------------------------------- function Main { if ($env:OS -and $env:OS -notlike 'Windows*') { Warn "OS không phải Windows ($env:OS) — chỉ -DryRun có ý nghĩa ở đây" } if ($DryRun) { Log 'DRY-RUN: in plan, KHÔNG đổi máy' } Log "LTStream installer v$SCRIPT_VERSION — base: $BASE_URL" Ensure-Binaries if ($SKIP_APP) { Log 'LTSTREAM_SKIP_APP=1 — skip cài app' } else { Install-App } Log "Done. Static binaries dir (nếu dùng): $BIN_DIR" if (-not $SKIP_APP -and -not $DryRun) { Log 'Mở app: Start menu → LTStream (mặc định cài vào %LOCALAPPDATA%\Programs\LTStream)' } } try { Main } finally { if ($script:TmpRoot -and (Test-Path -LiteralPath $script:TmpRoot)) { Remove-Item -LiteralPath $script:TmpRoot -Recurse -Force -ErrorAction SilentlyContinue } $ProgressPreference = $script:PrevProgressPreference }