From 5a25f9239d4b58482be0be9127605787029ee3fc Mon Sep 17 00:00:00 2001 From: Stephan Yelle Date: Sun, 22 Jun 2025 15:50:08 -0400 Subject: [PATCH] Add SM.sp1 --- SM.sp1 | 1239 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1239 insertions(+) create mode 100644 SM.sp1 diff --git a/SM.sp1 b/SM.sp1 new file mode 100644 index 0000000..14a05ed --- /dev/null +++ b/SM.sp1 @@ -0,0 +1,1239 @@ +# region changes to be done + + +#endregion + +# STACK = Scripted Tooling for Automated Client Kickoff +# MONKEY = Module-based Onboarding & Next-step Kickoff Engine Yoke +# Conveys the idea of coupling tasks together and keeping them under control. +#region Config & Task Definitions + +# Listening port for HTTP UI +$Port = 8082 + +# Configurable endpoints +$Global:DattoWebhookUrl = 'https://automate.svstools.ca/webhook/svsmspkit' + + +# Define every task once here: +# Id → checkbox HTML `id` +# Name → URL path (`/Name`) +# Label → user-visible text +# HandlerFn → the PowerShell function to invoke +# Page → which tab/page it appears on + +$Global:Tasks = @( + # On-Boarding, left column + @{ Id='setSVSPowerplan'; Name='setSVSPowerplan'; Label='Set SVS Powerplan'; HandlerFn='Handle-setSVSPowerPlan'; Page='onboard'; Column='left' }, + @{ Id='installSVSMSPModule'; Name='installSVSMSPModule'; Label='Install SVSMSP Module'; HandlerFn='Handle-InstallSVSMSP'; Page='onboard'; Column='left' }, + @{ Id='installCyberQP'; Name='installCyberQP'; Label='Install CyberQP'; HandlerFn='Handle-InstallCyberQP'; Page='onboard'; Column='left' }, + @{ Id='installSVSHelpDesk'; Name='installSVSHelpDesk'; Label='Install SVS HelpDesk'; HandlerFn='Handle-InstallSVSHelpDesk'; Page='onboard'; Column='left' }, + @{ Id='installThreatLocker'; Name='installThreatLocker'; Label='Install ThreatLocker'; HandlerFn='Handle-InstallThreatLocker'; Page='onboard'; Column='left' }, + @{ Id='installRocketCyber'; Name='installRocketCyber'; Label='Install RocketCyber'; HandlerFn='Handle-InstallRocketCyber'; Page='onboard'; Column='left' }, + @{ Id='installDattoRMM'; Name='installDattoRMM'; Label='Install DattoRMM'; HandlerFn='Handle-InstallDattoRMM'; Page='onboard'; Column='left'; + SubOptions= @( + @{ Value='inputVar'; Label='Copy Site Variables' }, + @{ Value='rmm'; Label='Install RMM Agent' }, + @{ Value='exe'; Label='Download Executable' } + ) + }, + + + # On-Boarding, right column (optional bits) + @{ Id='enableBitLocker'; Name='EnableBitLocker'; Label='Enable BitLocker'; HandlerFn='Set-SVSBitLocker'; Page='onboard'; Column='right' }, + @{ Id='setEdgeDefaultSearch';Name='setedgedefaultsearch';Label='Set Edge Default Search'; Tooltip='Will configure Edge to use Google as default search provider'; HandlerFn='set-EdgeDefaultSearchProvider';Page='onboard'; Column='right' }, + + # Off-Boarding + @{ Id='uninstallCyberQP'; Name='uninstallCyberQP'; Label='Uninstall CyberQP'; HandlerFn='Uninstall-CyberQP'; Page='offboard' }, + @{ Id='uninstallSVSMSPModule'; Name='uninstallSVSMSPModule'; Label='Uninstall SVSMSP Module'; HandlerFn='Cleanup-SVSMSP'; Page='offboard' }, + + # Tweaks + @{ Id='disableAnimations'; Name='disableAnimations'; Label='Disable Animations'; HandlerFn='Disable-Animations'; Page='tweaks' }, + + # SVS Apps + @{ Id='wingetLastpass'; Name='wingetLastpass'; Label='LastPass Desktop App'; HandlerFn='Install-WingetLastPass'; Page='SVSApps' } +) + +#endregion + +#region Logging Helpers + +# Initialize a global in-memory log cache +if (-not $Global:LogCache -or -not ($Global:LogCache -is [System.Collections.ArrayList])) { + $Global:LogCache = [System.Collections.ArrayList]::new() +} + +# Core Write-Log function (advanced with event-log support) +function Write-LogHelper { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Message, + [ValidateSet("Info","Warning","Error","Success","General")] + [string]$Level = "Info", + [string]$TaskCategory = "GeneralTask", + [switch]$LogToEvent, + [string]$EventSource = "SVSMSP_Module", + [string]$EventLog = "Application", + [int]$CustomEventID + ) + $EventID = @{ Info=1000; Warning=2000; Error=3000; Success=4000; General=1000 }[$Level] + $Icon = @{Info=[System.Char]::ConvertFromUtf32(0x1F4CB);Warning=[char]0x26A0;Error=[char]0x274C;Success=[char]0x2705;General=[char]0x1F4E6}[$Level] + $logEntry = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + Level = $Level + Message = "$Icon [$Level] [$TaskCategory] $Message (EventID:$EventID)" + } + [void]$Global:LogCache.Add($logEntry) + + if ($LogToEvent) { + try { + if (-not (Get-EventLog -LogName $EventLog -Source $EventSource -ErrorAction SilentlyContinue)) { + New-EventLog -LogName $EventLog -Source $EventSource -ErrorAction SilentlyContinue + } + Write-EventLog -LogName $EventLog -Source $EventSource ` + -EntryType $Level -EventId $EventID ` + -Message $Message + } catch { + Write-Host "$([System.Char]::ConvertFromUtf32(0x26A0))$([System.Char]::ConvertFromUtf32(0xFE0F)) [Warning] [EventLog] Failed to write to Event Log: $($_.Exception.Message)" -ForegroundColor Yellow + + + } + } +} + +# Hybrid wrapper: uses your module's Write-Log if available, else falls back +if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + function Write-LogHybrid { param($Message,$Level,$TaskCategory,$LogToEvent) Write-Log @PSBoundParameters } +} else { + function Write-LogHybrid { param($Message,$Level,$TaskCategory,$LogToEvent) Write-LogHelper @PSBoundParameters } +} + +#endregion + +#region Handler Stubs + +function Respond-Text { + param($Context, $Text) + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + $Context.Response.ContentType = 'text/plain' + $Context.Response.ContentLength64 = $bytes.Length + $Context.Response.OutputStream.Write($bytes,0,$bytes.Length) + $Context.Response.OutputStream.Close() +} + +function Respond-HTML { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][object] $Context, + [Parameter(Mandatory = $true)][string] $Html + ) + $bytes = [Text.Encoding]::UTF8.GetBytes($Html) + $Context.Response.ContentType = 'text/html' + $Context.Response.ContentLength64 = $bytes.Length + $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + $Context.Response.OutputStream.Close() +} + +# new helper to return JSON +function Respond-JSON { + param($Context, $Object) + $json = $Object | ConvertTo-Json -Depth 5 + $bytes = [Text.Encoding]::UTF8.GetBytes($json) + $Context.Response.ContentType = 'application/json' + $Context.Response.ContentLength64 = $bytes.Length + $Context.Response.OutputStream.Write($bytes,0,$bytes.Length) + $Context.Response.OutputStream.Close() +} +#region Get-DattoApiCreds +function Get-DattoApiCredentials { + param ([string]$Password) + $url = "https://automate.svstools.ca/webhook/svsmspkit" + $headers = @{ "SVSMSPKit" = $Password } + try { + $response = Invoke-RestMethod -Uri $url -Headers $headers -Method GET + return @{ + ApiUrl = $response.ApiUrl + ApiKey = $response.ApiKey + ApiSecretKey = $response.ApiSecretKey + } + } catch { + Write-LogHybrid "Failed to fetch API credentials: $($_.Exception.Message)" "Error" "DattoAuth" + return $null + } +} +#endregion + +#region Install-DattoRMM-Helper +function Install-DattoRMM-Helper { + param ( + [string]$ApiUrl, + [string]$ApiKey, + [string]$ApiSecretKey, + [switch]$FetchSitesOnly, + [string]$SiteName, + [string]$SiteUID + ) + if (-not $ApiUrl -or -not $ApiKey -or -not $ApiSecretKey) { + Write-LogHybrid -Message "Missing required parameters. Please provide ApiUrl, ApiKey, and ApiSecretKey." -Level "Error" -LogToEvent + return + } + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Write-LogHybrid -Message "Fetching OAuth token..." -Level "Info" + try { + $securePassword = ConvertTo-SecureString -String 'public' -AsPlainText -Force + $apiGenToken = Invoke-WebRequest -Credential (New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList ('public-client', $securePassword)) ` + -Uri ('{0}/auth/oauth/token' -f $ApiUrl) ` + -Method 'POST' ` + -ContentType 'application/x-www-form-urlencoded' ` + -Body ('grant_type=password&username={0}&password={1}' -f $ApiKey, $ApiSecretKey) ` + | ConvertFrom-Json + $requestToken = $apiGenToken.access_token + Write-LogHybrid -Message "OAuth token fetched successfully." -Level "Success" -LogToEvent + } catch { + Write-LogHybrid -Message "Failed to fetch OAuth token. Details: $($_.Exception.Message)" -Level "Error" -LogToEvent + return + } + $getHeaders = @{"Authorization" = "Bearer $requestToken"} + if ($FetchSitesOnly) { + Write-Host "Fetching list of sites from the Datto RMM API..." -ForegroundColor Cyan + try { + $getHeaders = @{"Authorization" = "Bearer $requestToken" } + $getSites = Invoke-WebRequest -Uri "$ApiUrl/api/v2/account/sites" -Method Get -Headers $getHeaders -ContentType "application/json" + $sitesJson = $getSites.Content | ConvertFrom-Json + $siteList = $sitesJson.sites | ForEach-Object { + [PSCustomObject]@{ + Name = $_.name + UID = $_.uid + } + } + Write-Host "Successfully fetched list of sites." -ForegroundColor Green + return $siteList + } + catch { + Write-Host "Failed to fetch sites from the API. Details: $($_.Exception.Message)" -ForegroundColor Red + return + } + } +} +#endregion + +#region SVS Module + +function Install-SVSMSP { + param ( + [switch] $Cleanup, + [switch] $InstallToolkit, + [Parameter(Mandatory = $false)][array] $AllModules = @(@{ ModuleName = "SVS_Toolkit" }, @{ ModuleName = "SVSMSP" }), + [Parameter(Mandatory = $false)][array] $AllRepositories = @(@{ RepoName = "SVS_Repo" }, @{ RepoName = "SVS_Toolkit" }), + [Parameter(Mandatory = $false)][string] $NewModuleName = "SVSMSP", + [Parameter(Mandatory = $false)][string] $NewRepositoryName = "SVS_Repo", + [Parameter(Mandatory = $false)][string] $NewRepositoryURL = "http://proget.svstools.ca:8083/nuget/SVS_Repo/" + ) + + function Perform-Cleanup { + Write-LogHybrid "Cleanup mode enabled. Starting cleanup..." "Info" "SVSModule" + # …your old cleanup logic here… + } + + function Perform-ToolkitInstallation { + Perform-Cleanup + Write-LogHybrid "Registering repo $NewRepositoryName…" "Info" "SVSModule" + if (-not (Get-PSRepository -Name $NewRepositoryName -ErrorAction SilentlyContinue)) { + Register-PSRepository -Name $NewRepositoryName -SourceLocation $NewRepositoryURL -InstallationPolicy Trusted + } + Write-LogHybrid "Installing module $NewModuleName…" "Info" "SVSModule" + Install-Module -Name $NewModuleName -Repository $NewRepositoryName -Scope AllUsers -Force + Write-LogHybrid "Toolkit installation complete." "Success" "SVSModule" + } + + Write-LogHybrid "Install-SVSMSP called" "Info" "SVSModule" + if ($Cleanup) { + Perform-Cleanup; return + } + if ($InstallToolkit) { + Perform-ToolkitInstallation; return + } + # default if no switch passed: + Perform-ToolkitInstallation +} + +#endregion + +# POST /getpw → read JSON body, call helper, return JSON + + +function Handle-FetchSites { + param($Context) + + # 1) Read incoming JSON (using block auto-disposes the reader) + + + <# powershell v7 + using ($reader = [IO.StreamReader]::new($Context.Request.InputStream)) { + $raw = $reader.ReadToEnd() + } + try { + $pw = (ConvertFrom-Json $raw).password + if (-not $pw) { throw "Missing `password` field" } + } catch { + Write-LogHybrid "Invalid JSON in /getpw payload: $($_.Exception.Message)" "Error" "FetchSites" + returnRespondEmpty $Context 400 + return + } + #> + + $reader = [IO.StreamReader]::new($Context.Request.InputStream) + try { + $raw = $reader.ReadToEnd() + } finally { + $reader.Close() + } + + try { + $pw = (ConvertFrom-Json $raw).password + } catch { + Write-LogHybrid "Invalid JSON in /getpw payload: $($_.Exception.Message)" "Error" "FetchSites" + returnRespondEmpty $Context + return + } + + # 2) Fetch your Datto API creds from the webhook + Write-LogHybrid "Calling webhook for Datto credentials…" "Info" "FetchSites" + try { + $hdr = @{ "SVSMSPKit" = $pw } + $resp = Invoke-RestMethod -Uri $Global:DattoWebhookUrl -Headers $hdr -Method GET + + # store for later RMM calls + $Global:ApiUrl = $resp.ApiUrl + $Global:ApiKey = $resp.ApiKey + $Global:ApiSecretKey = $resp.ApiSecretKey + + Write-LogHybrid "Fetched and stored API credentials." "Success" "FetchSites" + } catch { + Write-LogHybrid "Webhook call failed: $($_.Exception.Message)" "Error" "FetchSites" -LogToEvent + returnRespondEmpty $Context 403 + return + } + + # 3) Exchange for a bearer token + Write-LogHybrid "Requesting OAuth token" "Info" "FetchSites" + try { + $securePublic = ConvertTo-SecureString 'public' -AsPlainText -Force + $creds = New-Object System.Management.Automation.PSCredential('public-client',$securePublic) + $tokenResp = Invoke-RestMethod ` + -Uri "$Global:ApiUrl/auth/oauth/token" ` + -Credential $creds ` + -Method Post ` + -ContentType 'application/x-www-form-urlencoded' ` + -Body "grant_type=password&username=$Global:ApiKey&password=$Global:ApiSecretKey" + $token = $tokenResp.access_token + Write-LogHybrid "OAuth token acquired." "Success" "FetchSites" + } catch { + Write-LogHybrid "OAuth request failed: $($_.Exception.Message)" "Error" "FetchSites" + returnRespondEmpty $Context 500 + return + } + + # 4) Pull the site list + Write-LogHybrid "Fetching Datto RMM site list" "Info" "FetchSites" + try { + $hdr = @{ Authorization = "Bearer $token" } + $sitesResp = Invoke-RestMethod -Uri "$Global:ApiUrl/api/v2/account/sites" ` + -Method Get ` + -Headers $hdr ` + -ContentType 'application/json' + + $siteList = $sitesResp.sites | ForEach-Object { + [PSCustomObject]@{ Name = $_.name; UID = $_.uid } + } + Write-LogHybrid "Site list retrieved ($($siteList.Count) sites)." "Success" "FetchSites" + } catch { + Write-LogHybrid "Failed to fetch site list: $($_.Exception.Message)" "Error" "FetchSites" + returnRespondEmpty $Context 500 + return + } + + # 5) Return JSON array + $json = $siteList | ConvertTo-Json -Depth 2 + $bytes = [Text.Encoding]::UTF8.GetBytes($json) + $Context.Response.ContentType = 'application/json' + $Context.Response.ContentLength64 = $bytes.Length + $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + $Context.Response.OutputStream.Close() +} + + + +# Helper function to consistently return an empty JSON array +function returnRespondEmpty { + param( + [Parameter(Mandatory)][object]$Context, + [Parameter(Mandatory)][ValidateRange(100,599)][int]$StatusCode = 500 + ) + # Always return an empty JSON array body + $empty = [Text.Encoding]::UTF8.GetBytes("[]") + + # Set the desired status code and headers + $Context.Response.StatusCode = $StatusCode + $Context.Response.ContentType = 'application/json' + $Context.Response.ContentLength64 = $empty.Length + + # Write and close + $Context.Response.OutputStream.Write($empty, 0, $empty.Length) + $Context.Response.OutputStream.Close() +} + + + +# On-boarding handlers +function Handle-SetSVSPowerPlan { + param($Context) + + # 1) call into your module + Set-SVSPowerPlan + + # 2) log & write back a simple text response + Write-LogHybrid "PowerPlan set" "Success" "OnBoard" + Respond-Text $Context "PowerPlan applied" +} + +function Handle-InstallSVSMSP { + param($Context) + Write-LogHybrid "HTTP trigger: Handle-InstallSVSMSP" "Info" "OnBoard" + try { + Install-SVSMSP -InstallToolkit + Respond-Text $Context "SVSMSP Module installed/updated." + } catch { + Write-LogHybrid "Error in Install-SVSMSP: $_" "Error" "OnBoard" + Respond-Text $Context "ERROR: $_" + } +} + +function Handle-InstallCyberQP { + param($Context) + + # 1) call into your module + Install-CyberQP + + # 2) log & write back a simple text response + Write-LogHybrid "CyberQP installed" "Success" "OnBoard" + Respond-Text $Context "CyberQP installed" +} + +function Handle-InstallThreatLocker { + param($Context) + + # 1) call into your module + Install-ThreatLocker + + # 2) log & write back a simple text response + Write-LogHybrid "ThreatLocker installed" "Success" "OnBoard" + Respond-Text $Context "ThreatLocker installed" +} + +function Handle-InstallRocketCyber { + param($Context) + + # 1) call into your module + Install-RocketCyber + + # 2) log & write back a simple text response + Write-LogHybrid "RocketCyber installed" "Success" "OnBoard" + Respond-Text $Context "RocketCyber installed" +} + +function Handle-InstallSVSHelpDesk { + param($Context) + + # 1) call into your module + Install-SVSHelpDesk + + # 2) log & write back a simple text response + Write-LogHybrid "SVS HelpDesk installed" "Success" "OnBoard" + Respond-Text $Context "SVS HelpDesk installed" +} + +function Handle-InstallDattoRMM { + param($Context) + $req = $Context.Request + $resp = $Context.Response + + if ($req.HttpMethod -ne 'POST') { + $resp.StatusCode = 405; $resp.ContentType = 'text/plain' + $resp.OutputStream.Write([Text.Encoding]::UTF8.GetBytes('Use POST'),0,7) + $resp.OutputStream.Close(); return + } + + # parse JSON body + $body = (New-Object IO.StreamReader $req.InputStream).ReadToEnd() + $data = $body | ConvertFrom-Json + $checked = $data.checkedValues + $uid = $data.UID + $name = $data.Name + + try { + Install-DattoRMM ` + -ApiUrl $Global:ApiUrl ` + -ApiKey $Global:ApiKey ` + -ApiSecretKey $Global:ApiSecretKey ` + -SiteUID $uid ` + -SiteName $name ` + -PushSiteVars:($checked -contains 'inputVar') ` + -InstallRMM: ($checked -contains 'rmm') ` + -SaveCopy: ($checked -contains 'exe') + + Write-LogHybrid "RMM install triggered for $name" "Success" "DattoRMM" + $resp.StatusCode = 200 + $responseString = "Triggered DattoRMM for $name" + } + catch { + Write-LogHybrid "Error in Install-DattoRMM: $_" "Error" "DattoRMM" + $resp.StatusCode = 500 + $responseString = "ERROR: $($_.Exception.Message)" + } + + $b = [Text.Encoding]::UTF8.GetBytes($responseString) + $resp.ContentType = 'text/plain' + $resp.ContentLength64 = $b.Length + $resp.OutputStream.Write($b,0,$b.Length) + $resp.OutputStream.Close() +} + + +# Off-boarding handlers +function Handle-UninstallCyberQP { + param($Context) + + # 1) call into your module + Uninstall-CyberQP + + Write-LogHybrid "CyberQP uninstalled" "Success" "OffBoard" + Respond-Text $Context "CyberQP uninstalled" +} + +function Cleanup-SVSMSP { + param($Context) + Write-LogHybrid "SVSMSP cleaned up" "Success" "OffBoard" + Respond-Text $Context "SVSMSP cleaned up" +} + +# Tweaks handler +function Disable-Animations { + param($Context) + Write-LogHybrid "Animations disabled" "Success" "Tweaks" + Respond-Text $Context "Animations disabled" +} + +# SVSApps handler +function Install-WingetLastPass { + param($Context) + Write-LogHybrid "Winget LastPass installed" "Success" "SVSApps" + Respond-Text $Context "Winget LastPass installed" +} + +#endregion + +#region UI Generation + +function Build-Checkboxes { + param($Page, $Column) + + ( + $Global:Tasks | + Where-Object Page -EQ $Page | + Where-Object Column -EQ $Column | + ForEach-Object { + $taskId = $_.Id + $tooltip = if ($_.PSObject.Properties.Name -contains 'Tooltip' -and $_.Tooltip) { + " title='$($_.Tooltip)'" + } else { '' } + + $html = " $($_.Label)" + + if ($_.SubOptions) { + # join inside the code block is fine + $subHtml = ( + $_.SubOptions | + ForEach-Object { + "" + } + ) -join "`n" + + $html += @" + +"@ + } + + $html + } + ) -join "`n" +} + + + +### Get SVSMSP module version to display in the UI +function Get-ModuleVersionHtml { + $mod = Get-Module -ListAvailable -Name SVSMSP | Sort-Object Version -Descending | Select-Object -First 1 + if ($mod) { + return "
Module Version: $($mod.Version)
" + } + return "
SVSMSP_Module not found
" +} + + +function Get-UIHtml { + param([string]$Page = 'onboard') + + # + # 1) Inline your full original CSS here + # +$style = @' + +'@ + + $script = @' + + +'@ + + # + # 3) The HTML skeleton with placeholders + # +$htmlTemplate = @" + + + + + + Script Monkey + + $style + + +
+
+ SVS Logo + {{moduleVersion}} +
+
+
+ +
+ +
+
+

