# Provisions a Windows host (Windows 10/11 or Server 2019+) as a Gitea Actions
# runner for RustDesk desktop builds. Idempotent: safe to re-run.
#
# Versions are pinned to .gitea/workflows/build-windows.yml. Bump them there and
# here together.
#
# Usage (Administrator PowerShell):
#   Set-ExecutionPolicy -Scope Process Bypass -Force
#   .\provision.ps1 -GiteaUrl https://gitea.example.com -RunnerToken <token>
#
# By default the runner service is created under a dedicated local user
# (`gitea-runner`) -- LocalSystem has been observed to break flutter pub get,
# symlink creation, and git's "dubious ownership" check on this codebase. To
# opt out, pass `-ServiceAccount LocalSystem` (not recommended).

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)] [string] $GiteaUrl,
    [Parameter(Mandatory = $true)] [string] $RunnerToken,
    [string] $RunnerName     = "$env:COMPUTERNAME-rustdesk",
    [string] $RunnerLabels   = "windows-10,self-hosted,X64",
    [string] $RunnerVersion  = "0.2.11",
    [string] $ServiceAccount = "gitea-runner",
    [SecureString] $ServiceAccountPassword
)

$ErrorActionPreference = 'Stop'
$ProgressPreference    = 'SilentlyContinue'

# Must run elevated -- nearly every step writes Machine env, HKLM, or service config.
$me = [Security.Principal.WindowsIdentity]::GetCurrent()
if (-not (New-Object Security.Principal.WindowsPrincipal $me).IsInRole(
        [Security.Principal.WindowsBuiltInRole]::Administrator)) {
    throw 'Run this script in an elevated (Administrator) PowerShell session.'
}

# --- pinned versions (mirror .gitea/workflows/build-windows.yml env block) ---
$RUST_VERSION    = '1.75.0'
$RUST_NIGHTLY    = 'nightly-2023-10-13'
$LLVM_VERSION    = '15.0.6'
$FLUTTER_VERSION = '3.24.5'
$VCPKG_COMMIT    = '120deac3062162151622ca4860575a33844ba10b'

$ToolsRoot = 'C:\tools'
New-Item -ItemType Directory -Force -Path $ToolsRoot | Out-Null

# Exact-segment-match version of PATH augmentation. Substring matching would
# falsely find C:\bin when C:\binaries is on PATH.
function Add-MachinePath([string]$Dir) {
    $cur = [Environment]::GetEnvironmentVariable('Path', 'Machine')
    $segments = $cur -split ';' | Where-Object { $_ }
    if ($segments -notcontains $Dir) {
        [Environment]::SetEnvironmentVariable('Path', "$cur;$Dir", 'Machine')
    }
    if (($env:Path -split ';') -notcontains $Dir) { $env:Path = "$env:Path;$Dir" }
}

