#Requires -Version 5.1 <# .SYNOPSIS Fluxer Windows installer — download + verify + instance URL + shortcut. .DESCRIPTION Windows port of install-fluxer-arch.sh. Same idea, different building blocks: pacman -> the pkgs.fluxer.com/desktop/... download endpoint, pacman-key -> the installer's own Authenticode signature, .desktop rewriting -> a Start Menu .lnk shortcut carrying --fluxer-app-url, and "grep the app.asar" -> Select-String, same as before. .EXAMPLE powershell -ExecutionPolicy Bypass -File .\install-fluxer-windows.ps1 ` -Edition fluxer-canary -Instance https://fluxer.example.com One-liner (defaults to https://fluxer.systux.xyz): powershell -ExecutionPolicy Bypass -c "irm https:///windows.ps1 | iex" .PARAMETER Edition fluxer | fluxer-canary (default: fluxer-canary). Maps to the release channel: fluxer -> stable, fluxer-canary -> canary. .PARAMETER Instance Your instance base URL (default: https://fluxer.systux.xyz, or $env:INSTANCE). Prompted if explicitly emptied and running interactively. .PARAMETER LauncherOnly Skip download/install, only (re)write the shortcut. Requires the app to already be installed (found via the uninstall registry key). .PARAMETER AssumeYes Never prompt. .PARAMETER Arch Force arch: x64 | arm64 (default: detected). .PARAMETER InstallDir Only honoured for a perMachine NSIS build (passed as the installer's /D= switch); oneClick per-user builds ignore it and install under %LOCALAPPDATA%\Programs. Left unset, the script finds wherever the installer actually put the app afterwards rather than guessing. .PARAMETER Portable Download the portable build instead of running the installer. .PARAMETER AllowHttp Allow a plain http:// instance URL (loopback is always allowed). .PARAMETER Verify Run the launch-based flag check even if the static probe of the app bundle can't confirm the flag exists. .PARAMETER DisableGpuSandbox Pass --disable-gpu-sandbox. Off by default — see the GPU section below for why you'd want it. .PARAMETER ExtraFlags Extra flags, space-separated, restricted charset. --enable-features= / --disable-features= are MERGED into ours (Chromium keeps only the last occurrence of each). .NOTES Windows Chromium hardware-decodes H.264/HEVC via D3D11 automatically on supported GPUs/drivers — there's no VA-API-style flag to enable it, so this script (like the macOS port) carries no per-format decode flags. The one real-world GPU flag that shows up in NVIDIA+Chromium bug reports is --disable-gpu-sandbox, a workaround for the GPU process sandbox conflicting with NVIDIA's driver-level overlay hooks (ShadowPlay/Overlay) on some driver versions; it weakens the sandbox, so it stays opt-in here exactly as on the Linux build. --fluxer-app-url is not documented publicly (checked Sep 2026). The script probes the installed app for it and warns if it isn't found. Only point the desktop app at an instance you control. #> [CmdletBinding()] param( [ValidateSet('fluxer', 'fluxer-canary')] [string]$Edition = $(if ($env:EDITION) { $env:EDITION } else { 'fluxer-canary' }), [string]$Instance = $(if ($env:INSTANCE) { $env:INSTANCE } else { 'https://fluxer.systux.xyz' }), [switch]$LauncherOnly = ($env:LAUNCHER_ONLY -eq '1' -or $env:LAUNCHER_ONLY -eq 'true'), [switch]$AssumeYes = ($env:ASSUME_YES -eq '1' -or $env:ASSUME_YES -eq 'true'), [string]$Arch = $env:ARCH, [string]$InstallDir = $env:INSTALL_DIR, [switch]$Portable = ($env:PORTABLE -eq '1' -or $env:PORTABLE -eq 'true'), [switch]$AllowHttp = ($env:ALLOW_HTTP -eq '1' -or $env:ALLOW_HTTP -eq 'true'), [switch]$Verify = ($env:VERIFY -eq '1' -or $env:VERIFY -eq 'true'), [switch]$DisableGpuSandbox = ($env:DISABLE_GPU_SANDBOX -eq '1' -or $env:DISABLE_GPU_SANDBOX -eq 'true'), [string]$ExtraFlags = $env:EXTRA_FLAGS ) $ErrorActionPreference = 'Stop' $Origin = 'https://pkgs.fluxer.com' $TmpFiles = New-Object System.Collections.Generic.List[string] function Info { param([string]$Msg) Write-Host "==> $Msg" -ForegroundColor Cyan } function Ok { param([string]$Msg) Write-Host " + $Msg" -ForegroundColor Green } function Warn { param([string]$Msg) Write-Host " ! $Msg" -ForegroundColor Yellow } function Die { param([string]$Msg) Write-Host " x $Msg" -ForegroundColor Red; Cleanup; exit 1 } function Cleanup { foreach ($f in $TmpFiles) { Remove-Item -Path $f -Force -ErrorAction SilentlyContinue } } trap { Cleanup; break } function Confirm-Action { param([string]$Question) if ($AssumeYes) { return $true } if (-not [Environment]::UserInteractive) { return $false } $ans = Read-Host "$Question [Y/n]" return -not ($ans -match '^[Nn]') } if ($env:OS -ne 'Windows_NT') { Die "this script is for Windows — use install-fluxer-arch.sh / install-fluxer-macos.sh elsewhere" } # ------------------------------------------------------------ preflight $Channel = if ($Edition -eq 'fluxer-canary') { 'canary' } else { 'stable' } if ([string]::IsNullOrWhiteSpace($Instance)) { if (-not [Environment]::UserInteractive) { Die "-Instance not set and no terminal to prompt on" } $Instance = Read-Host "Instance URL (e.g. https://fluxer.example.com)" } $Instance = $Instance.TrimEnd('/') # Restricted charset: keeps the shortcut's Arguments field free of characters # that would need extra quoting handling. No userinfo (@), no query/fragment, # no IPv6 literals — same restriction as the Linux/macOS scripts. $UrlRe = '^(?https?)://(?[A-Za-z0-9._-]+)(:[0-9]+)?(/[A-Za-z0-9._/-]*)?$' if ($Instance -notmatch $UrlRe) { Die "invalid instance URL: '$Instance'" } $UrlScheme = $Matches.scheme $UrlHost = $Matches.host if ($UrlScheme -eq 'http' -and -not $AllowHttp) { if ($UrlHost -notmatch '^(localhost|127\.[0-9]+\.[0-9]+\.[0-9]+)$') { Die "plain http:// is only accepted for loopback; use https:// or pass -AllowHttp" } } Info "Checking $Instance ..." try { $null = Invoke-WebRequest -Uri "$Instance/" -Method Head -TimeoutSec 10 -UseBasicParsing Ok "$Instance reachable" } catch { Warn "$Instance did not answer — continuing anyway (server may be down)" } $AppName = if ($Edition -eq 'fluxer-canary') { 'Fluxer Canary' } else { 'Fluxer' } $AppComment = if ($Edition -eq 'fluxer-canary') { 'Canary build of Fluxer' } else { 'Fluxer desktop' } # ---------------------------------------------------------------- install $InstalledExe = $null if (-not $LauncherOnly) { if (-not $Arch) { # PROCESSOR_ARCHITEW6432 is set when this is a 32-bit process on a # 64-bit OS (e.g. 32-bit PowerShell); prefer it when present. $rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } $Arch = switch ($rawArch) { 'AMD64' { 'x64' } 'ARM64' { 'arm64' } default { Die "unrecognized architecture: $rawArch — pass -Arch x64|arm64" } } } if ($Arch -notin 'x64', 'arm64') { Die "ARCH must be x64 | arm64 (got '$Arch')" } Info "Channel: $Channel Arch: $Arch" $Format = if ($Portable) { 'portable' } else { 'setup' } $ArtifactUrl = "$Origin/desktop/$Channel/win32/$Arch/latest/$Format" $ShaUrl = "$ArtifactUrl.sha256" $verJson = $null try { $verJson = Invoke-RestMethod -Uri "$Origin/desktop/$Channel/win32/$Arch/latest" -TimeoutSec 10 } catch { } if ($verJson -and $verJson.version) { Info "Latest $Channel version: $($verJson.version)" } Info "Downloading $Edition for win32/$Arch ($Format)..." $downloadFile = Join-Path $env:TEMP "fluxer-$([guid]::NewGuid()).download" $TmpFiles.Add($downloadFile) try { Invoke-WebRequest -Uri $ArtifactUrl -OutFile $downloadFile -TimeoutSec 300 -UseBasicParsing } catch { Die "download failed: $ArtifactUrl ($($_.Exception.Message))" } Ok ("downloaded {0:N1} MB" -f ((Get-Item $downloadFile).Length / 1MB)) Info "Verifying checksum..." $shaExpected = $null try { $shaText = Invoke-RestMethod -Uri $ShaUrl -TimeoutSec 15 if ($shaText -match '[0-9a-fA-F]{64}') { $shaExpected = $Matches[0] } } catch { } if (-not $shaExpected) { Warn "couldn't fetch/parse $ShaUrl — skipping checksum verification" } else { $shaActual = (Get-FileHash -Path $downloadFile -Algorithm SHA256).Hash if ($shaActual.ToLower() -ne $shaExpected.ToLower()) { Die "checksum mismatch: expected $shaExpected, got $shaActual" } Ok "checksum verified" } # Sanity-check the Authenticode signature too, when present, same spirit # as the Arch script refusing an unsigned/wrong-fingerprint keyring. try { $sig = Get-AuthenticodeSignature -FilePath $downloadFile if ($sig.Status -eq 'Valid') { Ok "Authenticode signature valid ($($sig.SignerCertificate.Subject))" } elseif ($sig.Status -eq 'NotSigned') { Warn "downloaded file is not Authenticode-signed" } else { Warn "Authenticode signature status: $($sig.Status)" } } catch { } # Detect zip vs a self-contained portable exe by magic bytes rather than # trusting the file extension, since the artifact URL has no extension. $magic = New-Object byte[] 2 $fs = [System.IO.File]::OpenRead($downloadFile); $fs.Read($magic, 0, 2) | Out-Null; $fs.Close() $isZip = ($magic[0] -eq 0x50 -and $magic[1] -eq 0x4B) # 'PK' $isExe = ($magic[0] -eq 0x4D -and $magic[1] -eq 0x5A) # 'MZ' if ($Portable) { $destDir = if ($InstallDir) { $InstallDir } else { Join-Path $env:LOCALAPPDATA "$AppName Portable" } New-Item -ItemType Directory -Force -Path $destDir | Out-Null if ($isZip) { $zipFile = "$downloadFile.zip" Rename-Item -Path $downloadFile -NewName (Split-Path -Leaf $zipFile) $TmpFiles.Add($zipFile) Info "Extracting portable build to $destDir..." Expand-Archive -Path $zipFile -DestinationPath $destDir -Force } elseif ($isExe) { Info "Portable build is a self-contained exe; copying to $destDir..." Copy-Item -Path $downloadFile -Destination (Join-Path $destDir "$AppName.exe") -Force } else { Die "unrecognized portable artifact format (not zip or exe)" } $InstalledExe = Get-ChildItem -Path $destDir -Filter '*.exe' -Recurse | Where-Object { $_.Name -notmatch 'unins' } | Select-Object -First 1 -ExpandProperty FullName Ok "portable install ready: $InstalledExe" } else { if (-not $isExe) { Die "expected an NSIS installer exe but got something else (magic bytes didn't match 'MZ')" } $setupExe = "$downloadFile.exe" Rename-Item -Path $downloadFile -NewName (Split-Path -Leaf $setupExe) $TmpFiles.Add($setupExe) Info "Running installer (silent)..." # NSIS typically auto-launches the app when it finishes, even silent. # Snapshot what's already running so we can close only what the # installer spawned: otherwise single-instance forwarding would swallow # our --fluxer-app-url flag on the next launch. $fluxerPidsBefore = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -like 'Fluxer*' } | Select-Object -ExpandProperty Id) # Same idea for Desktop icons the installer may drop (they launch # vanilla, without our flags) — snapshot so we only remove new ones. $desktopDirs = @( [Environment]::GetFolderPath('Desktop'), (Join-Path $env:PUBLIC 'Desktop') ) $desktopIconsBefore = @(foreach ($dd in $desktopDirs) { if (Test-Path $dd) { Get-ChildItem -Path $dd -Filter 'Fluxer*.lnk' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName } }) $installArgs = @('/S') if ($InstallDir) { Warn "-InstallDir only takes effect on a perMachine build; a oneClick installer ignores /D= and installs per-user" $installArgs += "/D=$InstallDir" # NSIS requires /D to be the final argument, unquoted } $proc = Start-Process -FilePath $setupExe -ArgumentList $installArgs -PassThru -Wait -ErrorAction SilentlyContinue if (-not $proc -or $proc.ExitCode -ne 0) { Warn "installer exited with code $($proc.ExitCode) — if this is a perMachine build, try re-running from an elevated (Run as Administrator) prompt" } else { Ok "$Edition installed" } # Ask the OS's own package registry where it went, same idea as the # Arch script using `pacman -Ql` instead of guessing paths. Start-Sleep -Seconds 1 $uninstallRoots = @( 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' ) $entry = Get-ItemProperty -Path $uninstallRoots -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "$AppName*" } | Select-Object -First 1 if ($entry) { $iconPath = if ($entry.DisplayIcon) { ($entry.DisplayIcon -split ',')[0] } else { $null } if ($iconPath -and (Test-Path $iconPath) -and $iconPath -like '*.exe') { $InstalledExe = $iconPath } elseif ($entry.InstallLocation -and (Test-Path $entry.InstallLocation)) { $InstalledExe = Get-ChildItem -Path $entry.InstallLocation -Filter '*.exe' -Recurse | Where-Object { $_.Name -notmatch 'unins' } | Select-Object -First 1 -ExpandProperty FullName } } if (-not $InstalledExe) { Warn "couldn't find the install via the uninstall registry key — falling back to a directory search" foreach ($base in @("$env:LOCALAPPDATA\Programs", $env:ProgramFiles, ${env:ProgramFiles(x86)})) { $cand = Get-ChildItem -Path $base -Filter '*.exe' -Recurse -ErrorAction SilentlyContinue -Depth 2 | Where-Object { $_.Directory.Name -like "$AppName*" -and $_.Name -notmatch 'unins' } | Select-Object -First 1 -ExpandProperty FullName if ($cand) { $InstalledExe = $cand; break } } } if (-not $InstalledExe) { Die "installed, but couldn't locate the app's exe afterwards" } Ok "found install: $InstalledExe" # Close processes the installer auto-started — never anything the user # already had open before we ran (single-instance would otherwise keep # serving the old, unflagged window). $spawned = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -like 'Fluxer*' -and $fluxerPidsBefore -notcontains $_.Id }) foreach ($p in $spawned) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } if ($spawned.Count -gt 0) { Ok "closed $($spawned.Count) app process(es) auto-started by the installer — your shortcut will launch clean" } # Remove Desktop icons the installer dropped (unflagged vanilla launch) # — only ones that appeared during this run, never pre-existing ones. $removedIcons = 0 foreach ($dd in $desktopDirs) { if (-not (Test-Path $dd)) { continue } foreach ($lnk in (Get-ChildItem -Path $dd -Filter 'Fluxer*.lnk' -ErrorAction SilentlyContinue)) { if ($desktopIconsBefore -notcontains $lnk.FullName) { Remove-Item -Path $lnk.FullName -Force -ErrorAction SilentlyContinue $removedIcons++ } } } if ($removedIcons -gt 0) { Ok "removed $removedIcons installer Desktop icon(s) (unflagged) — use the Start Menu shortcut" } } } else { $uninstallRoots = @( 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' ) $entry = Get-ItemProperty -Path $uninstallRoots -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "$AppName*" } | Select-Object -First 1 if ($entry -and $entry.DisplayIcon) { $InstalledExe = ($entry.DisplayIcon -split ',')[0] } if (-not $InstalledExe -or -not (Test-Path $InstalledExe)) { Die "-LauncherOnly given but no installed $AppName found via the uninstall registry key" } } Info "Executable: $InstalledExe" # ------------------------------------------------------------- GPU detection $gpus = Get-CimInstance -ClassName Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name $isNvidia = $gpus -match 'NVIDIA' $isIntel = $gpus -match 'Intel' $isAmd = $gpus -match 'AMD|ATI|Radeon' Info "GPU: $($gpus -join ', ')" if ($isNvidia -and $isIntel) { Warn "hybrid Intel+NVIDIA detected — Chromium's own GPU selection decides which renders; there's no equivalent to the Linux GPU= override here" } # ------------------------------------------------------------- build flags $Flags = New-Object System.Collections.Generic.List[string] $Flags.Add("--fluxer-app-url=$Instance") $Enable = New-Object System.Collections.Generic.List[string] $Disable = New-Object System.Collections.Generic.List[string] if ($DisableGpuSandbox) { if ($isNvidia) { Warn "-DisableGpuSandbox — the Chromium GPU sandbox will be disabled" } else { Warn "-DisableGpuSandbox passed without an NVIDIA GPU detected — this flag is mainly an NVIDIA workaround, applying anyway" } $Flags.Add('--disable-gpu-sandbox') } if ($ExtraFlags) { foreach ($tok in ($ExtraFlags -split '\s+' | Where-Object { $_ })) { if ($tok -notmatch '^[A-Za-z0-9._:/=,+-]+$') { Die "ExtraFlags token has characters that can't be safely passed as a shortcut Argument: '$tok'" } if ($tok -like '--enable-features=*') { $Enable.AddRange([string[]]($tok.Substring(18) -split ',')) } elseif ($tok -like '--disable-features=*') { $Disable.AddRange([string[]]($tok.Substring(19) -split ',')) } else { $Flags.Add($tok) } } } if ($Enable.Count) { $Flags.Add('--enable-features=' + ($Enable -join ',')) } if ($Disable.Count) { $Flags.Add('--disable-features=' + ($Disable -join ',')) } Info "Launch command: $InstalledExe $($Flags -join ' ')" # ------------------------------------------------- write Start Menu shortcut # A new, separate shortcut rather than overwriting the installer's own one: # unlike rewriting a Linux .desktop file (which is just a text file Claude # owns once it's under ~/.local/share/applications), the installer's Start # Menu entry is something Windows' own uninstaller/Start layout code also # expects to find unchanged, so it's safer to leave it alone and add ours # alongside it. $startMenuDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs' New-Item -ItemType Directory -Force -Path $startMenuDir | Out-Null $shortcutPath = Join-Path $startMenuDir "$AppName ($UrlHost).lnk" try { $wsh = New-Object -ComObject WScript.Shell $shortcut = $wsh.CreateShortcut($shortcutPath) $shortcut.TargetPath = $InstalledExe $shortcut.Arguments = ($Flags -join ' ') $shortcut.WorkingDirectory = Split-Path -Parent $InstalledExe $shortcut.IconLocation = "$InstalledExe,0" $shortcut.Description = "$AppComment (self-hosted: $Instance)" $shortcut.Save() Ok "wrote shortcut: $shortcutPath" } catch { Warn "couldn't create the shortcut ($($_.Exception.Message)) — launch manually with:" Warn " & `"$InstalledExe`" $($Flags -join ' ')" } # ------------------------------------------------- verify flags exist Info "Checking that the app knows the flags we pass..." $appDir = Split-Path -Parent $InstalledExe $asar = Join-Path $appDir 'resources\app.asar' if (-not (Test-Path $asar)) { $asar = Join-Path $appDir 'resources\app' } function Test-BundleHas { param([string]$Pattern) if (-not (Test-Path $asar)) { return $null } # can't tell try { return [bool](Select-String -Path $asar -Pattern ([regex]::Escape($Pattern)) -SimpleMatch -Quiet -ErrorAction Stop) } catch { return $null } } $hasUrlFlag = Test-BundleHas '--fluxer-app-url' if ($hasUrlFlag -eq $null) { $hasUrlFlag = Test-BundleHas 'fluxer-app-url' } switch ($hasUrlFlag) { $true { Ok "--fluxer-app-url found in the app bundle" } $false { Warn "--fluxer-app-url NOT found in the app bundle — the instance override will probably be ignored" } default { Warn "couldn't locate app.asar to check --fluxer-app-url (looked at $asar)" } } $hasDebugFlag = Test-BundleHas 'fluxer-debug-info' if ($hasDebugFlag -eq $true -or $Verify) { $procName = [System.IO.Path]::GetFileNameWithoutExtension($InstalledExe) if (Get-Process -Name $procName -ErrorAction SilentlyContinue) { Warn "$Edition is already running (single-instance) — skipping the launch-based check" } else { $outFile = Join-Path $env:TEMP "fluxer-debug-$([guid]::NewGuid()).txt" $TmpFiles.Add($outFile) try { $p = Start-Process -FilePath $InstalledExe ` -ArgumentList @("--fluxer-app-url=$Instance", '--fluxer-debug-info') ` -PassThru -RedirectStandardOutput $outFile -WindowStyle Hidden if (-not (Wait-Process -Id $p.Id -Timeout 15 -ErrorAction SilentlyContinue)) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } $dbg = if (Test-Path $outFile) { Get-Content $outFile -Raw } else { '' } if ($dbg -and $dbg.Contains($Instance)) { Ok "app reports the instance override: $Instance" } else { Warn "could not confirm the override from --fluxer-debug-info output" } } catch { Warn "launch-based check failed to run: $($_.Exception.Message)" } } } else { Info "skipping launch-based check (--fluxer-debug-info not found in the bundle; pass -Verify to force)" } Cleanup Write-Host "" Ok "Done. Quit any running $Edition first (tray -> Quit, single-instance!), then launch from:" Write-Host " $shortcutPath"