1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# Builds the Windows app and its core and puts them over the installed copy
# (the Setup's per-user install, in the folder chosen at install time; by
# default %LOCALAPPDATA%\Programs\Firefox VPN Client), to try a change
# without making and running an installer:
# - a running copy is closed first: the app by its process id, after which
# its core restores the system proxy and exits, as when the app quits;
# - user-data in the install folder (accounts, settings, logs) is never
# touched;
# - a copy that was running is started again, and connected again if it
# was connected;
# - an installed TUN service is asked to replace itself with the new one,
# which it does when the build is signed with its key (the developer key
# in FIREFOX_VPN_SIGNING_KEY_FILE; scripts\signing-key.ps1). The core gets
# the same key: the app then installs only updates signed with it.
# The version is "major.minor.patch" from app\pubspec.yaml, or -AppVersion: an
# older one makes the app find the latest release as an update, to try the
# update notices. Nothing is written to dist\; scripts\build-windows.ps1
# makes the release files.
#Requires -Version 7
param([string]$AppVersion)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$match = Select-String -Path "$root\app\pubspec.yaml" -Pattern '^version:\s*(\d+\.\d+\.\d+)'
if (-not $match) { throw 'No version in app\pubspec.yaml' }
$version = $match.Matches[0].Groups[1].Value
if ($AppVersion) { $version = $AppVersion }
# The install folder, from the Setup's uninstall entry (AppId in
# app\windows\installer\firefox-vpn.iss).
$uninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{6F3A2C1E-9B4D-4E7A-8C21-5D0F7B9E4A63}_is1'
$dir = (Get-ItemProperty $uninstallKey -ErrorAction SilentlyContinue).InstallLocation
if (-not $dir) { throw 'Firefox VPN is not installed for this user; run the Setup once' }
$dir = $dir.TrimEnd('\')
$appExe = "$dir\firefox-vpn.exe"
$dataDir = "$dir\user-data"
# The same build as scripts\build-windows.ps1, the core into app\build.
Push-Location "$root\app"
try {
foreach ($stale in "build\flutter_assets", "build\windows\x64\runner\Release\data\flutter_assets") {
if (Test-Path $stale) { Remove-Item -LiteralPath $stale -Recurse -Force }
}
flutter pub get --offline
if ($LASTEXITCODE -ne 0) { flutter pub get }
if ($LASTEXITCODE -ne 0) { throw 'flutter pub get failed' }
flutter build windows --release --no-pub --dart-define=APP_VERSION=$version
if ($LASTEXITCODE -ne 0) { throw 'flutter build failed' }
} finally { Pop-Location }
$release = "$root\app\build\windows\x64\runner\Release"
$coreOut = "$root\app\build\installed-core"
$public = & "$PSScriptRoot\signing-key.ps1"
Push-Location "$root\core"
try {
$env:APP_VERSION = $version
go generate ./cmd/firefox-vpn-core
if ($LASTEXITCODE -ne 0) { throw 'go generate failed' }
$ldflags = "-s -w -X github.com/armanlife/firefox-vpn-client/core/internal/version.Current=$version"
if ($public) { $ldflags += " -X github.com/armanlife/firefox-vpn-client/core/internal/signing.Key=$public" }
go build -trimpath -ldflags $ldflags -o "$coreOut\firefox-vpn-core.exe" ./cmd/firefox-vpn-core
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
} finally {
Remove-Item Env:APP_VERSION -ErrorAction SilentlyContinue
Pop-Location
}
& "$PSScriptRoot\build-windows-tun.ps1" -Version $version -Out $coreOut -PublicKey $public
# The core's API, as the app reaches it: the address from config.json and
# the local token. The token is only sent, never shown.
function Invoke-Core([string]$Method, [string]$Path, [hashtable]$Body) {
$listen = '127.0.0.1:10820'
try {
$config = Get-Content "$dataDir\config.json" -Raw | ConvertFrom-Json
if ($config.dashboard.listen) { $listen = $config.dashboard.listen }
} catch {}
$port = $listen.Substring($listen.LastIndexOf(':') + 1)
$token = (Get-Content "$dataDir\local-api.token" -Raw).Trim()
$request = @{
Method = $Method; Uri = "http://127.0.0.1:$port/api$Path"; TimeoutSec = 5
Headers = @{ 'X-Local-Token' = $token }
}
if ($Body) { $request.Body = $Body | ConvertTo-Json; $request.ContentType = 'application/json' }
Invoke-RestMethod @request
}
function Get-Installed {
# Path is read once: it is looked up on each read, and a process that
# exits in between has none.
Get-Process | Where-Object { $path = $_.Path; $path -and $path.StartsWith("$dir\", [StringComparison]::OrdinalIgnoreCase) }
}
$running = @(Get-Installed)
$appWasRunning = [bool]($running | Where-Object { $_.Path -ieq $appExe })
$wasOn = $false
if ($running) {
try { $wasOn = (Invoke-Core GET /status).state.phase -notin 'off', '' } catch {}
# The app first: its core sees the end of its standard input, restores
# the system proxy, and exits.
$running | Where-Object { $_.Path -ieq $appExe } | ForEach-Object { Stop-Process -Id $_.Id -Force }
$deadline = (Get-Date).AddSeconds(10)
while ((Get-Installed) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 200 }
$left = @(Get-Installed)
if ($left) {
Write-Warning "Still running after 10 s, stopped by id: $($left.Id -join ', ')"
$left | ForEach-Object { Stop-Process -Id $_.Id -Force }
Start-Sleep -Milliseconds 500
}
}
# Files replaced; extra files and user-data stay. Robocopy retries a file
# that a scanner holds open for a moment.
robocopy $release $dir /E /XD user-data data /XF portable /R:10 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
if ($LASTEXITCODE -ge 8) { throw "copying the app failed (robocopy $LASTEXITCODE)" }
# The app's assets exactly as built: an asset removed from the build (an
# old icon, say) must not stay behind and be used.
robocopy "$release\data" "$dir\data" /MIR /R:10 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
if ($LASTEXITCODE -ge 8) { throw "copying the app's assets failed (robocopy $LASTEXITCODE)" }
robocopy $coreOut "$dir\core" firefox-vpn-core.exe firefox-vpn-tun.exe firefox-vpn-tun.exe.sig /R:10 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
if ($LASTEXITCODE -ge 8) { throw "copying the core failed (robocopy $LASTEXITCODE)" }
if (-not (Test-Path "$coreOut\firefox-vpn-tun.exe.sig")) {
Remove-Item "$dir\core\firefox-vpn-tun.exe.sig" -ErrorAction SilentlyContinue
}
Set-ItemProperty $uninstallKey -Name DisplayVersion -Value $version
Write-Host "Installed $version in $dir"
# The installed TUN service, if any, replaces itself with the new one: the
# app's core asks only when the service is older, and a test build keeps
# the version. It refuses a build its key did not sign (then install it
# again: core\firefox-vpn-tun.exe install, with the administrator prompt).
if (Get-Service FirefoxVPNTun -ErrorAction SilentlyContinue) {
$p = Start-Process -FilePath "$dir\core\firefox-vpn-tun.exe" -ArgumentList update -Wait -PassThru -NoNewWindow -RedirectStandardError "$coreOut\tun-update.txt"
$why = (Get-Content "$coreOut\tun-update.txt" -Raw -ErrorAction SilentlyContinue)
if ($p.ExitCode -eq 0) { Write-Host 'The TUN service updated itself' }
elseif ($why -match 'already this build') { Write-Host 'The TUN service is already this build' }
else { Write-Warning "The TUN service did not update itself: $why" }
}
if ($appWasRunning) {
# Without the proxy variables a build may have needed: the app's core
# would inherit them and send its own requests to Mozilla through them.
# They come back afterwards, for the terminal this script runs in.
$saved = @{}
foreach ($name in 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY') {
$saved[$name] = [Environment]::GetEnvironmentVariable($name)
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
}
try {
Start-Process -FilePath $appExe -WorkingDirectory $dir
Write-Host 'Started the app again'
if ($wasOn) {
# The app's core starts with the proxy off.
$deadline = (Get-Date).AddSeconds(30)
$done = $false
while (-not $done -and (Get-Date) -lt $deadline) {
Start-Sleep -Seconds 1
try { Invoke-Core POST /power @{ on = $true } | Out-Null; $done = $true } catch {}
}
if ($done) { Write-Host 'Connected again' } else { Write-Warning 'The app did not answer; connect by hand' }
}
} finally {
foreach ($name in $saved.Keys) {
if ($null -ne $saved[$name]) { Set-Item "Env:$name" $saved[$name] }
}
}
}
# Not robocopy's code, where 1 means "files copied".
exit 0