55param (
66 [string ]$InstallRoot = (Join-Path $env: LOCALAPPDATA ' TailscaleControl' ),
77 [string ]$SourceDirectory ,
8- [string ]$ReleaseTag = ' latest ' ,
8+ [string ]$ReleaseTag ,
99 [string ]$ReleaseAssetBase ,
1010 [switch ]$PreferRemote ,
11- [switch ]$SkipHashCheck ,
1211 [switch ]$NoLaunch
1312)
1413
1514Set-StrictMode - Version Latest
1615$ErrorActionPreference = ' Stop'
16+ $ProgressPreference = ' SilentlyContinue'
17+
18+ $DefaultReleaseTag = ' v1.0.0'
19+
20+ if ([string ]::IsNullOrWhiteSpace($DefaultReleaseTag ) -or $DefaultReleaseTag -eq ' __RELEASE_TAG__' ) {
21+ throw ' Default release tag was not filled. Run the GitHub Action before publishing install.ps1.'
22+ }
23+
24+ if ([string ]::IsNullOrWhiteSpace([string ]$ReleaseTag )) {
25+ $ReleaseTag = $DefaultReleaseTag
26+ }
1727
1828$script :AppName = ' Tailscale Control'
1929$script :AppVersion = ' 1.0.0'
@@ -28,28 +38,32 @@ $script:InstalledIconPath = Join-Path $script:InstalledIconsDir 'tailscale-contr
2838$script :LauncherVbsPath = Join-Path $InstallRoot ' TailscaleControlLauncher.vbs'
2939$script :StartMenuShortcutPath = Join-Path ([Environment ]::GetFolderPath(' Programs' )) ' Tailscale Control.lnk'
3040$script :GitHubReleasesBase = ' https://github.com/luizbizzio/tailscale-control/releases'
31- $script :ExpectedScriptSha256 = ' 97ea5df2ef1a2089d1e50804112f53597e861b95e69a5d12ff6a613e2ecc71c2'
3241$script :IconAssets = @ (
3342 [pscustomobject ]@ {
3443 FileName = ' tailscale-control.ico'
3544 RelativePath = ' assets/icons/tailscale-control.ico'
36- ExpectedSha256 = ' 2c2c83a52aafd6def4c074fd3127d7de9f414453e8b92db30b4d1d11c1ea0e3a'
3745 },
3846 [pscustomobject ]@ {
3947 FileName = ' tailscale.ico'
4048 RelativePath = ' assets/icons/tailscale.ico'
41- ExpectedSha256 = ' 1eff7d0ee72515e6bfd9a3301900c23a288b389e202c55a51fdd735243c411f0'
4249 },
4350 [pscustomobject ]@ {
4451 FileName = ' tailscale-mtu.ico'
4552 RelativePath = ' assets/icons/tailscale-mtu.ico'
46- ExpectedSha256 = ' 01e024c88d947c4f40e9659498a2ae6ed10ed970a9130806000e2ffd17fcf0b5'
4753 }
4854)
4955
5056function Write-Step {
51- param ([string ]$Message )
52- Write-Host (' [{0}] {1}' -f (Get-Date ).ToString(' HH:mm:ss' ), $Message )
57+ param (
58+ [string ]$Message ,
59+ [ConsoleColor ]$Color = [ConsoleColor ]::Cyan
60+ )
61+
62+ try {
63+ Write-Host " [$ ( (Get-Date ).ToString(' HH:mm:ss' )) ] $Message " - ForegroundColor $Color
64+ } catch {
65+ Write-Output $Message
66+ }
5367}
5468
5569function Initialize-Directory {
@@ -59,89 +73,95 @@ function Initialize-Directory {
5973 }
6074}
6175
62- function Get-FileSha256Hex {
63- param ([string ]$Path )
64- return ([string ](Get-FileHash - Algorithm SHA256 - LiteralPath $Path ).Hash).ToLowerInvariant()
65- }
66-
67- function Assert-Hash {
76+ function Assert-IcoFile {
6877 param (
6978 [string ]$Path ,
70- [string ]$Expected ,
7179 [string ]$Label
7280 )
73- if ($SkipHashCheck ) {
74- Write-Step (' Skipping SHA-256 verification for ' + $Label + ' .' )
75- return
76- }
77- if ([string ]::IsNullOrWhiteSpace([string ]$Expected )) {
78- throw (' Missing expected SHA-256 for ' + $Label + ' .' )
81+
82+ $bytes = [System.IO.File ]::ReadAllBytes($Path )
83+ if ($bytes.Length -lt 6 ) {
84+ throw ($Label + ' is too small to be a valid icon file.' )
7985 }
80- $actual = Get-FileSha256Hex - Path $Path
81- if ($actual -ne $Expected.ToLowerInvariant ()) {
82- throw ($Label + ' SHA-256 mismatch. Expected ' + $Expected + ' but got ' + $actual + ' .' )
86+ if ($bytes [0 ] -ne 0 -or $bytes [1 ] -ne 0 -or $bytes [2 ] -ne 1 -or $bytes [3 ] -ne 0 ) {
87+ throw ($Label + ' does not look like a valid .ico file.' )
8388 }
8489}
8590
86- function Assert-IcoFile {
91+ function Assert-PowerShellScript {
8792 param (
8893 [string ]$Path ,
8994 [string ]$Label
9095 )
91- $bytes = [System.IO.File ]::ReadAllBytes($Path )
92- if ($bytes.Length -lt 6 ) {
93- throw ($Label + ' is too small to be a valid icon file.' )
96+
97+ $raw = Get-Content - LiteralPath $Path - Raw - Encoding UTF8
98+ if ($raw -match ' (?i)<html' -or $raw -match ' (?i)<!DOCTYPE html' ) {
99+ throw ($Label + ' looks like HTML instead of PowerShell.' )
94100 }
95- if ($bytes [ 0 ] -ne 0 -or $bytes [ 1 ] -ne 0 -or $bytes [ 2 ] -ne 1 -or $bytes [ 3 ] -ne 0 ) {
96- throw ($Label + ' does not look like a valid .ico file .' )
101+ if ($raw -notmatch ' \$script:AppName\s*=\s*[ '' "]Tailscale Control[ '' "] ' ) {
102+ throw ($Label + ' did not pass the content sanity check .' )
97103 }
98104}
99105
106+ function Resolve-ReleaseTag {
107+ param ([string ]$Tag )
108+
109+ $tagValue = [string ]$Tag
110+ if ([string ]::IsNullOrWhiteSpace($tagValue )) {
111+ return $DefaultReleaseTag
112+ }
113+ $tagValue = $tagValue.Trim ()
114+ if ($tagValue -eq ' latest' ) {
115+ return ' latest'
116+ }
117+ if ($tagValue -match ' ^\d+(\.\d+){1,3}([\-+].*)?$' ) {
118+ return (' v' + $tagValue )
119+ }
120+ return $tagValue
121+ }
122+
100123function Resolve-ReleaseAssetBase {
101124 param (
102125 [string ]$Tag ,
103126 [string ]$ExplicitBase
104127 )
128+
105129 if (-not [string ]::IsNullOrWhiteSpace([string ]$ExplicitBase )) {
106130 return ([string ]$ExplicitBase ).TrimEnd(' /' )
107131 }
108- $tagValue = [ string ] $Tag
109- if ([ string ]::IsNullOrWhiteSpace( $tagValue )) { $tagValue = ' latest ' }
132+
133+ $tagValue = Resolve-ReleaseTag - Tag $Tag
110134 if ($tagValue -eq ' latest' ) {
111- return ( $ script :GitHubReleasesBase + ' / latest/download' )
135+ return ' https://github.com/luizbizzio/tailscale-control/releases/ latest/download'
112136 }
113- return ($ script :GitHubReleasesBase + ' /download/' + [uri ]::EscapeDataString($tagValue ))
137+ return (' https://github.com/luizbizzio/tailscale-control/releases /download/' + [uri ]::EscapeDataString($tagValue ))
114138}
115139
140+ $script :ResolvedReleaseTag = Resolve-ReleaseTag - Tag $ReleaseTag
116141$script :ResolvedReleaseAssetBase = Resolve-ReleaseAssetBase - Tag $ReleaseTag - ExplicitBase $ReleaseAssetBase
117142$script :RemoteScriptUrl = ($script :ResolvedReleaseAssetBase.TrimEnd (' /' ) + ' /tailscale-control.ps1' )
118143
119144function Invoke-Download {
120145 param (
121146 [string ]$Url ,
122147 [string ]$OutFile ,
123- [string ]$ExpectedSha256 ,
124148 [string ]$Label ,
125149 [switch ]$Icon
126150 )
151+
127152 Write-Step (' Downloading ' + $Label + ' ...' )
128- Invoke-WebRequest - Uri $Url - OutFile $OutFile - UseBasicParsing
153+ Invoke-WebRequest - Uri $Url - OutFile $OutFile - UseBasicParsing - ErrorAction Stop
154+ if (-not (Test-Path - LiteralPath $OutFile )) {
155+ throw ($Label + ' was not downloaded.' )
156+ }
129157 if ((Get-Item - LiteralPath $OutFile ).Length -le 0 ) {
130158 throw ($Label + ' download returned an empty file.' )
131159 }
132160 if ($Icon ) {
133161 Assert-IcoFile - Path $OutFile - Label $Label
162+ } elseif ($Label -eq ' script' ) {
163+ Assert-PowerShellScript - Path $OutFile - Label $Label
134164 }
135- elseif ($Label -eq ' script' ) {
136- $raw = Get-Content - LiteralPath $OutFile - Raw - Encoding UTF8
137- if ($raw -match ' (?i)<html' -or $raw -match ' (?i)<!DOCTYPE html' ) {
138- throw ' Downloaded script looks like HTML instead of PowerShell.'
139- }
140- if ($raw -notmatch ' \$script:AppName\s*=\s*['' \"]Tailscale Control['' \"]' ) {
141- throw ' Downloaded script did not pass the content sanity check.'
142- }
143- }
144- Assert-Hash - Path $OutFile - Expected $ExpectedSha256 - Label $Label
145165}
146166
147167function Resolve-SourceDirectory {
@@ -159,6 +179,7 @@ function Resolve-LocalIconPath {
159179 [string ]$BaseDirectory ,
160180 [string ]$FileName
161181 )
182+
162183 if ([string ]::IsNullOrWhiteSpace([string ]$BaseDirectory )) { return $null }
163184 $assetPath = Join-Path (Join-Path $BaseDirectory ' assets\icons' ) $FileName
164185 if (Test-Path - LiteralPath $assetPath ) { return $assetPath }
@@ -169,17 +190,19 @@ function Resolve-LocalIconPath {
169190
170191function Get-AppVersionFromFile {
171192 param ([string ]$Path )
193+
172194 try {
173195 $raw = Get-Content - LiteralPath $Path - Raw - Encoding UTF8
174- if ($raw -match ' \$script:AppVersion\s*=\s*['' \ "]([^'' \ "]+)['' \ "]' ) { return [string ]$Matches [1 ] }
175- if ($raw -match ' \$AppVersion\s*=\s*['' \ "]([^'' \ "]+)['' \ "]' ) { return [string ]$Matches [1 ] }
196+ if ($raw -match ' \$script:AppVersion\s*=\s*['' "]([^'' "]+)['' "]' ) { return [string ]$Matches [1 ] }
197+ if ($raw -match ' \$AppVersion\s*=\s*['' "]([^'' "]+)['' "]' ) { return [string ]$Matches [1 ] }
176198 }
177199 catch { }
178200 return ' unknown'
179201}
180202
181203function Write-AppLauncherVbs {
182204 param ([string ]$ScriptPath )
205+
183206 $content = @"
184207Set oShell = CreateObject("WScript.Shell")
185208cmd = Chr(34) & "$ ( $script :PowerShellExe ) " & Chr(34) & " -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File " & Chr(34) & "$ScriptPath " & Chr(34)
@@ -209,6 +232,7 @@ function Install-FileAtomically {
209232 [string ]$SourcePath ,
210233 [string ]$DestinationPath
211234 )
235+
212236 $destinationDirectory = Split-Path - Parent $DestinationPath
213237 Initialize-Directory - Path $destinationDirectory
214238 $tmpPath = $DestinationPath + ' .tmp'
@@ -222,6 +246,11 @@ function Install-FileAtomically {
222246 Move-Item - LiteralPath $tmpPath - Destination $DestinationPath - Force
223247}
224248
249+ try {
250+ [Net.ServicePointManager ]::SecurityProtocol = [Net.SecurityProtocolType ]::Tls12 -bor [Net.SecurityProtocolType ]::Tls11 -bor [Net.SecurityProtocolType ]::Tls
251+ } catch {
252+ }
253+
225254$resolvedSourceDir = Resolve-SourceDirectory
226255$localScriptPath = if ($resolvedSourceDir ) { Join-Path $resolvedSourceDir ' tailscale-control.ps1' } else { $null }
227256$useLocalScript = $false
@@ -237,51 +266,59 @@ $tempIconsDir = Join-Path $tempRoot 'assets\icons'
237266Initialize-Directory - Path $tempIconsDir
238267$tempScriptPath = Join-Path $tempRoot ' tailscale-control.ps1'
239268
240- if ($useLocalScript ) {
241- Write-Step (' Using local script from ' + $localScriptPath )
242- Copy-Item - LiteralPath $localScriptPath - Destination $tempScriptPath - Force
243- }
244- else {
245- Invoke-Download - Url $script :RemoteScriptUrl - OutFile $tempScriptPath - ExpectedSha256 $script :ExpectedScriptSha256 - Label ' script'
246- }
269+ try {
270+ Write-Step (' Selected release: ' + $script :ResolvedReleaseTag )
271+ Write-Step (' Release asset base: ' + $script :ResolvedReleaseAssetBase )
247272
248- foreach ($asset in $script :IconAssets ) {
249- $tempIconPath = Join-Path $tempIconsDir $asset.FileName
250- $localIconPath = Resolve-LocalIconPath - BaseDirectory $resolvedSourceDir - FileName $asset.FileName
251- if (-not $PreferRemote -and -not [string ]::IsNullOrWhiteSpace([string ]$localIconPath )) {
252- Write-Step (' Using local icon from ' + $localIconPath )
253- Copy-Item - LiteralPath $localIconPath - Destination $tempIconPath - Force
254- Assert-IcoFile - Path $tempIconPath - Label $asset.FileName
273+ if ($useLocalScript ) {
274+ Write-Step (' Using local script from ' + $localScriptPath )
275+ Copy-Item - LiteralPath $localScriptPath - Destination $tempScriptPath - Force
276+ Assert-PowerShellScript - Path $tempScriptPath - Label ' script'
255277 }
256278 else {
257- $iconUrl = ($script :ResolvedReleaseAssetBase.TrimEnd (' /' ) + ' /' + $asset.FileName )
258- Invoke-Download - Url $iconUrl - OutFile $tempIconPath - ExpectedSha256 $asset.ExpectedSha256 - Label $asset.FileName - Icon
279+ Invoke-Download - Url $script :RemoteScriptUrl - OutFile $tempScriptPath - Label ' script'
259280 }
260- }
261281
262- Install-FileAtomically - SourcePath $tempScriptPath - DestinationPath $script :InstalledScriptPath
263- foreach ($asset in $script :IconAssets ) {
264- $sourceIcon = Join-Path $tempIconsDir $asset.FileName
265- $destinationIcon = Join-Path $script :InstalledIconsDir $asset.FileName
266- Install-FileAtomically - SourcePath $sourceIcon - DestinationPath $destinationIcon
267- }
268- Write-AppLauncherVbs - ScriptPath $script :InstalledScriptPath
269- Initialize-StartMenuShortcut
282+ foreach ($asset in $script :IconAssets ) {
283+ $tempIconPath = Join-Path $tempIconsDir $asset.FileName
284+ $localIconPath = Resolve-LocalIconPath - BaseDirectory $resolvedSourceDir - FileName $asset.FileName
285+ if (-not $PreferRemote -and -not [string ]::IsNullOrWhiteSpace([string ]$localIconPath )) {
286+ Write-Step (' Using local icon from ' + $localIconPath )
287+ Copy-Item - LiteralPath $localIconPath - Destination $tempIconPath - Force
288+ Assert-IcoFile - Path $tempIconPath - Label $asset.FileName
289+ }
290+ else {
291+ $iconUrl = ($script :ResolvedReleaseAssetBase.TrimEnd (' /' ) + ' /' + $asset.FileName )
292+ Invoke-Download - Url $iconUrl - OutFile $tempIconPath - Label $asset.FileName - Icon
293+ }
294+ }
270295
271- try {
272- Remove-Item - LiteralPath $tempRoot - Recurse - Force - ErrorAction SilentlyContinue
273- }
274- catch { }
275-
276- $version = Get-AppVersionFromFile - Path $script :InstalledScriptPath
277- if ([string ]::IsNullOrWhiteSpace([string ]$version )) { $version = [string ]$script :AppVersion }
278- Write-Step (' Install/update completed. Version ' + $version + ' .' )
279- Write-Step (' Script path: ' + $script :InstalledScriptPath )
280- Write-Step (' Icons path: ' + $script :InstalledIconsDir )
281- Write-Step (' Remote asset base: ' + $script :ResolvedReleaseAssetBase )
282- Write-Step (' Launcher path: ' + $script :LauncherVbsPath )
283-
284- if (-not $NoLaunch ) {
285- Write-Step ' Launching Tailscale Control without console...'
286- Start-Process - FilePath $script :WScriptExe - ArgumentList @ ($script :LauncherVbsPath )
296+ Install-FileAtomically - SourcePath $tempScriptPath - DestinationPath $script :InstalledScriptPath
297+ foreach ($asset in $script :IconAssets ) {
298+ $sourceIcon = Join-Path $tempIconsDir $asset.FileName
299+ $destinationIcon = Join-Path $script :InstalledIconsDir $asset.FileName
300+ Install-FileAtomically - SourcePath $sourceIcon - DestinationPath $destinationIcon
301+ }
302+ Write-AppLauncherVbs - ScriptPath $script :InstalledScriptPath
303+ Initialize-StartMenuShortcut
304+
305+ $version = Get-AppVersionFromFile - Path $script :InstalledScriptPath
306+ if ([string ]::IsNullOrWhiteSpace([string ]$version )) { $version = [string ]$script :AppVersion }
307+ Write-Step (' Install/update completed. Version ' + $version + ' .' ) Green
308+ Write-Step (' Script path: ' + $script :InstalledScriptPath )
309+ Write-Step (' Icons path: ' + $script :InstalledIconsDir )
310+ Write-Step (' Remote asset base: ' + $script :ResolvedReleaseAssetBase )
311+ Write-Step (' Launcher path: ' + $script :LauncherVbsPath )
312+
313+ if (-not $NoLaunch ) {
314+ Write-Step ' Launching Tailscale Control without console...' Green
315+ Start-Process - FilePath $script :WScriptExe - ArgumentList (' "' + $script :LauncherVbsPath + ' "' )
316+ }
317+ } finally {
318+ try {
319+ if (Test-Path - LiteralPath $tempRoot ) {
320+ Remove-Item - LiteralPath $tempRoot - Recurse - Force - ErrorAction SilentlyContinue
321+ }
322+ }
323+ catch { }
287324}
0 commit comments