feat: 建立 Sense Windows 交付包 (#70)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-windows.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,106 @@
|
||||
param([string]$MediaMTXPath = '')
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$senseRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
|
||||
$serverRoot = Join-Path $senseRoot 'server'
|
||||
$uiRoot = Join-Path $senseRoot 'ui'
|
||||
$distRoot = Join-Path $senseRoot 'dist'
|
||||
$target = Join-Path $distRoot 'sense-windows-amd64'
|
||||
$archive = Join-Path $distRoot 'sense-windows-amd64.zip'
|
||||
$staging = Join-Path $distRoot ('.sense-windows-amd64.staging-' + $PID)
|
||||
|
||||
function Assert-ChildPath([string]$Parent, [string]$Child) {
|
||||
$parentPath = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\'
|
||||
$childPath = [IO.Path]::GetFullPath($Child)
|
||||
if (-not $childPath.StartsWith($parentPath, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Unsafe build path outside $Parent`: $Child"
|
||||
}
|
||||
}
|
||||
|
||||
Assert-ChildPath $senseRoot $distRoot
|
||||
Assert-ChildPath $distRoot $target
|
||||
Assert-ChildPath $distRoot $archive
|
||||
Assert-ChildPath $distRoot $staging
|
||||
|
||||
$goVersion = ''
|
||||
Push-Location $serverRoot
|
||||
try { $goVersion = (& go env GOVERSION).Trim() } finally { Pop-Location }
|
||||
$nodeVersion = (& node --version).Trim().TrimStart('v')
|
||||
$pnpmVersion = (& corepack pnpm@9.15.1 --version).Trim()
|
||||
if ($goVersion -ne 'go1.26.5') { throw "Go 1.26.5 is required; module toolchain reported $goVersion." }
|
||||
if ($nodeVersion -ne '22.22.1') { throw "Node 22.22.1 is required; found $nodeVersion." }
|
||||
if ($pnpmVersion -ne '9.15.1') { throw "pnpm 9.15.1 is required; corepack reported $pnpmVersion." }
|
||||
|
||||
$hadNodeModules = Test-Path -LiteralPath (Join-Path $uiRoot 'node_modules')
|
||||
$hadUIDist = Test-Path -LiteralPath (Join-Path $uiRoot 'dist')
|
||||
try {
|
||||
New-Item -ItemType Directory -Force -Path $distRoot | Out-Null
|
||||
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $staging | Out-Null
|
||||
|
||||
Push-Location $uiRoot
|
||||
try {
|
||||
& corepack pnpm@9.15.1 install --frozen-lockfile
|
||||
if ($LASTEXITCODE -ne 0) { throw 'pnpm install failed.' }
|
||||
& corepack pnpm@9.15.1 run build:prod
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Sense UI production build failed.' }
|
||||
} finally { Pop-Location }
|
||||
|
||||
$oldGOOS, $oldGOARCH, $oldCGO = $env:GOOS, $env:GOARCH, $env:CGO_ENABLED
|
||||
try {
|
||||
$env:GOOS = 'windows'; $env:GOARCH = 'amd64'; $env:CGO_ENABLED = '0'
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
& go build -trimpath -ldflags '-s -w' -o (Join-Path $staging 'sense.exe') .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Sense server Windows build failed.' }
|
||||
} finally { Pop-Location }
|
||||
} finally {
|
||||
$env:GOOS, $env:GOARCH, $env:CGO_ENABLED = $oldGOOS, $oldGOARCH, $oldCGO
|
||||
}
|
||||
|
||||
Copy-Item -LiteralPath (Join-Path $uiRoot 'dist') -Destination (Join-Path $staging 'web') -Recurse
|
||||
New-Item -ItemType Directory -Path (Join-Path $staging 'scripts\runtime'), (Join-Path $staging 'config'), (Join-Path $staging 'bin') | Out-Null
|
||||
Copy-Item -Path (Join-Path $senseRoot 'scripts\runtime\*.ps1') -Destination (Join-Path $staging 'scripts\runtime')
|
||||
foreach ($name in @('start-sense', 'stop-sense', 'check-sense', 'migrate-sense', 'backup-sense', 'restore-sense', 'initialize-admin')) {
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot "scripts\runtime\$name.bat") -Destination (Join-Path $staging "$name.bat")
|
||||
}
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.env.example') -Destination (Join-Path $staging 'config\sense.env.example')
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.env.example') -Destination (Join-Path $staging 'config\sense.env')
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.demo.env.example') -Destination (Join-Path $staging 'config\sense.demo.env.example')
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.demo.env.example') -Destination (Join-Path $staging 'config\sense.demo.env')
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\mediamtx.yml') -Destination (Join-Path $staging 'config\mediamtx.yml')
|
||||
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\db.sql') -Destination (Join-Path $staging 'config\db.sql')
|
||||
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\pg.sql') -Destination (Join-Path $staging 'config\pg.sql')
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'README-WINDOWS.md') -Destination (Join-Path $staging 'README-WINDOWS.md')
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'package\README-MEDIAMTX.txt') -Destination (Join-Path $staging 'bin\README-MEDIAMTX.txt')
|
||||
Copy-Item -LiteralPath (Join-Path $senseRoot 'LICENSES') -Destination (Join-Path $staging 'LICENSES') -Recurse
|
||||
if (-not [string]::IsNullOrWhiteSpace($MediaMTXPath)) {
|
||||
$mediaSource = [IO.Path]::GetFullPath($MediaMTXPath)
|
||||
if (-not (Test-Path -LiteralPath $mediaSource -PathType Leaf)) { throw "MediaMTX binary not found: $mediaSource" }
|
||||
Copy-Item -LiteralPath $mediaSource -Destination (Join-Path $staging 'bin\mediamtx.exe')
|
||||
}
|
||||
$commit = (& git -C (Split-Path $senseRoot -Parent) rev-parse HEAD).Trim()
|
||||
[IO.File]::WriteAllLines((Join-Path $staging 'VERSION.txt'), @(
|
||||
"source_commit=$commit", 'go=1.26.5', 'node=22.22.1', 'pnpm=9.15.1'
|
||||
), (New-Object Text.UTF8Encoding($false)))
|
||||
|
||||
& (Join-Path $PSScriptRoot 'test-package.ps1') -PackageRoot $staging
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Sense package audit failed.' }
|
||||
$manifest = foreach ($file in Get-ChildItem -LiteralPath $staging -Recurse -File | Sort-Object FullName) {
|
||||
$relative = $file.FullName.Substring($staging.Length + 1).Replace('\', '/')
|
||||
"$(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 | Select-Object -ExpandProperty Hash) $relative"
|
||||
}
|
||||
[IO.File]::WriteAllLines((Join-Path $staging 'MANIFEST.sha256'), $manifest, (New-Object Text.UTF8Encoding($false)))
|
||||
|
||||
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
|
||||
Move-Item -LiteralPath $staging -Destination $target
|
||||
if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force }
|
||||
Compress-Archive -LiteralPath $target -DestinationPath $archive -CompressionLevel Optimal
|
||||
Write-Host "Sense Windows package: $target"
|
||||
Write-Host "Sense Windows archive: $archive"
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
|
||||
if (-not $hadUIDist -and (Test-Path -LiteralPath (Join-Path $uiRoot 'dist'))) { Remove-Item -LiteralPath (Join-Path $uiRoot 'dist') -Recurse -Force }
|
||||
if (-not $hadNodeModules -and (Test-Path -LiteralPath (Join-Path $uiRoot 'node_modules'))) { Remove-Item -LiteralPath (Join-Path $uiRoot 'node_modules') -Recurse -Force }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
param([Parameter(Mandatory = $true)][string]$PackageRoot)
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = [IO.Path]::GetFullPath($PackageRoot)
|
||||
if (-not (Test-Path -LiteralPath $root -PathType Container)) { throw "Package directory not found: $root" }
|
||||
$required = @(
|
||||
'sense.exe', 'start-sense.bat', 'stop-sense.bat', 'check-sense.bat',
|
||||
'migrate-sense.bat', 'backup-sense.bat', 'restore-sense.bat',
|
||||
'initialize-admin.bat', 'README-WINDOWS.md', 'config\sense.env',
|
||||
'config\sense.env.example', 'config\sense.demo.env',
|
||||
'config\mediamtx.yml', 'config\db.sql', 'config\pg.sql',
|
||||
'web\index.html', 'scripts\runtime\sense-common.ps1'
|
||||
)
|
||||
foreach ($relative in $required) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $root $relative))) { throw "Package is missing required path: $relative" }
|
||||
}
|
||||
$forbiddenDirectories = Get-ChildItem -LiteralPath $root -Recurse -Directory | Where-Object { $_.Name -in @('node_modules', '.git', 'dist', '.cache') }
|
||||
if ($forbiddenDirectories) { throw "Package contains forbidden build directory: $($forbiddenDirectories[0].FullName)" }
|
||||
$forbiddenFiles = Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $_.Extension -in @('.db', '.sqlite', '.sqlite3', '.dump', '.bak') }
|
||||
if ($forbiddenFiles) { throw "Package contains database or backup data: $($forbiddenFiles[0].FullName)" }
|
||||
$configFiles = @((Join-Path $root 'config\sense.env'), (Join-Path $root 'config\sense.demo.env'))
|
||||
foreach ($configFile in $configFiles) {
|
||||
$content = Get-Content -LiteralPath $configFile -Raw
|
||||
foreach ($secret in @('SENSE_DATABASE_URL', 'SENSE_DEMO_DATABASE_URL', 'SENSE_JWT_SECRET', 'SENSE_BOOTSTRAP_TOKEN', 'SENSE_CREDENTIAL_KEY')) {
|
||||
if ($content -match "(?m)^$secret[ \t]*=[ \t]*[^ \t\r\n]") { throw "Package contains a non-empty secret field: $secret" }
|
||||
}
|
||||
}
|
||||
$textExtensions = @('.md', '.txt', '.env', '.example', '.ps1', '.bat', '.yml', '.yaml', '.json', '.html', '.js', '.css', '.sql')
|
||||
foreach ($file in Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $textExtensions -contains $_.Extension.ToLowerInvariant() }) {
|
||||
$content = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue
|
||||
if ($content -match '(?i)(admin123|password123|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY)') {
|
||||
throw "Package contains a forbidden default credential or private key marker: $($file.FullName)"
|
||||
}
|
||||
}
|
||||
Write-Host "Sense package audit passed: $root"
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\backup-sense.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,22 @@
|
||||
param(
|
||||
[ValidateSet('production', 'demo')][string]$Mode = 'production',
|
||||
[string]$OutputDirectory = ''
|
||||
)
|
||||
. (Join-Path $PSScriptRoot 'sense-common.ps1')
|
||||
|
||||
try {
|
||||
$root = Get-SensePackageRoot
|
||||
$state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort
|
||||
$pgDump = Get-SensePostgresTool -Name 'pg_dump'
|
||||
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { $OutputDirectory = Join-Path $root 'backups' }
|
||||
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
|
||||
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$output = Join-Path $OutputDirectory "sense-$($state.Database.Database)-$stamp.dump"
|
||||
Invoke-SensePostgresTool -Tool $pgDump -Database $state.Database -Arguments @('--dbname', $state.Database.Sanitized, '--format=custom', '--no-owner', '--file', $output)
|
||||
Write-Host "Sense backup created: $output"
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\check-sense.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,19 @@
|
||||
param(
|
||||
[ValidateSet('production', 'demo')][string]$Mode = 'production',
|
||||
[switch]$Running
|
||||
)
|
||||
. (Join-Path $PSScriptRoot 'sense-common.ps1')
|
||||
|
||||
try {
|
||||
$root = Get-SensePackageRoot
|
||||
$state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort:$Running
|
||||
if ($Running -and -not (Test-SenseTcpEndpoint -HostName $state.Host -Port $state.Port)) {
|
||||
throw "Sense is not accepting TCP connections at $($state.Host):$($state.Port)."
|
||||
}
|
||||
Write-Host "Sense $Mode configuration check passed."
|
||||
Write-Host "PostgreSQL endpoint: reachable; MediaMTX mode: $($state.MediaMode); HTTP port: $($state.Port)."
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\initialize-admin.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,29 @@
|
||||
param(
|
||||
[string]$Username = '',
|
||||
[ValidateSet('production', 'demo')][string]$Mode = 'production'
|
||||
)
|
||||
. (Join-Path $PSScriptRoot 'sense-common.ps1')
|
||||
|
||||
$passwordPointer = [IntPtr]::Zero
|
||||
try {
|
||||
$root = Get-SensePackageRoot
|
||||
$state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort
|
||||
$token = Get-SenseEnvironmentValue -Name 'SENSE_BOOTSTRAP_TOKEN'
|
||||
if ($token.Length -lt 32) { throw 'Set a temporary random SENSE_BOOTSTRAP_TOKEN of at least 32 characters, then restart Sense.' }
|
||||
if ([string]::IsNullOrWhiteSpace($Username)) { $Username = Read-Host 'Administrator username' }
|
||||
$securePassword = Read-Host 'Administrator password (at least 6 characters)' -AsSecureString
|
||||
$passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword)
|
||||
$password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPointer)
|
||||
$body = @{ username = $Username; password = $password; nickName = $Username } | ConvertTo-Json -Compress
|
||||
$headers = @{ 'X-Sense-Bootstrap-Token' = $token }
|
||||
$uri = "http://$($state.Host):$($state.Port)/api/v1/public/bootstrap"
|
||||
Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $body | Out-Null
|
||||
Write-Host 'Sense administrator created. Remove SENSE_BOOTSTRAP_TOKEN from config/sense.env and restart Sense now.'
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
} finally {
|
||||
if ($passwordPointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer) }
|
||||
Remove-Variable password -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\migrate-sense.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,19 @@
|
||||
param([ValidateSet('production', 'demo')][string]$Mode = 'production')
|
||||
. (Join-Path $PSScriptRoot 'sense-common.ps1')
|
||||
|
||||
try {
|
||||
$root = Get-SensePackageRoot
|
||||
$state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort
|
||||
$sense = Join-Path $root 'sense.exe'
|
||||
Write-Host 'Applying pending Sense database migrations...'
|
||||
Push-Location $root
|
||||
try {
|
||||
& $sense migrate -c $state.SettingsPath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Sense database migration failed.' }
|
||||
} finally { Pop-Location }
|
||||
Write-Host 'Sense database migration completed.'
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\restore-sense.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,27 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$BackupFile,
|
||||
[Parameter(Mandatory = $true)][string]$ConfirmDatabaseName,
|
||||
[ValidateSet('production', 'demo')][string]$Mode = 'production',
|
||||
[string]$Confirmation = ''
|
||||
)
|
||||
. (Join-Path $PSScriptRoot 'sense-common.ps1')
|
||||
|
||||
try {
|
||||
$root = Get-SensePackageRoot
|
||||
$state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort
|
||||
$backup = [IO.Path]::GetFullPath($BackupFile)
|
||||
if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { throw "Backup file not found: $backup" }
|
||||
if ($ConfirmDatabaseName -cne $state.Database.Database) {
|
||||
throw 'Restore confirmation does not exactly match the configured database name.'
|
||||
}
|
||||
$pgRestore = Get-SensePostgresTool -Name 'pg_restore'
|
||||
Write-Warning "Restoring will replace objects in database '$ConfirmDatabaseName'. Stop Sense before continuing."
|
||||
$answer = if ([string]::IsNullOrWhiteSpace($Confirmation)) { Read-Host "Type RESTORE-$ConfirmDatabaseName to continue" } else { $Confirmation }
|
||||
if ($answer -cne "RESTORE-$ConfirmDatabaseName") { throw 'Restore cancelled.' }
|
||||
Invoke-SensePostgresTool -Tool $pgRestore -Database $state.Database -Arguments @('--dbname', $state.Database.Sanitized, '--clean', '--if-exists', '--no-owner', '--exit-on-error', $backup)
|
||||
Write-Host 'Sense database restore completed. Run migrate-sense.bat before starting Sense.'
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$script:SenseAllowedEnvironment = @(
|
||||
'SENSE_MODE', 'SENSE_HOST', 'SENSE_PORT', 'SENSE_DATABASE_URL',
|
||||
'SENSE_DEMO_DATABASE_URL', 'SENSE_JWT_SECRET', 'SENSE_BOOTSTRAP_TOKEN',
|
||||
'SENSE_CREDENTIAL_KEY', 'SENSE_ONVIF_DISCOVERY_IP',
|
||||
'SENSE_ONVIF_ALLOWED_CIDRS', 'SENSE_MEDIAMTX_MODE',
|
||||
'SENSE_MEDIAMTX_BINARY', 'SENSE_MEDIAMTX_CONFIG',
|
||||
'SENSE_MEDIAMTX_API', 'SENSE_WEB_ROOT', 'SENSE_AUTO_MIGRATE',
|
||||
'SENSE_POSTGRES_BIN'
|
||||
)
|
||||
|
||||
function Get-SensePackageRoot {
|
||||
param([string]$ScriptDirectory = $PSScriptRoot)
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $ScriptDirectory '..\..'))
|
||||
}
|
||||
|
||||
function Import-SenseEnvironment {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
throw "Sense configuration file not found: $Path"
|
||||
}
|
||||
$lineNumber = 0
|
||||
foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) {
|
||||
$lineNumber++
|
||||
$line = $rawLine.Trim()
|
||||
if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue }
|
||||
$separator = $line.IndexOf('=')
|
||||
if ($separator -lt 1) {
|
||||
throw "Invalid Sense configuration at line $lineNumber. Expected NAME=value."
|
||||
}
|
||||
$name = $line.Substring(0, $separator).Trim()
|
||||
if ($script:SenseAllowedEnvironment -notcontains $name) {
|
||||
throw "Unsupported Sense configuration key at line ${lineNumber}: $name"
|
||||
}
|
||||
$value = $line.Substring($separator + 1)
|
||||
if ($value.Length -ge 2) {
|
||||
$first, $last = $value[0], $value[$value.Length - 1]
|
||||
if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
}
|
||||
$existing = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||||
if ([string]::IsNullOrWhiteSpace($existing)) {
|
||||
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-SenseEnvironmentValue {
|
||||
param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '')
|
||||
$value = [Environment]::GetEnvironmentVariable($Name, 'Process')
|
||||
if ([string]::IsNullOrWhiteSpace($value)) { return $Default }
|
||||
return $value
|
||||
}
|
||||
|
||||
function ConvertTo-SenseYamlString {
|
||||
param([AllowEmptyString()][string]$Value)
|
||||
return ($Value | ConvertTo-Json -Compress)
|
||||
}
|
||||
|
||||
function Resolve-SenseConfiguredPath {
|
||||
param([Parameter(Mandatory = $true)][string]$PackageRoot, [AllowEmptyString()][string]$Value)
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
|
||||
if ([System.IO.Path]::IsPathRooted($Value)) {
|
||||
return [System.IO.Path]::GetFullPath($Value)
|
||||
}
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $PackageRoot $Value))
|
||||
}
|
||||
|
||||
function Get-SenseDatabaseInfo {
|
||||
param([Parameter(Mandatory = $true)][string]$Connection)
|
||||
$result = @{ Host = '127.0.0.1'; Port = 5432; Database = ''; Sanitized = $Connection; Password = '' }
|
||||
if ($Connection -match '^postgres(?:ql)?://') {
|
||||
$uri = [Uri]$Connection
|
||||
$result.Host = $uri.Host
|
||||
if (-not $uri.IsDefaultPort) { $result.Port = $uri.Port }
|
||||
$result.Database = $uri.AbsolutePath.TrimStart('/')
|
||||
if ($uri.UserInfo) {
|
||||
$parts = $uri.UserInfo.Split(':', 2)
|
||||
$user = [Uri]::UnescapeDataString($parts[0])
|
||||
if ($parts.Count -eq 2) { $result.Password = [Uri]::UnescapeDataString($parts[1]) }
|
||||
$builder = [UriBuilder]$uri
|
||||
$builder.UserName = $user
|
||||
$builder.Password = ''
|
||||
$result.Sanitized = $builder.Uri.AbsoluteUri
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
$matches = [regex]::Matches($Connection, '(?:^|\s)(?<key>[A-Za-z_][A-Za-z0-9_]*)=(?<value>''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\s]+)')
|
||||
$sanitized = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($match in $matches) {
|
||||
$key = $match.Groups['key'].Value
|
||||
$raw = $match.Groups['value'].Value
|
||||
$value = $raw
|
||||
if ($raw.Length -ge 2 -and (($raw[0] -eq "'" -and $raw[$raw.Length - 1] -eq "'") -or ($raw[0] -eq '"' -and $raw[$raw.Length - 1] -eq '"'))) {
|
||||
$value = $raw.Substring(1, $raw.Length - 2)
|
||||
}
|
||||
if ($key.ToLowerInvariant() -eq 'password') {
|
||||
$result.Password = $value
|
||||
continue
|
||||
}
|
||||
switch ($key.ToLowerInvariant()) {
|
||||
'host' { $result.Host = $value }
|
||||
'port' { $result.Port = [int]$value }
|
||||
'dbname' { $result.Database = $value }
|
||||
}
|
||||
$sanitized.Add("$key=$raw")
|
||||
}
|
||||
if ($matches.Count -eq 0) { throw 'SENSE_DATABASE_URL must be a PostgreSQL URI or keyword connection string.' }
|
||||
$result.Sanitized = $sanitized -join ' '
|
||||
return $result
|
||||
}
|
||||
|
||||
function Test-SenseTcpEndpoint {
|
||||
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port, [int]$TimeoutMilliseconds = 2000)
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
try {
|
||||
$task = $client.ConnectAsync($HostName, $Port)
|
||||
if (-not $task.Wait($TimeoutMilliseconds)) { return $false }
|
||||
return $client.Connected
|
||||
} catch {
|
||||
return $false
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Test-SenseListenPortAvailable {
|
||||
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port)
|
||||
$ip = if ($HostName -eq '0.0.0.0') { [Net.IPAddress]::Any } elseif ($HostName -eq 'localhost') { [Net.IPAddress]::Loopback } else { [Net.IPAddress]::Parse($HostName) }
|
||||
$listener = New-Object Net.Sockets.TcpListener($ip, $Port)
|
||||
try { $listener.Start(); return $true } catch { return $false } finally { try { $listener.Stop() } catch {} }
|
||||
}
|
||||
|
||||
function Initialize-SenseRuntime {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PackageRoot,
|
||||
[ValidateSet('production', 'demo')][string]$Mode = 'production',
|
||||
[switch]$AllowOccupiedPort
|
||||
)
|
||||
$configName = if ($Mode -eq 'demo') { 'sense.demo.env' } else { 'sense.env' }
|
||||
Import-SenseEnvironment -Path (Join-Path $PackageRoot "config\$configName")
|
||||
|
||||
$hostName = Get-SenseEnvironmentValue -Name 'SENSE_HOST' -Default '127.0.0.1'
|
||||
$portText = Get-SenseEnvironmentValue -Name 'SENSE_PORT' -Default '18080'
|
||||
$port = 0
|
||||
if (-not [int]::TryParse($portText, [ref]$port) -or $port -lt 1 -or $port -gt 65535) {
|
||||
throw 'SENSE_PORT must be an integer between 1 and 65535.'
|
||||
}
|
||||
if ($hostName -notin @('127.0.0.1', '0.0.0.0', 'localhost')) {
|
||||
throw 'SENSE_HOST must be 127.0.0.1, localhost, or 0.0.0.0.'
|
||||
}
|
||||
if (-not $AllowOccupiedPort -and -not (Test-SenseListenPortAvailable -HostName $hostName -Port $port)) {
|
||||
throw "Sense HTTP port $hostName`:$port is already in use. Stop the existing process or change SENSE_PORT."
|
||||
}
|
||||
|
||||
$databaseVariable = if ($Mode -eq 'demo') { 'SENSE_DEMO_DATABASE_URL' } else { 'SENSE_DATABASE_URL' }
|
||||
$databaseURL = Get-SenseEnvironmentValue -Name $databaseVariable
|
||||
if ([string]::IsNullOrWhiteSpace($databaseURL)) { throw "$databaseVariable is required." }
|
||||
$database = Get-SenseDatabaseInfo -Connection $databaseURL
|
||||
if ([string]::IsNullOrWhiteSpace($database.Database)) { throw "$databaseVariable must name a database." }
|
||||
if ($Mode -eq 'demo' -and $database.Database -notmatch '(?i)demo|test') {
|
||||
throw 'Demo mode requires a database name containing demo or test; production data must never be reused as demo data.'
|
||||
}
|
||||
if (-not (Test-SenseTcpEndpoint -HostName $database.Host -Port $database.Port)) {
|
||||
throw "PostgreSQL is unreachable at $($database.Host):$($database.Port). Start PostgreSQL and verify the database connection."
|
||||
}
|
||||
|
||||
$jwtSecret = Get-SenseEnvironmentValue -Name 'SENSE_JWT_SECRET'
|
||||
if ($Mode -eq 'production' -and $jwtSecret.Trim().Length -lt 32) {
|
||||
throw 'SENSE_JWT_SECRET must contain at least 32 characters in production.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($jwtSecret)) { throw 'SENSE_JWT_SECRET is required.' }
|
||||
|
||||
$mediaMode = (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_MODE' -Default $(if ($Mode -eq 'demo') { 'disabled' } else { 'managed' })).ToLowerInvariant()
|
||||
if ($mediaMode -notin @('managed', 'external', 'disabled')) { throw 'SENSE_MEDIAMTX_MODE must be managed, external, or disabled.' }
|
||||
if ($Mode -eq 'production' -and $mediaMode -eq 'disabled') { throw 'MediaMTX cannot be disabled in production.' }
|
||||
$mediaAPI = Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_API' -Default 'http://127.0.0.1:9997'
|
||||
$apiUri = [Uri]$mediaAPI
|
||||
if ($apiUri.Scheme -ne 'http' -or $apiUri.Host -notin @('127.0.0.1', 'localhost', '::1')) {
|
||||
throw 'SENSE_MEDIAMTX_API must be an HTTP loopback URL.'
|
||||
}
|
||||
$mediaBinary = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_BINARY')
|
||||
$mediaConfig = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_CONFIG')
|
||||
if ($mediaMode -eq 'managed') {
|
||||
if (-not (Test-Path -LiteralPath $mediaBinary -PathType Leaf)) { throw 'Managed MediaMTX binary not found. Set SENSE_MEDIAMTX_BINARY to mediamtx.exe.' }
|
||||
if (-not (Test-Path -LiteralPath $mediaConfig -PathType Leaf)) { throw 'Managed MediaMTX configuration not found. Set SENSE_MEDIAMTX_CONFIG.' }
|
||||
}
|
||||
if ($mediaMode -eq 'external' -and -not (Test-SenseTcpEndpoint -HostName $apiUri.Host -Port $apiUri.Port)) {
|
||||
throw "External MediaMTX Control API is unreachable at $($apiUri.Host):$($apiUri.Port)."
|
||||
}
|
||||
|
||||
$webRoot = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_WEB_ROOT' -Default 'web')
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw 'Sense web assets are missing. Rebuild or replace the delivery package.' }
|
||||
[Environment]::SetEnvironmentVariable('SENSE_WEB_ROOT', $webRoot, 'Process')
|
||||
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_MODE', $mediaMode, 'Process')
|
||||
if ($mediaMode -eq 'managed') {
|
||||
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_BINARY', $mediaBinary, 'Process')
|
||||
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_CONFIG', $mediaConfig, 'Process')
|
||||
} else {
|
||||
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_BINARY', '', 'Process')
|
||||
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_CONFIG', '', 'Process')
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_API', $mediaAPI, 'Process')
|
||||
|
||||
$runtimeDir = Join-Path $PackageRoot 'data\runtime'
|
||||
$logDir = Join-Path $PackageRoot 'logs'
|
||||
New-Item -ItemType Directory -Force -Path $runtimeDir, $logDir | Out-Null
|
||||
$settingsPath = Join-Path $runtimeDir 'settings.yml'
|
||||
$applicationMode = if ($Mode -eq 'production') { 'prod' } else { 'test' }
|
||||
$lines = @(
|
||||
'settings:',
|
||||
' application:',
|
||||
" mode: $applicationMode",
|
||||
" host: $(ConvertTo-SenseYamlString $hostName)",
|
||||
' name: sense',
|
||||
" port: $port",
|
||||
' readtimeout: 10',
|
||||
' writertimeout: 20',
|
||||
' enabledp: false',
|
||||
' logger:',
|
||||
" path: $(ConvertTo-SenseYamlString $logDir)",
|
||||
" stdout: ''",
|
||||
' level: info',
|
||||
' enableddb: false',
|
||||
' jwt:',
|
||||
" secret: $(ConvertTo-SenseYamlString $jwtSecret)",
|
||||
' timeout: 3600',
|
||||
' database:',
|
||||
' driver: postgres',
|
||||
" source: $(ConvertTo-SenseYamlString $databaseURL)",
|
||||
' gen:',
|
||||
" dbname: $(ConvertTo-SenseYamlString $database.Database)",
|
||||
" frontpath: ''",
|
||||
' extend:',
|
||||
' demo:',
|
||||
' name: data',
|
||||
' cache:',
|
||||
" memory: ''",
|
||||
' queue:',
|
||||
' memory:',
|
||||
' poolSize: 100',
|
||||
' locker:',
|
||||
' redis:'
|
||||
)
|
||||
[IO.File]::WriteAllLines($settingsPath, $lines, (New-Object Text.UTF8Encoding($false)))
|
||||
return @{ PackageRoot = $PackageRoot; SettingsPath = $settingsPath; Host = $hostName; Port = $port; Database = $database; Mode = $Mode; MediaMode = $mediaMode }
|
||||
}
|
||||
|
||||
function Get-SensePostgresTool {
|
||||
param([Parameter(Mandatory = $true)][string]$Name)
|
||||
$configured = Get-SenseEnvironmentValue -Name 'SENSE_POSTGRES_BIN'
|
||||
if (-not [string]::IsNullOrWhiteSpace($configured)) {
|
||||
$candidate = Join-Path $configured "$Name.exe"
|
||||
if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate }
|
||||
}
|
||||
$command = Get-Command "$Name.exe" -ErrorAction SilentlyContinue
|
||||
if ($command) { return $command.Source }
|
||||
throw "$Name.exe was not found. Install PostgreSQL client tools or set SENSE_POSTGRES_BIN."
|
||||
}
|
||||
|
||||
function Invoke-SensePostgresTool {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Tool,
|
||||
[Parameter(Mandatory = $true)][hashtable]$Database,
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments
|
||||
)
|
||||
$oldPassword = [Environment]::GetEnvironmentVariable('PGPASSWORD', 'Process')
|
||||
try {
|
||||
if (-not [string]::IsNullOrEmpty($Database.Password)) {
|
||||
[Environment]::SetEnvironmentVariable('PGPASSWORD', $Database.Password, 'Process')
|
||||
}
|
||||
& $Tool @Arguments
|
||||
if ($LASTEXITCODE -ne 0) { throw "PostgreSQL tool failed with exit code $LASTEXITCODE." }
|
||||
} finally {
|
||||
[Environment]::SetEnvironmentVariable('PGPASSWORD', $oldPassword, 'Process')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\start-sense.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,31 @@
|
||||
param(
|
||||
[ValidateSet('production', 'demo')][string]$Mode = 'production',
|
||||
[switch]$SkipMigration
|
||||
)
|
||||
. (Join-Path $PSScriptRoot 'sense-common.ps1')
|
||||
|
||||
try {
|
||||
$root = Get-SensePackageRoot
|
||||
$state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode
|
||||
$sense = Join-Path $root 'sense.exe'
|
||||
if (-not (Test-Path -LiteralPath $sense -PathType Leaf)) { throw "Sense executable not found: $sense" }
|
||||
$autoMigrate = (Get-SenseEnvironmentValue -Name 'SENSE_AUTO_MIGRATE' -Default 'true').ToLowerInvariant()
|
||||
Push-Location $root
|
||||
try {
|
||||
if (-not $SkipMigration -and $autoMigrate -notin @('false', '0', 'no')) {
|
||||
Write-Host 'Applying pending Sense database migrations...'
|
||||
& $sense migrate -c $state.SettingsPath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Sense database migration failed. Review the error above and the PostgreSQL connection.' }
|
||||
}
|
||||
if ($Mode -eq 'demo') {
|
||||
Write-Warning 'Sense is running in isolated demo mode. Demo data must not be used as production data.'
|
||||
}
|
||||
Write-Host "Starting Sense at http://$($state.Host):$($state.Port)/ ..."
|
||||
Write-Host 'Press Ctrl+C in this window to stop Sense and its managed MediaMTX process.'
|
||||
& $sense server -c $state.SettingsPath
|
||||
exit $LASTEXITCODE
|
||||
} finally { Pop-Location }
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\stop-sense.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,24 @@
|
||||
param([ValidateSet('production', 'demo')][string]$Mode = 'production')
|
||||
. (Join-Path $PSScriptRoot 'sense-common.ps1')
|
||||
|
||||
try {
|
||||
$root = Get-SensePackageRoot
|
||||
$configName = if ($Mode -eq 'demo') { 'sense.demo.env' } else { 'sense.env' }
|
||||
$config = Join-Path $root "config\$configName"
|
||||
Import-SenseEnvironment -Path $config
|
||||
$port = [int](Get-SenseEnvironmentValue -Name 'SENSE_PORT' -Default '18080')
|
||||
$connection = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $connection) { Write-Host "Sense is not listening on port $port."; exit 0 }
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $($connection.OwningProcess)"
|
||||
$expected = [IO.Path]::GetFullPath((Join-Path $root 'sense.exe'))
|
||||
if (-not $process -or [IO.Path]::GetFullPath($process.ExecutablePath) -ne $expected) {
|
||||
throw "Port $port belongs to another process; it was not stopped."
|
||||
}
|
||||
& taskkill.exe /PID $process.ProcessId /T /F | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to stop the Sense process tree.' }
|
||||
Write-Host 'Sense and its managed child processes were stopped.'
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
Reference in New Issue
Block a user