On-Boarding

+

This new deployment method ensures everything is successfully deployed with greater ease!

+ + +
+
+

SVSMSP Stack

+ + {{onboardLeftColumn}} +
+
+

Optional

+ + {{onboardRightColumn}} +
+
+ + + + + + + + + +
+

Off-Boarding

+
+{{offboardCheckboxes}} +
+ +
+
+

Tweaks

+
+{{tweaksCheckboxes}} +
+ +
+
+

SVS APPs

+
+{{appsCheckboxes}} +
+ +
+
+
+ $script + +
+ + +
+ + + +"@ + + # + # 4) Build the checkbox HTML and tasks JS from $Global:Tasks + # + + # On-boarding now has two columns: + $onboardLeft = Build-Checkboxes -Page 'onboard' -Column 'left' + $onboardRight = Build-Checkboxes -Page 'onboard' -Column 'right' + + # Off-boarding, Tweaks, SVSApps stay one-column: + $offboard = Build-Checkboxes -Page 'offboard' -Column '' + $tweaks = Build-Checkboxes -Page 'tweaks' -Column '' + $apps = Build-Checkboxes -Page 'SVSApps' -Column '' + + # Tasks JS array (fixed) + $tasksJsAll = ( + $Global:Tasks | ForEach-Object { + " { id: '$($_.Id)', handler: '/$($_.Name)', label: '$($_.Label)' }" + } + ) -join ",`n" + + + # + # 5) Inject into template + # + $html = $htmlTemplate + $html = $html.Replace('{{moduleVersion}}', (Get-ModuleVersionHtml)) + $html = $html.Replace('{{onboardLeftColumn}}', $onboardLeft) + $html = $html.Replace('{{onboardRightColumn}}', $onboardRight) + $html = $html.Replace('{{offboardCheckboxes}}', $offboard) + $html = $html.Replace('{{tweaksCheckboxes}}', $tweaks) + $html = $html.Replace('{{appsCheckboxes}}', $apps) + $html = $html.Replace('{{tasksJsAll}}', $tasksJsAll) + $html = $html.Replace('{{defaultPage}}', $Page) + + + return $html +} + +#endregion + + + + +#region HTTP Listener & Routing + +# Handle shutdown command +if ($path -eq 'quit') { + Write-LogHybrid "Shutdown requested" "Info" "Server" + Respond-Text $Context "Server shutting down." + # This will break out of the while loop in Start-Server + $Global:Listener.Stop() + return +} + +# Sends the HTML for a given page or invokes a task handler +function Dispatch-Request { + param($Context) + + # figure out the path + $path = $Context.Request.Url.AbsolutePath.TrimStart('/') + + # ---- Shutdown handler ---- + if ($path -eq 'quit') { + Write-LogHybrid "Shutdown requested" "Info" "Server" + Respond-Text $Context "Server shutting down." + # stop the listener loop + $Global:Listener.Stop() + return + } + + # ---- Fetch Sites endpoint ---- + if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'getpw') { + Handle-FetchSites $Context + return + } + + # ---- Serve UI pages ---- + if ($path -in @('', 'onboard', 'offboard', 'tweaks', 'SVSApps')) { + $page = if ($path -eq '') { 'onboard' } else { $path } + $html = Get-UIHtml -Page $page + Respond-HTML $Context $html + return + } + + # ---- Task invocation ---- + $task = $Global:Tasks | Where-Object Name -EQ $path + if ($task) { + & $task.HandlerFn $Context + return + } + + # ---- 404 ---- + $Context.Response.StatusCode = 404 + Respond-Text $Context '404 - Not Found' +} + + +# Starts the HTTP listener loop +function Start-Server { + # make it accessible to Dispatch-Request + $Global:Listener = [System.Net.HttpListener]::new() + $Global:Listener.Prefixes.Add("http://localhost:$Port/") + $Global:Listener.Start() + Write-Host "Listening on http://localhost:$Port/ ..." + + try { + while ($Global:Listener.IsListening) { + $ctx = $Global:Listener.GetContext() + try { + Dispatch-Request $ctx + } catch { + Write-LogHybrid "Dispatch error: $_" "Error" "Server" + } + } + } finally { + # once the loop exits, clean up + $Global:Listener.Close() + Write-LogHybrid "Listener closed." "Info" "Server" + } +} + + +#endregion + +# open browser on whatever port you've set +Start-Process "msedge.exe" -ArgumentList "--app=http://localhost:$Port" + +# now start your server (this will block until you hit Exit in the UI) +Start-Server