# --- 1. Chocolatey (used for git, python, nuget, 7zip, node, dotnet, ...) ---
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
    Write-Host '==> Installing Chocolatey'
    Set-ExecutionPolicy Bypass -Scope Process -Force
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    Invoke-Expression ((New-Object Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
}

Write-Host '==> Installing base packages'
# nodejs-lts:      act_runner spawns Node to execute JavaScript actions.
# powershell-core: workflows use `shell: pwsh` (PS 7), not the OS's PS 5.1.
# dotnet-sdk:      WiX 4 SDK-style projects (.wixproj) need it for the MSI build.
choco install -y --no-progress `
    git python311 nuget.commandline 7zip cmake ninja `
    nodejs-lts powershell-core dotnet-sdk
Add-MachinePath 'C:\Program Files\Git\cmd'
Add-MachinePath 'C:\Program Files\Git\bin'   # bash.exe + posix tools (sed, find, ...)
Add-MachinePath 'C:\Python311'
Add-MachinePath 'C:\Python311\Scripts'
Add-MachinePath 'C:\Program Files\nodejs'
Add-MachinePath 'C:\Program Files\PowerShell\7'
Add-MachinePath 'C:\Program Files\dotnet'

# --- 2. Visual Studio 2022 Build Tools (MSVC v143 + Win10 SDK) ---
# Use [Environment]::GetEnvironmentVariable to avoid the PowerShell parser quirk
# that mis-tokenises `$env:ProgramFiles(x86)` as `$env:ProgramFiles` + `(x86)`.
$pfx86       = [Environment]::GetEnvironmentVariable('ProgramFiles(x86)')
$vsInstaller = Join-Path $pfx86 'Microsoft Visual Studio\Installer\vswhere.exe'
$vsPresent   = (Test-Path $vsInstaller) -and `
               ((& $vsInstaller -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath) -ne $null)
if (-not $vsPresent) {
    Write-Host '==> Installing VS 2022 Build Tools (this takes a while)'
    $vsBootstrapper = "$env:TEMP\vs_buildtools.exe"
    Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_buildtools.exe' -OutFile $vsBootstrapper
    $vsArgs = @(
        '--quiet','--wait','--norestart','--nocache',
        '--add','Microsoft.VisualStudio.Workload.VCTools',
        '--add','Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
        '--add','Microsoft.VisualStudio.Component.VC.ATL',
        '--add','Microsoft.VisualStudio.Component.Windows10SDK.20348',
        '--add','Microsoft.VisualStudio.Component.VC.CMake.Project',
        '--includeRecommended'
    )
    $p = Start-Process -FilePath $vsBootstrapper -ArgumentList $vsArgs -Wait -PassThru
    if ($p.ExitCode -notin 0,3010) { throw "VS Build Tools installer exit $($p.ExitCode)" }
}

# --- 3. Rust (stable + nightly with i686 target) ---
# Install machine-wide so any user (including the dedicated runner account)
# shares one toolchain registry. Without this, rustup state lives in the
# installing user's profile and the service user has no default toolchain.
$rustupHome = 'C:\rustup'
$cargoHome  = 'C:\cargo'
[Environment]::SetEnvironmentVariable('RUSTUP_HOME', $rustupHome, 'Machine')
[Environment]::SetEnvironmentVariable('CARGO_HOME',  $cargoHome,  'Machine')
$env:RUSTUP_HOME = $rustupHome
$env:CARGO_HOME  = $cargoHome
Add-MachinePath "$cargoHome\bin"

if (-not (Test-Path "$cargoHome\bin\rustup.exe")) {
    Write-Host '==> Installing rustup (machine-wide at C:\rustup, C:\cargo)'
    Invoke-WebRequest -Uri 'https://win.rustup.rs/x86_64' -OutFile "$env:TEMP\rustup-init.exe"
    & "$env:TEMP\rustup-init.exe" -y --default-toolchain none --profile minimal
}
rustup toolchain install $RUST_VERSION --profile minimal --component rustfmt
rustup target add --toolchain $RUST_VERSION x86_64-pc-windows-msvc
rustup toolchain install $RUST_NIGHTLY --profile minimal --component rustfmt
rustup target add --toolchain $RUST_NIGHTLY i686-pc-windows-msvc
rustup default $RUST_VERSION

# --- 4. LLVM/Clang (matches KyleMayes/install-llvm-action layout) ---
$llvmDir = "$ToolsRoot\llvm-$LLVM_VERSION"
if (-not (Test-Path "$llvmDir\bin\clang.exe")) {
    Write-Host "==> Installing LLVM $LLVM_VERSION"
    $llvmExe = "$env:TEMP\LLVM-$LLVM_VERSION-win64.exe"
    Invoke-WebRequest -Uri "https://github.com/llvm/llvm-project/releases/download/llvmorg-$LLVM_VERSION/LLVM-$LLVM_VERSION-win64.exe" -OutFile $llvmExe
    & $llvmExe /S "/D=$llvmDir" | Out-Null
}
[Environment]::SetEnvironmentVariable('LIBCLANG_PATH', "$llvmDir\bin", 'Machine')
Add-MachinePath "$llvmDir\bin"

# --- 5. Flutter (stable channel, with windows precache) ---
$flutterDir = "$ToolsRoot\flutter"
if (-not (Test-Path "$flutterDir\bin\flutter.bat")) {
    Write-Host "==> Installing Flutter $FLUTTER_VERSION"
    $flutterZip = "$env:TEMP\flutter.zip"
    Invoke-WebRequest -Uri "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_$FLUTTER_VERSION-stable.zip" -OutFile $flutterZip
    Expand-Archive -Force -Path $flutterZip -DestinationPath $ToolsRoot
}
Add-MachinePath "$flutterDir\bin"
& "$flutterDir\bin\flutter.bat" config --no-analytics | Out-Null
& "$flutterDir\bin\flutter.bat" precache --windows | Out-Null

# --- 6. vcpkg pinned to commit ---
$vcpkgDir = 'C:\vcpkg'
if (-not (Test-Path "$vcpkgDir\.git")) {
    Write-Host '==> Cloning vcpkg'
    git clone https://github.com/microsoft/vcpkg.git $vcpkgDir
}
Push-Location $vcpkgDir
git fetch --tags origin
git -c advice.detachedHead=false checkout $VCPKG_COMMIT
if (-not (Test-Path "$vcpkgDir\vcpkg.exe")) { & "$vcpkgDir\bootstrap-vcpkg.bat" -disableMetrics }
Pop-Location
[Environment]::SetEnvironmentVariable('VCPKG_ROOT', $vcpkgDir, 'Machine')
Add-MachinePath $vcpkgDir

# --- 7. CI prerequisites that aren't tools, but environmental switches ---

# git's "dubious ownership" check (>= 2.35.2) refuses to operate on a repo whose
# .git directory is owned by a different user than the one running git. The
# Flutter SDK at C:\tools\flutter is provisioned by this script as Administrator
# but the runner service runs as a non-admin user. Trust everything system-wide.
git config --system --add safe.directory '*' 2>$null

# Flutter on Windows needs SeCreateSymbolicLinkPrivilege to build plugins.
# Enable Developer Mode (registry) AND grant the privilege via Local Security
# Policy to the built-in "Users" group (SID S-1-5-32-545). Either alone has been
# observed to not take effect until logon-token refresh; doing both is
# belt-and-suspenders. The privilege only reaches a long-running service after
# a reboot or a fresh service-token issuance.
$devKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock'
if (-not (Test-Path $devKey)) { New-Item -Path $devKey -Force | Out-Null }
New-ItemProperty -Path $devKey -Name 'AllowDevelopmentWithoutDevLicense' `
    -PropertyType DWORD -Value 1 -Force | Out-Null

$secCfg = "$env:TEMP\sec-symlink.cfg"
secedit /export /cfg $secCfg | Out-Null
$secContent = Get-Content $secCfg -Raw
if ($secContent -match 'SeCreateSymbolicLinkPrivilege\s*=\s*([^\r\n]*)') {
    $cur = $matches[1]
    if ($cur -notmatch '\*S-1-5-32-545') {
        $secContent = $secContent -replace `
            '(SeCreateSymbolicLinkPrivilege\s*=\s*)([^\r\n]*)', `
            '$1$2,*S-1-5-32-545'
    }
} else {
    $secContent = $secContent -replace `
        '(\[Privilege Rights\][\r\n]+)', `
        "`$1SeCreateSymbolicLinkPrivilege = *S-1-5-32-545`r`n"
}
$secContent | Set-Content $secCfg
secedit /configure /db "$env:TEMP\sec-symlink.sdb" /cfg $secCfg /areas USER_RIGHTS /quiet
Remove-Item $secCfg, "$env:TEMP\sec-symlink.sdb" -ErrorAction SilentlyContinue

# --- 8. Dedicated runner user ---
# Running as LocalSystem causes a cascade of issues:
#   - $USERPROFILE = C:\Windows\System32\config\systemprofile, which Flutter,
#     dart pub, and other POSIX-leaning tools mis-handle.
#   - cargo install lands binaries in that systemprofile path -> not on PATH.
#   - flutter/windows occasionally vanishes during long cargo builds.
# A normal local user fixes all of these.
if ($ServiceAccount -ne 'LocalSystem') {
    if (-not (Get-LocalUser -Name $ServiceAccount -ErrorAction SilentlyContinue)) {
        if (-not $ServiceAccountPassword) {
            # Generate a 32-byte random password using the OS RNG. Encoded as
            # base64 (alphanumeric + +/) and trimmed of padding -- meets local
            # password complexity without needing System.Web (which is missing
            # on Server Core).
            $bytes = New-Object byte[] 24
            [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
            $plain = ([Convert]::ToBase64String($bytes)).TrimEnd('=') + 'A1!'
            $ServiceAccountPassword = ConvertTo-SecureString $plain -AsPlainText -Force
            Remove-Variable plain, bytes
        }
        Write-Host "==> Creating local user '$ServiceAccount'"
        New-LocalUser -Name $ServiceAccount -Password $ServiceAccountPassword `
                      -PasswordNeverExpires -AccountNeverExpires `
                      -Description 'Gitea Actions runner service account' | Out-Null
        Add-LocalGroupMember -Group 'Users' -Member $ServiceAccount
    }

    # Grant "Log on as a service" via secedit (no PS native cmdlet for this).
    $sid = (Get-LocalUser $ServiceAccount).SID.Value
    $svcCfg = "$env:TEMP\sec-svc.cfg"
    secedit /export /cfg $svcCfg | Out-Null
    $svcContent = Get-Content $svcCfg -Raw
    if ($svcContent -match "SeServiceLogonRight\s*=\s*([^\r\n]*)") {
        if ($matches[1] -notmatch [regex]::Escape($sid)) {
            $svcContent = $svcContent -replace `
                '(SeServiceLogonRight\s*=\s*)([^\r\n]*)', `
                "`$1`$2,*$sid"
        }
    } else {
        $svcContent = $svcContent -replace `
            '(\[Privilege Rights\][\r\n]+)', `
            "`$1SeServiceLogonRight = *$sid`r`n"
    }
    $svcContent | Set-Content $svcCfg
    secedit /configure /db "$env:TEMP\sec-svc.sdb" /cfg $svcCfg /areas USER_RIGHTS /quiet
    Remove-Item $svcCfg, "$env:TEMP\sec-svc.sdb" -ErrorAction SilentlyContinue

    # Ensure the user can read/write everything it needs for builds.
    foreach ($p in @('C:\actions-runner','C:\cargo','C:\cargo-tools','C:\vcpkg','C:\vcpkg-cache')) {
        New-Item -ItemType Directory -Force -Path $p | Out-Null
        icacls $p /grant "${ServiceAccount}:(OI)(CI)F" /T 2>$null | Out-Null
    }
    foreach ($p in @('C:\rustup','C:\tools')) {
        if (Test-Path $p) { icacls $p /grant "${ServiceAccount}:(OI)(CI)RX" /T 2>$null | Out-Null }
    }
}

# --- 9. Gitea act_runner ---
$runnerDir = 'C:\actions-runner'
New-Item -ItemType Directory -Force -Path $runnerDir | Out-Null
$runnerExe = "$runnerDir\act_runner.exe"
if (-not (Test-Path $runnerExe)) {
    Write-Host "==> Downloading act_runner $RunnerVersion"
    Invoke-WebRequest -Uri "https://gitea.com/gitea/act_runner/releases/download/v$RunnerVersion/act_runner-$RunnerVersion-windows-amd64.exe" -OutFile $runnerExe
}

Push-Location $runnerDir
if (-not (Test-Path "$runnerDir\.runner")) {
    Write-Host '==> Registering runner'
    & $runnerExe register --no-interactive `
        --instance $GiteaUrl `
        --token    $RunnerToken `
        --name     $RunnerName `
        --labels   $RunnerLabels
}

# Reconfigure the service every run so re-running with a different
# -ServiceAccount actually takes effect.
$svc = Get-Service -Name 'gitea-act-runner' -ErrorAction SilentlyContinue
if ($svc) {
    if ($svc.Status -eq 'Running') { Stop-Service gitea-act-runner }
} else {
    Write-Host '==> Installing runner as Windows service'
    choco install -y --no-progress nssm
    nssm install gitea-act-runner $runnerExe daemon | Out-Null
}
nssm set gitea-act-runner AppDirectory $runnerDir          | Out-Null
nssm set gitea-act-runner Start         SERVICE_AUTO_START | Out-Null
nssm set gitea-act-runner AppStdout     "$runnerDir\runner.log" | Out-Null
nssm set gitea-act-runner AppStderr     "$runnerDir\runner.log" | Out-Null

if ($ServiceAccount -eq 'LocalSystem') {
    nssm set gitea-act-runner ObjectName 'LocalSystem' | Out-Null
} else {
    $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ServiceAccountPassword)
    try {
        $plain = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($bstr)
        nssm set gitea-act-runner ObjectName ".\$ServiceAccount" $plain | Out-Null
    } finally {
        [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
        Remove-Variable plain -ErrorAction SilentlyContinue
    }
}

# Start may fail before reboot if the new SeServiceLogonRight hasn't reached
# SCM yet -- that's expected; the service will start cleanly after reboot.
try {
    Start-Service gitea-act-runner
} catch {
    Write-Warning "Could not start gitea-act-runner now ($($_.Exception.Message)). It will start on reboot."
}
Pop-Location

Write-Host ''
Write-Host '==> Done.'
Write-Host '    A reboot is REQUIRED before the first build run, so:'
Write-Host '      - the runner service inherits the new SeCreateSymbolicLinkPrivilege token'
Write-Host '      - all PATH/env changes propagate to the SCM-launched service'
Write-Host '    After reboot, verify the runner shows up in Gitea > Site Admin > Actions > Runners.'
if ($ServiceAccount -eq 'LocalSystem') {
    Write-Warning 'Service is running as LocalSystem. RustDesk builds have been observed to fail in this configuration (Flutter pub get, symlinks, dubious ownership). Re-run with -ServiceAccount gitea-runner to switch.'
}
