Initial commit: RGB LED Strip Controller v0.3.0

- WPF + WinRT BLE controller for ELK-BLEDOM compatible LED strips

- Multi-device control, effects, grayscale and temperature modes

- Velopack updates via Gitea Releases, Gitea Actions CI
This commit is contained in:
fristy committed 2026-09-04 23:31:11 +03:00
commit c9e643d617
40 files changed
+5411

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
# Scripts
## Structure
- `release/`
- `package-velopack.ps1`: Gitea version guard + build + package release assets + optional Authenticode signing + installer icon
- `upload-feed.ps1`: `vpk upload gitea` ile Gitea Releases'e hedefli upload
- `version/`
- `bump-version.ps1`: bump `major.minor.patch` version fields in the app project
## Prerequisites
- .NET 8 SDK (`actions/setup-dotnet` CI'da kurar, localde PATH'te `dotnet` olmali)
- Repo-local `vpk` tool: `dotnet tool restore` (manifest: `.config/dotnet-tools.json`, vpk 0.0.1298)
## Common Commands
- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/version/bump-version.ps1 -Patch`
- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/version/bump-version.ps1 -Minor`
- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/version/bump-version.ps1 -Major`
- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/release/package-velopack.ps1 -SkipSigning`
- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/release/package-velopack.ps1 -PfxPath <cert.pfx>`
- `$env:VPK_TOKEN = "<gitea-token>"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/release/upload-feed.ps1`
## Signing
- Authenticode signing varsayilan olarak **pasif**: `-PfxPath` verilmezse uyariyla atlanir.
- Acmak icin `-PfxPath <cert.pfx>` ver; PFX sifresi interaktif sorulur, script icinde saklanmaz.
- Timestamp (TSA) kullanilmiyor; gerekirse global bir TSA servisiyle `-SignToolPath` akisina eklenebilir.
- v0.3.0 stratejisi: GPG imzali git tag + Gitea release hash'leri yeterli.
## Packaging Guard
- `scripts/release/package-velopack.ps1` Gitea Releases API uzerinden cloud surumu kontrol eder.
- Gitea'da ayni surum (tag `v<version>`) varsa paketleme durdurulur.
- Gitea'daki surum daha yuksekse paketleme durdurulur.
- API'ye erisilemiyorsa fail-fast durur; offline paketleme icin `-SkipCloudCheck` kullanilabilir.
- Varsayilan installer ikonu `src/RgbLedStripController/Assets/AppIcon.ico` dosyasindan `vpk pack --icon` ile verilir.
## Upload Guard
- `scripts/release/upload-feed.ps1` yerel release manifest'ini okur, `full/delta .nupkg` + `Setup.exe` / `Portable.zip` varligini preflight olarak dogrular.
- `-Token` verilmezse `VPK_TOKEN` env kullanilir; ikisi de yoksa durur.
- Hedef tag (`v<version>`) Gitea'da varsa upload durdurulur (`-Merge` ile mevcut release'e birlestirilebilir).
- `-ExpectedTag` verilirse turetilen tag ile karsilastirilir (CI'da tag/csproj tutarliligi icin).
- `-WhatIf` ile dry-run yapilabilir.
- Basarili oldugunda Gitea release linkini ekrana yazar.
## CI (Gitea Actions)
- Workflow: `.gitea/workflows/ci.yml` — `build`, `test`, `release` job'lari.
- `windows` etiketli runner gerekir (WPF sadece Windows'ta derlenir).
- NuGet cache adimi `continue-on-error: true` — cache servisi yoksa build kirilmaz.
- `release` job'u sadece `v*` tag'lerinde calisir, `-SkipSigning` ile paketler.
- Gerekli repo secret: `VPK_TOKEN` (Gitea API token, release yazma yetkili).
+271
View File
@@ -0,0 +1,271 @@
[CmdletBinding()]
param(
[string]$Project = "src/RgbLedStripController/RgbLedStripController.csproj",
[string]$Configuration = "Release",
[string]$Runtime = "win-x64",
[string]$Channel = "win-x64-stable",
[string]$PackId = "Fristy.RgbLedStripController",
[string]$PackTitle = "RGB Led Strip Controller",
[string]$PackAuthors = "fristy",
[string]$GiteaRepoUrl = "https://git.fristy.net/fristy/rgb-led-strip-controller",
[string]$InstallerIcon = "src/RgbLedStripController/Assets/AppIcon.ico",
[string]$PfxPath = "",
[string]$SignToolPath = "signtool.exe",
[int]$SignParallel = 1,
[switch]$SkipSigning,
[switch]$SkipCloudCheck
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\.."))
$projectPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Project))
$nugetConfigPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot "NuGet.Config"))
if (-not (Test-Path -LiteralPath $projectPath)) {
throw "Project file not found: $projectPath"
}
[xml]$projectXml = Get-Content -Raw -LiteralPath $projectPath
$versionNode = $null
foreach ($propertyGroup in @($projectXml.Project.PropertyGroup)) {
if ($propertyGroup.PSObject.Properties.Name -contains "Version" -and -not [string]::IsNullOrWhiteSpace($propertyGroup.Version)) {
$versionNode = $propertyGroup.Version
break
}
}
$version = [string]$versionNode
if ([string]::IsNullOrWhiteSpace($version)) {
throw "No <Version> found in project file: $projectPath"
}
function Assert-WorkspacePath {
param([string]$Path)
$fullPath = [System.IO.Path]::GetFullPath($Path)
if (-not $fullPath.StartsWith($repoRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to use path outside workspace: $fullPath"
}
return $fullPath
}
function Resolve-AbsolutePath {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) {
throw "Path value cannot be empty."
}
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
}
return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
}
function Get-GiteaReleaseTags {
param([string]$RepoUrl)
$uri = [Uri]$RepoUrl
$segments = $uri.AbsolutePath.Trim("/").Split("/") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
if ($segments.Count -lt 2) {
throw "Could not parse owner/repo from GiteaRepoUrl: $RepoUrl"
}
$owner = $segments[-2]
$repo = $segments[-1]
$apiUrl = "{0}://{1}/api/v1/repos/{2}/{3}/releases?draft=false&limit=50" -f $uri.Scheme, $uri.Authority, $owner, $repo
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$releases = Invoke-RestMethod -Uri $apiUrl -Method Get -ErrorAction Stop
}
catch {
throw "Gitea releases could not be read: $apiUrl`n$($_.Exception.Message)"
}
return @($releases | ForEach-Object { $_.tag_name } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
function Get-MaxVersionFromTags {
param([string[]]$Tags)
$versions = @()
foreach ($tag in $Tags) {
$clean = $tag.TrimStart("v", "V")
$parsed = $null
if ([Version]::TryParse($clean, [ref]$parsed)) {
$versions += $parsed
}
}
if ($versions.Count -eq 0) {
return $null
}
return ($versions | Sort-Object -Descending | Select-Object -First 1)
}
function Read-SecretAsPlainText {
param([string]$Prompt)
$secureString = Read-Host -Prompt $Prompt -AsSecureString
if ($secureString.Length -eq 0) {
throw "Empty password is not allowed."
}
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureString)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
}
finally {
if ($bstr -ne [IntPtr]::Zero) {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
}
}
function Resolve-SignToolExecutable {
param([string]$RequestedPath)
if (Test-Path -LiteralPath $RequestedPath) {
return [System.IO.Path]::GetFullPath($RequestedPath)
}
$command = Get-Command -Name $RequestedPath -ErrorAction SilentlyContinue
if ($null -ne $command) {
return $command.Source
}
throw "signtool.exe could not be resolved. Pass -SignToolPath with an absolute path if needed."
}
$publishDir = Assert-WorkspacePath (Join-Path $repoRoot "artifacts\publish\$Runtime\$version")
$releaseDir = Assert-WorkspacePath (Join-Path $repoRoot "artifacts\velopack\$Channel")
$mainExe = "RgbLedStripController.exe"
$dotnet = "dotnet"
$signingEnabled = (-not $SkipSigning.IsPresent) -and (-not [string]::IsNullOrWhiteSpace($PfxPath))
if ((-not $SkipSigning.IsPresent) -and [string]::IsNullOrWhiteSpace($PfxPath)) {
Write-Warning "PfxPath not supplied; Authenticode signing will be skipped. Pass -PfxPath to enable it."
}
$signToolExecutable = $null
$signToolDirectory = $null
$plainPassword = $null
$resolvedPfxPath = $null
$resolvedInstallerIconPath = Resolve-AbsolutePath -Path $InstallerIcon
$signParams = $null
if (-not (Test-Path -LiteralPath $resolvedInstallerIconPath)) {
throw "Installer icon file not found: $resolvedInstallerIconPath"
}
$env:DOTNET_CLI_HOME = Join-Path $repoRoot ".dotnet"
$env:APPDATA = Join-Path $repoRoot ".appdata"
$env:NUGET_PACKAGES = Join-Path $repoRoot ".nuget\packages"
$env:NUGET_HTTP_CACHE_PATH = Join-Path $repoRoot ".nuget\http-cache"
$localVersion = [Version]$version
$cloudVersion = $null
if (-not $SkipCloudCheck.IsPresent) {
$cloudVersion = Get-MaxVersionFromTags -Tags (Get-GiteaReleaseTags -RepoUrl $GiteaRepoUrl)
}
if ($null -ne $cloudVersion) {
if ($localVersion -lt $cloudVersion) {
throw "Local version $version is older than Gitea version $cloudVersion. Bump the project version before packaging."
}
if ($localVersion -eq $cloudVersion) {
throw "Gitea already contains version $version. Packaging with the same version is blocked."
}
}
if ($signingEnabled) {
$resolvedPfxPath = Resolve-AbsolutePath -Path $PfxPath
if (-not (Test-Path -LiteralPath $resolvedPfxPath)) {
throw "PFX file not found: $resolvedPfxPath"
}
if ($SignParallel -lt 1) {
throw "SignParallel must be at least 1."
}
$signToolExecutable = Resolve-SignToolExecutable -RequestedPath $SignToolPath
$signToolDirectory = Split-Path -Parent $signToolExecutable
if (-not [string]::IsNullOrWhiteSpace($signToolDirectory)) {
$env:PATH = $signToolDirectory + [System.IO.Path]::PathSeparator + $env:PATH
}
$plainPassword = Read-SecretAsPlainText -Prompt "PFX password"
$signParams = "/fd SHA256 /f `"$resolvedPfxPath`" /p `"$plainPassword`""
}
if (Test-Path -LiteralPath $publishDir) {
Remove-Item -LiteralPath $publishDir -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $publishDir | Out-Null
New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null
$publishArgs = @(
"publish",
$projectPath,
"-c", $Configuration,
"-r", $Runtime,
"--self-contained", "true",
"-p:PublishSingleFile=true",
"-o", $publishDir,
"--configfile", $nugetConfigPath
)
& $dotnet @publishArgs
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE"
}
$packArgs = @(
"tool", "run", "vpk", "--",
"pack",
"--packId", $PackId,
"--packVersion", $version,
"--packDir", $publishDir,
"--mainExe", $mainExe,
"--packTitle", $PackTitle,
"--packAuthors", $PackAuthors,
"--runtime", $Runtime,
"--channel", $Channel,
"--icon", $resolvedInstallerIconPath,
"--outputDir", $releaseDir
)
if ($signingEnabled) {
$packArgs += @(
"--signParams", $signParams,
"--signParallel", $SignParallel.ToString()
)
}
& $dotnet @packArgs
if ($LASTEXITCODE -ne 0) {
throw "vpk pack failed with exit code $LASTEXITCODE"
}
Write-Host ""
Write-Host "Velopack package build complete." -ForegroundColor Green
Write-Host "Version: $version"
Write-Host "Gitea: $(if ($null -ne $cloudVersion) { $cloudVersion.ToString() } else { 'none' })"
Write-Host "Signing: $(if ($signingEnabled) { 'enabled' } else { 'skipped' })"
if ($signingEnabled -and -not [string]::IsNullOrWhiteSpace($signToolExecutable)) {
Write-Host "SignTool: $signToolExecutable"
}
Write-Host "Icon: $resolvedInstallerIconPath"
Write-Host "PublishDir: $publishDir"
Write-Host "ReleaseDir: $releaseDir"
Write-Host "Gitea repo: $GiteaRepoUrl"
Write-Host "Tag: v$version"
Write-Host ""
Write-Host "Upload with: scripts/release/upload-feed.ps1 (vpk upload gitea)." -ForegroundColor Yellow
+141
View File
@@ -0,0 +1,141 @@
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string]$Channel = "win-x64-stable",
[string]$GiteaRepoUrl = "https://git.fristy.net/fristy/rgb-led-strip-controller",
[string]$Tag = "",
[string]$Token = $env:VPK_TOKEN,
[string]$ExpectedTag = "",
[switch]$Prerelease,
[switch]$Merge
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\.."))
if ([string]::IsNullOrWhiteSpace($Token)) {
throw "Gitea token is required. Pass -Token or set the VPK_TOKEN environment variable."
}
function Assert-WorkspacePath {
param([string]$Path)
$fullPath = [System.IO.Path]::GetFullPath($Path)
if (-not $fullPath.StartsWith($repoRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to use path outside workspace: $fullPath"
}
return $fullPath
}
function Get-GiteaReleaseTagNames {
param(
[string]$RepoUrl,
[string]$AccessToken
)
$uri = [Uri]$RepoUrl
$segments = $uri.AbsolutePath.Trim("/").Split("/") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
if ($segments.Count -lt 2) {
throw "Could not parse owner/repo from GiteaRepoUrl: $RepoUrl"
}
$owner = $segments[-2]
$repo = $segments[-1]
$apiUrl = "{0}://{1}/api/v1/repos/{2}/{3}/releases?draft=false&limit=50" -f $uri.Scheme, $uri.Authority, $owner, $repo
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$headers = @{ Authorization = "token $AccessToken" }
$releases = Invoke-RestMethod -Uri $apiUrl -Method Get -Headers $headers -ErrorAction Stop
}
catch {
throw "Gitea releases could not be read: $apiUrl`n$($_.Exception.Message)"
}
return @($releases | ForEach-Object { $_.tag_name } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
$releaseDir = Assert-WorkspacePath (Join-Path $repoRoot "artifacts\velopack\$Channel")
$manifestPath = Join-Path $releaseDir "releases.$Channel.json"
if (-not (Test-Path -LiteralPath $releaseDir)) {
throw "Release directory not found: $releaseDir"
}
if (-not (Test-Path -LiteralPath $manifestPath)) {
throw "Release manifest not found: $manifestPath"
}
$localManifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json
if ($null -eq $localManifest.Assets -or $localManifest.Assets.Count -eq 0) {
throw "Release manifest does not contain any assets: $manifestPath"
}
$packageId = $localManifest.Assets[0].PackageId
$localVersions = @(
$localManifest.Assets |
Where-Object { $_.PackageId -eq $packageId -and -not [string]::IsNullOrWhiteSpace($_.Version) } |
ForEach-Object { [Version]$_.Version }
)
if ($localVersions.Count -eq 0) {
throw "Could not determine local package version from: $manifestPath"
}
$localVersion = ($localVersions | Sort-Object -Descending | Select-Object -First 1)
$tag = if ([string]::IsNullOrWhiteSpace($Tag)) { "v$($localVersion.ToString())" } else { $Tag }
if (-not [string]::IsNullOrWhiteSpace($ExpectedTag) -and -not [string]::Equals($tag, $ExpectedTag, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Derived tag $tag does not match expected tag $ExpectedTag. Check the pushed tag against the project version."
}
$setupFiles = @(Get-ChildItem -LiteralPath $releaseDir -Filter "*-Setup.exe" -File)
$portableFiles = @(Get-ChildItem -LiteralPath $releaseDir -Filter "*-Portable.zip" -File)
if ($setupFiles.Count -eq 0) {
throw "No Setup.exe file found in release directory: $releaseDir"
}
if ($portableFiles.Count -eq 0) {
throw "No Portable.zip file found in release directory: $releaseDir"
}
$existingTags = Get-GiteaReleaseTagNames -RepoUrl $GiteaRepoUrl -AccessToken $Token
if ($existingTags -contains $tag -and -not $Merge.IsPresent) {
throw "Gitea already contains tag $tag. Upload is blocked (pass -Merge to merge into the existing release)."
}
$releaseUrl = $GiteaRepoUrl.TrimEnd("/") + "/releases/tag/" + $tag
$vpkArgs = @(
"tool", "run", "vpk", "--",
"upload", "gitea",
"--outputDir", $releaseDir,
"--channel", $Channel,
"--repoUrl", $GiteaRepoUrl,
"--token", $Token,
"--tag", $tag,
"--publish"
)
if ($Prerelease.IsPresent) {
$vpkArgs += "--pre"
}
if ($Merge.IsPresent) {
$vpkArgs += "--merge"
}
if ($PSCmdlet.ShouldProcess("$GiteaRepoUrl@$tag", "Upload Velopack release")) {
& "dotnet" @vpkArgs
if ($LASTEXITCODE -ne 0) {
throw "vpk upload failed with exit code $LASTEXITCODE"
}
}
Write-Host ""
Write-Host "Velopack feed upload complete." -ForegroundColor Green
Write-Host "Version: $($localVersion.ToString())"
Write-Host "Tag: $tag"
Write-Host "Channel: $Channel"
Write-Host "ReleaseDir: $releaseDir"
Write-Host "Release: $releaseUrl"
Write-Host "Prerelease: $(if ($Prerelease.IsPresent) { 'yes' } else { 'no' })"
+67
View File
@@ -0,0 +1,67 @@
[CmdletBinding(DefaultParameterSetName = "Patch")]
param(
[Parameter(Mandatory = $true, ParameterSetName = "Major")]
[switch]$Major,
[Parameter(Mandatory = $true, ParameterSetName = "Minor")]
[switch]$Minor,
[Parameter(Mandatory = $true, ParameterSetName = "Patch")]
[switch]$Patch,
[string]$Project = "src/RgbLedStripController/RgbLedStripController.csproj"
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\.."))
$projectPath = if ([System.IO.Path]::IsPathRooted($Project)) {
$Project
} else {
Join-Path $repoRoot $Project
}
$projectPath = [System.IO.Path]::GetFullPath($projectPath)
if (-not (Test-Path -LiteralPath $projectPath)) {
throw "Project file not found: $projectPath"
}
$content = Get-Content -Raw -LiteralPath $projectPath
$versionMatch = [System.Text.RegularExpressions.Regex]::Match($content, "<Version>(?<value>\d+\.\d+\.\d+)</Version>")
if (-not $versionMatch.Success) {
throw "No semantic <Version> value found in project file: $projectPath"
}
$currentVersion = $versionMatch.Groups["value"].Value
$parts = $currentVersion.Split(".") | ForEach-Object { [int]$_ }
$newVersion = switch ($PSCmdlet.ParameterSetName) {
"Major" { "{0}.0.0" -f ($parts[0] + 1) }
"Minor" { "{0}.{1}.0" -f $parts[0], ($parts[1] + 1) }
default { "{0}.{1}.{2}" -f $parts[0], $parts[1], ($parts[2] + 1) }
}
$newAssemblyVersion = "$newVersion.0"
function Replace-FirstMatch {
param(
[string]$Text,
[string]$Pattern,
[string]$Replacement
)
$regex = [System.Text.RegularExpressions.Regex]::new($Pattern)
return $regex.Replace($Text, $Replacement, 1)
}
$content = Replace-FirstMatch $content "<Version>[^<]+</Version>" "<Version>$newVersion</Version>"
$content = Replace-FirstMatch $content "<AssemblyVersion>[^<]+</AssemblyVersion>" "<AssemblyVersion>$newAssemblyVersion</AssemblyVersion>"
$content = Replace-FirstMatch $content "<FileVersion>[^<]+</FileVersion>" "<FileVersion>$newAssemblyVersion</FileVersion>"
$content = Replace-FirstMatch $content "<InformationalVersion>[^<]+</InformationalVersion>" "<InformationalVersion>$newVersion</InformationalVersion>"
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($projectPath, $content, $utf8NoBom)
Write-Host ("Version updated: {0} -> {1}" -f $currentVersion, $newVersion) -ForegroundColor Green
Write-Host ("Project file: {0}" -f $projectPath)