# Bolt Installer for Windows — https://sparcle.app/install.ps1 # Usage (run in PowerShell): # irm https://sparcle.app/install.ps1 | iex # Bolt (free), latest # $env:BOLT_VERSION='0.1.18'; irm https://sparcle.app/install.ps1 | iex # Pin a specific version # # Backwards-compat: $env:EDITION='personal', 'free', 'trial', or 'enterprise' # all accept and resolve to the same free Bolt build. # # What this does: # 1. Resolves the target version ($env:BOLT_VERSION > /releases/latest) # 2. Downloads the correct installer from GitHub Releases (with on-disk cache) # 3. Marks the file as trusted for Windows to run safely # 4. Runs the installer silently # 5. Launches the app # # Re-runs are network-free when the cached download still matches the remote # (per-version cache at %LOCALAPPDATA%\Sparcle\bolt-installer\v\ — # override with $env:BOLT_INSTALLER_CACHE_DIR). # # No admin required. Safe to re-run. param( [string]$Edition = "", [string]$Version = "" ) # Allow $env:EDITION as fallback (needed for irm | iex which can't pass params) if (-not $Edition) { $Edition = if ($env:EDITION) { $env:EDITION } else { "personal" } } # Same pattern for version pinning. if (-not $Version -and $env:BOLT_VERSION) { $Version = $env:BOLT_VERSION } $VersionPinned = [bool]$Version if ($Version) { $Version = $Version -replace '^v', '' } $ErrorActionPreference = "Stop" # ── Config ────────────────────────────────────────────────────────────────── $FallbackVersion = "0.1.0" $GitHubRepo = "SparcleHQ/sparcle.app" $DefaultBoltPgReleasesUrl = "https://sparcle.app" $DefaultBoltPgFallbackReleasesUrl = "" $DownloadRetryMax = 5 $DownloadRetryDelaySeconds = 2 $BoltApiReadyTimeoutSeconds = 120 $BoltApiPortBase = 13018 $BoltApiPortRange = 10 $PrewarmRetryAttempts = 3 $PrewarmRetryDelaySeconds = 2 $CacheBaseDir = if ($env:BOLT_INSTALLER_CACHE_DIR) { $env:BOLT_INSTALLER_CACHE_DIR } else { Join-Path $env:LOCALAPPDATA "Sparcle\bolt-installer" } $CacheKeepVersions = 2 # ── Resolve version (env/param > per-platform pointer > /releases/latest) ─── # Per-platform "latest" pointer: each OS/arch advances independently as its # build publishes (channel-stable/bolt-latest-windows-x86_64.json), mirroring # install.sh. Without this, Windows stayed on the previous version even after # its own windows-x86_64 build had fully published, because the global # /releases/latest flag only flips once EVERY platform (including a slow # cross-arch mac build) has published -- a whole-release gate, not a # per-platform one, that install.ps1 alone was blocking on. if (-not $Version) { $ChannelTag = if ($env:BOLT_CHANNEL_TAG) { $env:BOLT_CHANNEL_TAG } else { "channel-stable" } $PointerUrl = "https://github.com/$GitHubRepo/releases/download/$ChannelTag/bolt-latest-windows-x86_64.json" try { $Pointer = Invoke-RestMethod $PointerUrl -TimeoutSec 5 -ErrorAction Stop if ($Pointer.version) { $Version = ($Pointer.version -replace '^v', '') } } catch { # No pointer yet (new channel, network hiccup) -- fall through to the # global release below, same as install.sh's own fallback. } } if (-not $Version) { try { $Release = Invoke-RestMethod "https://api.github.com/repos/$GitHubRepo/releases/latest" -TimeoutSec 5 -ErrorAction Stop $Version = ($Release.tag_name -replace '^v', '') if (-not $Version) { throw "empty tag" } } catch { $Version = $FallbackVersion Write-Host " ⚠ Could not fetch latest version — using v$Version" -ForegroundColor Yellow } } $BaseUrl = "https://github.com/$GitHubRepo/releases/download/v$Version" # ── Helpers ───────────────────────────────────────────────────────────────── function Info($msg) { Write-Host " ==> " -ForegroundColor Blue -NoNewline; Write-Host $msg } function Ok($msg) { Write-Host " ✓ " -ForegroundColor Green -NoNewline; Write-Host $msg } function Warn($msg) { Write-Host " ⚠ $msg" -ForegroundColor Yellow } function Fail($msg) { Write-Host " ✗ " -ForegroundColor Red -NoNewline; Write-Host $msg; exit 1 } function Invoke-DownloadWithRetry { param( [string]$Url, [string]$OutFile, [string]$Label ) for ($attempt = 1; $attempt -le $DownloadRetryMax; $attempt++) { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop return } catch { if ($attempt -eq $DownloadRetryMax) { throw } Warn "Download retry $attempt/$DownloadRetryMax failed for $Label. Retrying..." Start-Sleep -Seconds $DownloadRetryDelaySeconds } } } function Get-RemoteAssetInfo { param([string]$Url) try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -TimeoutSec 30 -MaximumRedirection 10 -ErrorAction Stop $sizeHdr = $resp.Headers['Content-Length'] $etagHdr = $resp.Headers['ETag'] if ($sizeHdr -is [array]) { $sizeHdr = $sizeHdr[0] } if ($etagHdr -is [array]) { $etagHdr = $etagHdr[0] } $size = 0 if ($sizeHdr) { [void][int64]::TryParse([string]$sizeHdr, [ref]$size) } return @{ Size = $size; Etag = [string]$etagHdr } } catch { return $null } } # Walk back recent releases looking for the most recent one whose asset names # match a regex. Suffix examples: # '-x86_64-pc-windows-msvc\.exe$' # '-x86_64-pc-windows-msvc\.(exe|msi)$' # Used when the current /releases/latest tag is missing the user's installer # (partial ship in flight, or a single-asset 502'd silently like trial mac # arm64 on v0.1.31). Returns the tag (without leading v) or $null. function Find-LatestReleaseWithAsset { param( [string]$FilePrefixPattern, [string]$SuffixPattern ) try { $releases = Invoke-RestMethod "https://api.github.com/repos/$GitHubRepo/releases?per_page=10" -TimeoutSec 10 -ErrorAction Stop foreach ($rel in $releases) { foreach ($asset in $rel.assets) { if ($asset.name -match ("^" + $FilePrefixPattern + "-[0-9][0-9.]*" + $SuffixPattern)) { return ($rel.tag_name -replace '^v', '') } } } } catch { return $null } return $null } function Test-CacheFresh { param( [string]$Cached, [string]$Meta, [hashtable]$Remote ) if (-not (Test-Path $Cached)) { return $false } if (-not $Remote -or -not $Remote.Size -or $Remote.Size -le 1000) { return $false } $info = Get-Item $Cached -ErrorAction SilentlyContinue if (-not $info -or $info.Length -ne $Remote.Size) { return $false } if ((Test-Path $Meta) -and $Remote.Etag) { $savedEtag = "" foreach ($line in Get-Content $Meta -ErrorAction SilentlyContinue) { if ($line -match '^etag\t(.+)$') { $savedEtag = $Matches[1]; break } } if ($savedEtag -and $savedEtag -ne $Remote.Etag) { return $false } } return $true } function Save-CacheMeta { param( [string]$Meta, [hashtable]$Remote ) $lines = @() if ($Remote.Size) { $lines += "size`t$($Remote.Size)" } if ($Remote.Etag) { $lines += "etag`t$($Remote.Etag)" } $lines += "fetched_at`t$([DateTime]::UtcNow.ToString('o'))" Set-Content -Path $Meta -Value $lines -Encoding UTF8 } function Invoke-CacheGc { param([string]$CurrentVersionDir) if (-not (Test-Path $CacheBaseDir)) { return } try { $dirs = Get-ChildItem -Path $CacheBaseDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -like 'v*' -and $_.FullName -ne $CurrentVersionDir } | Sort-Object LastWriteTime -Descending $skip = [Math]::Max(0, $CacheKeepVersions - 1) $toDelete = $dirs | Select-Object -Skip $skip foreach ($d in $toDelete) { Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $d.FullName } } catch { # Best-effort GC — never fail the install over leftover cache. } } function Get-CachedOrDownload { param( [string]$Url, [string]$FileName, [string]$Label ) $verDir = Join-Path $CacheBaseDir "v$Version" New-Item -ItemType Directory -Force -Path $verDir | Out-Null $dlPath = Join-Path $verDir $FileName $metaPath = "$dlPath.meta" $partial = "$dlPath.partial" $remote = Get-RemoteAssetInfo -Url $Url if ($remote -and (Test-CacheFresh -Cached $dlPath -Meta $metaPath -Remote $remote)) { $sizeMb = [math]::Round((Get-Item $dlPath).Length / 1MB, 1) Ok "Using cached $Label (${sizeMb}MB) — skipping download" return @{ Path = $dlPath; VersionDir = $verDir } } Info "Downloading $Label..." if (Test-Path $partial) { Remove-Item -Force $partial -ErrorAction SilentlyContinue } Invoke-DownloadWithRetry -Url $Url -OutFile $partial -Label $Label if (-not (Test-Path $partial) -or (Get-Item $partial).Length -lt 1000) { Remove-Item -Force $partial -ErrorAction SilentlyContinue throw "Downloaded file is missing or too small." } Move-Item -Force $partial $dlPath if (-not $remote) { $remote = Get-RemoteAssetInfo -Url $Url } if (-not $remote) { $remote = @{} } # Authoritative size = what we wrote to disk. $remote.Size = (Get-Item $dlPath).Length Save-CacheMeta -Meta $metaPath -Remote $remote $sizeMb = [math]::Round((Get-Item $dlPath).Length / 1MB, 1) Ok "Downloaded ${sizeMb}MB" return @{ Path = $dlPath; VersionDir = $verDir } } function Wait-ApiReadiness { param( [int]$TimeoutSeconds = 120 ) $portEnd = $BoltApiPortBase + $BoltApiPortRange - 1 # Try plaintext http:// first, then https:// (skipping cert validation for # the locally-generated sidecar cert) since the API may be in TLS mode. $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() while ($stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { for ($port = $BoltApiPortBase; $port -le $portEnd; $port++) { foreach ($path in @("/api/health", "/health")) { foreach ($scheme in @("http", "https")) { try { $url = "${scheme}://127.0.0.1:$port$path" $iwrParams = @{ Uri = $url UseBasicParsing = $true TimeoutSec = 2 ErrorAction = "Stop" } if ($scheme -eq "https" -and (Get-Command Invoke-WebRequest).Parameters.ContainsKey("SkipCertificateCheck")) { $iwrParams.SkipCertificateCheck = $true } $response = Invoke-WebRequest @iwrParams if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300) { return $url } } catch { # keep probing } } } } Start-Sleep -Seconds 1 } return $null } function Verify-RuntimeContract { if ($env:BOLT_SKIP_API_HEALTH_CHECK -eq "1") { Warn "Skipping API health verification (BOLT_SKIP_API_HEALTH_CHECK=1)" return } Info "Verifying API runtime readiness..." $readyUrl = Wait-ApiReadiness -TimeoutSeconds $BoltApiReadyTimeoutSeconds if ($readyUrl) { Ok "API is healthy at $readyUrl" return } Fail "Install completed, but API readiness check failed (tried /api/health and /health on ports $BoltApiPortBase-$($BoltApiPortBase + $BoltApiPortRange - 1) for ${BoltApiReadyTimeoutSeconds}s)." } function Select-ReleaseAsset { param( [string]$DesiredExt ) $script:FileName = "$FilePrefix-$Version-$Arch.$DesiredExt" $script:FileUrl = "$BaseUrl/$($script:FileName)" } function Configure-PgRuntimeSources { # Keep any user-provided env vars unchanged; only set defaults when absent. if (-not $env:BOLT_PG_RELEASES_URL) { $env:BOLT_PG_RELEASES_URL = $DefaultBoltPgReleasesUrl } if (-not $env:BOLT_PG_FALLBACK_RELEASES_URL -and $DefaultBoltPgFallbackReleasesUrl) { $env:BOLT_PG_FALLBACK_RELEASES_URL = $DefaultBoltPgFallbackReleasesUrl } Info "PostgreSQL source (primary): $($env:BOLT_PG_RELEASES_URL)" if ($env:BOLT_PG_FALLBACK_RELEASES_URL) { Info "PostgreSQL source (fallback): $($env:BOLT_PG_FALLBACK_RELEASES_URL)" } else { Info "PostgreSQL source (fallback): " } } function Save-PgRuntimeSources { param( [string]$AppIdentifier ) $configDir = Join-Path $env:APPDATA "$AppIdentifier\embedded-postgres" New-Item -ItemType Directory -Force -Path $configDir | Out-Null $configFile = Join-Path $configDir "sources.env" $lines = @( "# Generated by Bolt installer", "BOLT_PG_RELEASES_URL=$($env:BOLT_PG_RELEASES_URL)" ) if ($env:BOLT_PG_FALLBACK_RELEASES_URL) { $lines += "BOLT_PG_FALLBACK_RELEASES_URL=$($env:BOLT_PG_FALLBACK_RELEASES_URL)" } Set-Content -Path $configFile -Value $lines -Encoding UTF8 Info "Persisted PostgreSQL sources config: $configFile" } function Stop-BoltProcesses { # NSIS /S silently skips files that are in use, so an "upgrade" over a # running Bolt is a no-op for any locked binary — most painfully the # bolt-api sidecar, which stays at the first-installed version forever # while the user sees "Installed successfully" on every retry. # Kill known image names defensively before invoking the installer. $names = @('bolt', 'bolt-api', 'Bolt', 'Bolt Personal', 'Bolt Enterprise') $killed = $false foreach ($n in $names) { $procs = Get-Process -Name $n -ErrorAction SilentlyContinue if ($procs) { $procs | Stop-Process -Force -ErrorAction SilentlyContinue $killed = $true } } if ($killed) { # Wait for the processes to actually exit and release their file locks # before the installer replaces the bundle. Stop-Process -Force is async # w.r.t. handle release; without this the new bolt-api binary can fail to # copy (silent partial upgrade), and a lingering sidecar keeps the embedded # Postgres cluster busy during first launch. $deadline = (Get-Date).AddSeconds(8) while ((Get-Process -Name $names -ErrorAction SilentlyContinue) -and ((Get-Date) -lt $deadline)) { Start-Sleep -Milliseconds 300 } Info "Stopped running Bolt processes so files can be replaced" } } function Is-Administrator { try { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } function Invoke-PostgresPrewarm { param( [string]$ExecutablePath, [string]$AppIdentifier ) if (-not (Test-Path $ExecutablePath)) { Write-Host " ⚠ Skipping PostgreSQL prewarm: executable not found at $ExecutablePath" -ForegroundColor Yellow return } Info "Prewarming embedded PostgreSQL (user-space, one-time setup)..." $previous = [Environment]::GetEnvironmentVariable("BOLT_APP_IDENTIFIER", "Process") try { [Environment]::SetEnvironmentVariable("BOLT_APP_IDENTIFIER", $AppIdentifier, "Process") for ($attempt = 1; $attempt -le $PrewarmRetryAttempts; $attempt++) { $proc = Start-Process -FilePath $ExecutablePath -ArgumentList "prewarm-postgres" -WindowStyle Hidden -PassThru -Wait if ($proc.ExitCode -eq 0) { Ok "Embedded PostgreSQL prewarm complete" return } if ($attempt -lt $PrewarmRetryAttempts) { Warn "Embedded PostgreSQL prewarm attempt $attempt/$PrewarmRetryAttempts failed (exit $($proc.ExitCode)); retrying..." Start-Sleep -Seconds $PrewarmRetryDelaySeconds } else { Write-Host " ⚠ Embedded PostgreSQL prewarm failed after $PrewarmRetryAttempts attempts (app will retry on first launch) [exit $($proc.ExitCode)]" -ForegroundColor Yellow } } } catch { Write-Host " ⚠ Embedded PostgreSQL prewarm failed (app will retry on first launch): $($_.Exception.Message)" -ForegroundColor Yellow } finally { [Environment]::SetEnvironmentVariable("BOLT_APP_IDENTIFIER", $previous, "Process") } } # ── Parse edition ─────────────────────────────────────────────────────────── # Bolt is one product (Bolt Enterprise) shipped as one binary per platform, # free to download for everyone. Legacy `personal`, `free`, `trial` argument # values are accepted as no-op aliases so any bookmarked one-liner keeps # working. Bundle id + installed-app folder are app.sparcle.bolt.enterprise. switch ($Edition.ToLower()) { { $_ -in "enterprise", "personal", "free", "trial" } { $Edition = "enterprise" # THREE names, deliberately: $DisplayName is what a human reads, $AppName is # the LEGACY install-directory / Start-Menu name kept so a pre-rename machine # is still found, and $FilePrefix is the frozen GitHub release-asset prefix. # Only echoed text uses $DisplayName; asset URLs and the update feed are # untouched by it. # # NOTE the asymmetry with install.sh, which spells the human-visible name # APP_NAME="Bolt" and has no DISPLAY_NAME. It can: macOS/Linux need no # legacy-name variable, because the old bundle names are listed literally at # the one place that removes them. Windows needs $AppName to stay # "Bolt Enterprise" so $ProductDirNames below still searches the old # directory — that line is pinned by the_curl_installer_agrees_with_the_owner # in src-tauri/src/product_name.rs, so do not "simplify" it to one name. $AppName = "Bolt Enterprise" $DisplayName = "Bolt" $FilePrefix = "Bolt-Enterprise" $AppIdentifier = "app.sparcle.bolt.enterprise" } default { Fail "Unknown edition: $Edition. Bolt is free; just run without arguments." } } Configure-PgRuntimeSources # ── Detect architecture ──────────────────────────────────────────────────── $RealArch = $env:PROCESSOR_ARCHITECTURE $Arch = if ([Environment]::Is64BitOperatingSystem) { if ($RealArch -eq "ARM64") { # x86_64 MSI runs fine on Windows ARM64 via built-in emulation "x86_64-pc-windows-msvc" } else { "x86_64-pc-windows-msvc" } } else { Fail "32-bit Windows is not supported." } # Prefer the NSIS .exe installer for consumer `irm | iex` flows: it is built # `currentUser` (bundle.windows.nsis.installMode in both tauri configs), so it # carries RequestExecutionLevel user, installs into $LOCALAPPDATA\Bolt and # raises NO UAC prompt — which is what lets a standard user with no # administrator rights install Bolt at all. It used to be `perMachine`, and a # non-admin simply could not get past the elevation prompt. # # The MSI is per-machine (Tauri v2 emits no per-user MSI) and is the FLEET path: # Intune/SCCM run it as SYSTEM, and docs/enterprise/unattended-install.md points # administrators at it. Reachable here via $env:BOLT_INSTALL_MSI, or when the # .exe 404s — both of which self-elevate below. $PreferMsi = [bool]$env:BOLT_INSTALL_MSI $InstallerExt = if ($PreferMsi) { "msi" } else { "exe" } Select-ReleaseAsset -DesiredExt $InstallerExt # Per-platform walk-back: if v$Version doesn't have either the .exe or .msi for # this edition (partial ship, or a single-asset silent 502 — the same failure # that hit trial mac arm64 on v0.1.31), walk back recent releases to find one # that does. Skipped when the user pinned a version via $env:BOLT_VERSION or # -Version arg. if (-not $VersionPinned) { $primaryInfo = Get-RemoteAssetInfo -Url $FileUrl if (-not $primaryInfo) { # Try the other extension first within the same release. $altExt = if ($InstallerExt -eq "exe") { "msi" } else { "exe" } $altUrl = "$BaseUrl/$FilePrefix-$Version-$Arch.$altExt" if (-not (Get-RemoteAssetInfo -Url $altUrl)) { Warn "v$Version has no Windows installer for $DisplayName — checking earlier releases..." $prefixPattern = [regex]::Escape($FilePrefix) $suffixPattern = "-" + [regex]::Escape($Arch) + "\.(exe|msi)$" $fallback = Find-LatestReleaseWithAsset -FilePrefixPattern $prefixPattern -SuffixPattern $suffixPattern if ($fallback -and $fallback -ne $Version) { Warn "Installing v$fallback (latest v$Version is mid-ship for Windows)" $Version = $fallback $BaseUrl = "https://github.com/$GitHubRepo/releases/download/v$Version" Select-ReleaseAsset -DesiredExt $InstallerExt } } } } Write-Host "" Write-Host " ⚡ Bolt Installer" -ForegroundColor Cyan Write-Host " ─────────────────────────────────────" Write-Host " Edition: $DisplayName" Write-Host " Version: $Version" Write-Host " Architecture: $Arch" Write-Host " Installer: $InstallerExt" Write-Host "" # ── Download (with per-version cache) ─────────────────────────────────────── # Cache layout: $CacheBaseDir\v\ + sidecar .meta with # size/etag. Re-running this script with the same version is a no-op for the # network when the cached file still matches the remote. try { $cacheResult = Get-CachedOrDownload -Url $FileUrl -FileName $FileName -Label $FileName $DlPath = $cacheResult.Path $VersionDir = $cacheResult.VersionDir } catch { if ($InstallerExt -eq "exe") { Warn "NSIS .exe not available for v$Version — falling back to MSI..." $InstallerExt = "msi" Select-ReleaseAsset -DesiredExt $InstallerExt try { $cacheResult = Get-CachedOrDownload -Url $FileUrl -FileName $FileName -Label $FileName $DlPath = $cacheResult.Path $VersionDir = $cacheResult.VersionDir } catch { Fail "Download failed: $FileName not found.`n $DisplayName may not be available for $Arch yet.`n Check https://sparcle.app/download for supported platforms." } } else { Fail "Download failed: $FileName not found.`n $DisplayName may not be available for $Arch yet.`n Check https://sparcle.app/download for supported platforms." } } # Prune older version caches in the background. Invoke-CacheGc -CurrentVersionDir $VersionDir # ══════════════════════════════════════════════════════════════════════════════ # Integrity verification of the downloaded artifact # ══════════════════════════════════════════════════════════════════════════════ # Mirrors install.sh: SHA-256 against the release's SHA256SUMS (zero new deps), # plus minisign authenticity when available. A mismatch aborts and deletes the # file. "Neither available" (older release, or no minisign + no SHA256SUMS) warns # and continues so existing releases still install — unless # $env:BOLT_REQUIRE_VERIFICATION = "1" (recommended for locked-down/MDM fleets). $BoltMinisignPubKey = "RWSd12rmLcdOLGl9yZ2hL7tigihN0ZGT923La8KNXaLQfW3lsSsPom0Q" # Decode a Tauri-format detached signature into a raw minisign .minisig. # Tauri writes the .sig base64-ENCODED (its bytes are the base64 of a raw # minisign signature whose first line is "untrusted comment:"); `minisign -V` # needs that raw form. This mirrors install.sh's decode_tauri_sig — WITHOUT it # the wrapped file is handed straight to minisign, which rejects it and # false-aborts a genuine download (the exact bug install.sh already fixed). # Returns $true and writes $OutPath only when the result looks like a real # minisign signature; on $false the caller SKIPS minisign rather than verifying # against a mangled sig. function Decode-TauriSig { param([string]$InPath, [string]$OutPath) try { $raw = [System.IO.File]::ReadAllText($InPath) if ($raw -match '^untrusted comment:') { # Already raw (defensive; a future release may ship the sig unwrapped). Copy-Item -LiteralPath $InPath -Destination $OutPath -Force } else { $bytes = [Convert]::FromBase64String(($raw -replace '\s', '')) [System.IO.File]::WriteAllBytes($OutPath, $bytes) } return ((Test-Path $OutPath) ` -and ((Get-Content -LiteralPath $OutPath -TotalCount 1) -match '^untrusted comment:')) } catch { return $false } } function Verify-Download { param([string]$Path) if (-not (Test-Path $Path)) { Fail "Internal error: downloaded file missing before verification." } $fname = Split-Path -Leaf $Path $verified = $false $method = "" # Layer 1 — SHA-256 against the release's SHA256SUMS. $sumsPath = Join-Path $env:TEMP "BoltSHA256SUMS-$Version.txt" $haveSums = $false try { Invoke-WebRequest -Uri "$BaseUrl/SHA256SUMS" -OutFile $sumsPath -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop $haveSums = (Test-Path $sumsPath) -and ((Get-Item $sumsPath).Length -gt 0) } catch { $haveSums = $false } if ($haveSums) { $expected = $null foreach ($line in Get-Content $sumsPath) { $parts = $line -split '\s+', 2 if ($parts.Count -eq 2) { $name = $parts[1].TrimStart('*').Trim() if ($name -eq $fname) { $expected = $parts[0].Trim().ToLower(); break } } } if ($expected) { $actual = (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLower() if ($actual -eq $expected) { $verified = $true; $method = "SHA-256" } else { Remove-Item $Path -Force -ErrorAction SilentlyContinue Fail "Integrity check FAILED for ${fname}.`n expected $expected`n got $actual`n The download is corrupted or has been tampered with - do not run it.`n Re-download from https://sparcle.app/download; if it fails again, contact security@sparcle.app." } } } # Layer 2 — minisign authenticity (best-effort; stronger than a checksum). if (Get-Command minisign -ErrorAction SilentlyContinue) { $sigPath = "$Path.sig" $rawSigPath = "$Path.minisig" $haveSig = $false try { Invoke-WebRequest -Uri "$BaseUrl/$fname.sig" -OutFile $sigPath -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop $haveSig = (Test-Path $sigPath) -and ((Get-Item $sigPath).Length -gt 0) } catch { $haveSig = $false } # Tauri ships the .sig base64-wrapped; minisign needs the raw form. If it # can't be decoded, SKIP minisign rather than feed the wrapped file to # `minisign -V`, which would reject it and false-abort a genuine download. if ($haveSig -and (Decode-TauriSig -InPath $sigPath -OutPath $rawSigPath)) { & minisign -V -P $BoltMinisignPubKey -m $Path -x $rawSigPath *> $null if ($LASTEXITCODE -eq 0) { if ($method) { $method = "$method + minisign" } else { $method = "minisign" } $verified = $true } else { Remove-Item $Path -Force -ErrorAction SilentlyContinue Fail "Signature verification FAILED for $fname - it is NOT authentically signed by Sparcle.`n Do not run it. Re-download from https://sparcle.app/download or contact security@sparcle.app." } } } if ($verified) { Ok "Verified $fname ($method)" } else { Warn "Could not verify ${fname}: no signature/checksum available for this release." if (-not (Get-Command minisign -ErrorAction SilentlyContinue)) { Warn "Install 'minisign' (winget install jedisct1.minisign) for signature verification." } if ($env:BOLT_REQUIRE_VERIFICATION -eq "1") { Remove-Item $Path -Force -ErrorAction SilentlyContinue Fail "BOLT_REQUIRE_VERIFICATION=1 is set but $fname could not be verified - aborting." } } } # Verify before we trust/install/run anything from the download. Verify-Download -Path $DlPath # ── Mark as trusted for Windows to run safely ─────────────────────────────── Info "Marking $DisplayName as trusted..." Unblock-File -Path $DlPath -ErrorAction SilentlyContinue Ok "Installer trusted — no SmartScreen warnings" # ── Install ───────────────────────────────────────────────────────────────── Stop-BoltProcesses Info "Installing $DisplayName..." $isAdmin = Is-Administrator if ($InstallerExt -eq "exe") { # NSIS installer — /S for silent. The installer is `currentUser`, so it raises # no UAC prompt and needs no administrator rights; it installs into # $LOCALAPPDATA\Bolt for whoever is running it, admin or not. $proc = Start-Process -FilePath $DlPath -ArgumentList "/S" -Wait -PassThru if ($proc.ExitCode -eq 0) { Ok "Installed successfully (for $env:USERNAME — no administrator rights needed)" } elseif ($proc.ExitCode -eq 1223) { # 1223 = ERROR_CANCELLED. A currentUser installer does not elevate, so this # should now be unreachable; it is kept because an older cached .exe, or a # machine whose policy forces elevation for anything named *setup*, can # still produce it. Do not word it as "the UAC prompt" — see above. Fail "Installation was cancelled before it finished.`n Try running the installer manually: $DlPath" } else { Fail "Installation failed (exit code $($proc.ExitCode)).`n Try running the installer manually: $DlPath" } } else { # Prefer per-user install so non-admin users can install successfully; # but Tauri MSIs are perMachine and will reject ALLUSERS=2 with code 1625. # On that, relaunch this install step elevated via UAC. $perUserArgs = "/i `"$DlPath`" /quiet /norestart ALLUSERS=2 MSIINSTALLPERUSER=1" $proc = Start-Process msiexec.exe -ArgumentList $perUserArgs -Wait -PassThru if ($proc.ExitCode -eq 0) { Ok "Installed successfully (single-user mode)" } elseif ($proc.ExitCode -in 1625, 1603, 1925 -and -not $isAdmin) { # Tauri MSIs are perMachine-only — Windows rejected the per-user install # attempt. Self-elevate and retry as machine-wide. UAC prompts once. Info "Per-user MSI rejected (exit $($proc.ExitCode)); retrying as Administrator via UAC..." $elevProc = Start-Process msiexec.exe -Verb RunAs -ArgumentList "/i `"$DlPath`" /passive /norestart ALLUSERS=1" -Wait -PassThru if ($elevProc.ExitCode -ne 0) { Fail "Elevated install failed (exit code $($elevProc.ExitCode)). Try: msiexec /i `"$DlPath`" /passive" } Ok "Installed successfully (all-users mode via UAC)" } elseif ($proc.ExitCode -eq 1925 -and $isAdmin) { Info "Per-user install failed in admin context, retrying machine-wide..." $machineArgs = "/i `"$DlPath`" /quiet /norestart ALLUSERS=1" $proc = Start-Process msiexec.exe -ArgumentList $machineArgs -Wait -PassThru if ($proc.ExitCode -ne 0) { Fail "Installation failed (exit code $($proc.ExitCode))." } Ok "Installed successfully (all-users mode)" } else { Fail "Installation failed (exit code $($proc.ExitCode))." } } # ── Persist runtime config ───────────────────────────────────────────────── # (download cache under $CacheBaseDir is intentionally preserved for re-runs) Save-PgRuntimeSources -AppIdentifier $AppIdentifier # ── Launch ───────────────────────────────────────────────────────────────── # Tauri uses the Cargo crate name for the binary (`bolt.exe`), not the # productName — same quirk Mac install.sh documents at install_macos(). # The install *directory* is productName ("Bolt Enterprise", "Bolt Personal", # "Bolt"); the .exe inside is always `bolt.exe`. $ExeName = "bolt.exe" # WHERE THE INSTALLER ACTUALLY PUT IT — every layout we have ever shipped, most # likely first. Getting this list wrong does not fail loudly: $ExePath stays # $null, the Postgres prewarm is skipped and we fall through to hunting a Start # Menu shortcut, so the app still launches and the miss is invisible. # # %LOCALAPPDATA%\ the CURRENT NSIS installer. `currentUser` # mode is `$INSTDIR = $LOCALAPPDATA\ # ${PRODUCTNAME}` — note there is NO # "Programs" segment, which is the easy thing # to assume and the reason this list is # spelled out rather than derived. # %LOCALAPPDATA%\Programs\ older per-user layout. # %ProgramFiles%\ the MSI, and every NSIS build before the # installer became per-user. # # The directory is `productName`, which is "Bolt"; $AppName is "Bolt Enterprise" # (the edition label and asset prefix) and is NOT the folder. Both names are # tried so this keeps working on a machine that still carries a pre-rename # install — the same forward/backward tolerance install.sh applies on macOS. $ProductDirNames = @("Bolt", $AppName) | Select-Object -Unique $SearchRoots = @() foreach ($n in $ProductDirNames) { $SearchRoots += (Join-Path $env:LOCALAPPDATA $n) $SearchRoots += (Join-Path (Join-Path $env:LOCALAPPDATA "Programs") $n) $SearchRoots += (Join-Path $env:ProgramFiles $n) } $ExePath = $null foreach ($root in $SearchRoots) { $candidate = Join-Path $root $ExeName if (Test-Path $candidate) { $ExePath = Get-Item $candidate break } } if ($ExePath) { # Backward-compatible cleanup of the pre-rename install dir. This runs ONLY # when the exe we just found lives in a "Bolt" directory — i.e. the renamed # build is installed and verified — so on a machine still carrying the old # layout it is a no-op rather than a directory deletion. Data and credentials # are untouched: they live under the app id (app.sparcle.bolt.enterprise), # not in the install dir. $InstalledRoot = Split-Path -Parent $ExePath.FullName if ((Split-Path -Leaf $InstalledRoot) -eq 'Bolt') { $LegacyParents = @( $env:LOCALAPPDATA, (Join-Path $env:LOCALAPPDATA 'Programs'), $env:ProgramFiles ) foreach ($parent in $LegacyParents) { if (-not $parent) { continue } $LegacyRoot = Join-Path $parent 'Bolt Enterprise' if ((Test-Path $LegacyRoot) -and ($LegacyRoot -ne $InstalledRoot)) { Info "Removing legacy install dir $LegacyRoot (data and credentials preserved, same app id)..." Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $LegacyRoot } } } Invoke-PostgresPrewarm -ExecutablePath $ExePath.FullName -AppIdentifier $AppIdentifier Info "Launching $DisplayName..." Start-Process $ExePath.FullName Verify-RuntimeContract } else { # Fallback: try Start Menu shortcut in both user and machine locations. # NSIS perMachine installs put shortcuts under %ProgramData%, not %APPDATA%. $ShortcutRoots = @( (Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs'), (Join-Path $env:ProgramData 'Microsoft\Windows\Start Menu\Programs') ) $Shortcut = $null foreach ($r in $ShortcutRoots) { if (Test-Path $r) { foreach ($n in $ProductDirNames) { $Shortcut = Get-ChildItem $r -Filter "$n.lnk" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 if ($Shortcut) { break } } if ($Shortcut) { break } } } if ($Shortcut) { Info "Launching $DisplayName..." Start-Process $Shortcut.FullName Verify-RuntimeContract } else { Info "Installation complete — launch $DisplayName from the Start Menu." } } Write-Host "" Write-Host " ✅ $DisplayName is ready!" -ForegroundColor Green Write-Host "" switch ($Edition) { "personal" { Write-Host " Next: Add your AI API key in Settings → AI Configuration" Write-Host " Tip: Google Gemini has a free tier — works great with Bolt" } "trial" { Write-Host " Next: Try instant demo mode, or configure your own IDP + LLM" Write-Host " All features unlocked - free for individuals" } } Write-Host ""