diff --git a/Packaging/AppxManifest.xml b/Packaging/AppxManifest.xml new file mode 100644 index 0000000..fabfd6c --- /dev/null +++ b/Packaging/AppxManifest.xml @@ -0,0 +1,57 @@ + + + + + + + CursorLang + {PublisherDisplayName} + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packaging/Assets/Square150x150Logo.png b/Packaging/Assets/Square150x150Logo.png new file mode 100644 index 0000000..e736297 --- /dev/null +++ b/Packaging/Assets/Square150x150Logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5dd71d408e30fd30890253ddb0f18fffc4cdff00cfcedb369dc97018def7d1b1 +size 2371 diff --git a/Packaging/Assets/Square310x310Logo.png b/Packaging/Assets/Square310x310Logo.png new file mode 100644 index 0000000..0f177f4 --- /dev/null +++ b/Packaging/Assets/Square310x310Logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2090657c5f70a1bae62027662760b42935ed80ec72f490e79c23afd4aefe4bc3 +size 5208 diff --git a/Packaging/Assets/Square44x44Logo.png b/Packaging/Assets/Square44x44Logo.png new file mode 100644 index 0000000..ba0a816 --- /dev/null +++ b/Packaging/Assets/Square44x44Logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fdb6c1f43b74d2b026ba8190de9d6d0d5fdfd87a5b5c4bb66274b22a16719ab8 +size 681 diff --git a/Packaging/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/Packaging/Assets/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000..64df46c --- /dev/null +++ b/Packaging/Assets/Square44x44Logo.targetsize-24_altform-unplated.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd4d4d1acde872813d54db3fd675674e6b173a5c6ed28622021a05060676f028 +size 311 diff --git a/Packaging/Assets/Square44x44Logo.targetsize-32_altform-unplated.png b/Packaging/Assets/Square44x44Logo.targetsize-32_altform-unplated.png new file mode 100644 index 0000000..b846abb --- /dev/null +++ b/Packaging/Assets/Square44x44Logo.targetsize-32_altform-unplated.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7159b6aab969ddd2086c2d258bacefd51da6fc7206061bf918a339221c650427 +size 363 diff --git a/Packaging/Assets/Square44x44Logo.targetsize-48_altform-unplated.png b/Packaging/Assets/Square44x44Logo.targetsize-48_altform-unplated.png new file mode 100644 index 0000000..540ff22 --- /dev/null +++ b/Packaging/Assets/Square44x44Logo.targetsize-48_altform-unplated.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:696c09cde205529561c0a19e8567916b2efe63eba70cf3059508a1905dd9fe38 +size 490 diff --git a/Packaging/Assets/Square71x71Logo.png b/Packaging/Assets/Square71x71Logo.png new file mode 100644 index 0000000..5fad8c6 --- /dev/null +++ b/Packaging/Assets/Square71x71Logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4d6c224ecb06e2956520c61df781734147bc8dc58c66ac4c08e112d16c7f87c +size 1036 diff --git a/Packaging/Assets/StoreLogo.png b/Packaging/Assets/StoreLogo.png new file mode 100644 index 0000000..76b9772 --- /dev/null +++ b/Packaging/Assets/StoreLogo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:addf77784e8d3af26e0928b3655879d03186fe10fed519689e32ca1f9480fcb9 +size 792 diff --git a/Packaging/Assets/Wide310x150Logo.png b/Packaging/Assets/Wide310x150Logo.png new file mode 100644 index 0000000..416bb2a --- /dev/null +++ b/Packaging/Assets/Wide310x150Logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed46b7353fb7d8ef4adb85c8a9f6c97be06ac0cf08640ef1c48e830fffcc96dd +size 2729 diff --git a/Packaging/New-Assets.ps1 b/Packaging/New-Assets.ps1 new file mode 100644 index 0000000..3c89fa8 --- /dev/null +++ b/Packaging/New-Assets.ps1 @@ -0,0 +1,232 @@ +<# +.SYNOPSIS + Draws the application icons: the logos for MSIX and the .ico for the exe. + +.DESCRIPTION + The icons are drawn by code rather than kept as pictures so that the whole + set of sizes is rebuilt by a single command whenever another drawing is + wanted. + + The current drawing is a placeholder: a dark rounded square with a white + "Aя". Before publishing to the Store it is worth replacing with a real one: + rewriting New-Logo is enough, the rest of the script does not depend on the + drawing. + +.EXAMPLE + pwsh -File Packaging\New-Assets.ps1 +#> +[CmdletBinding()] +param( + # Where to put the MSIX logos + [string] $AssetsPath, + + # Where to put the .ico for the exe + [string] $IconPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# The defaults are worked out here rather than in the parameter declarations: +# in Windows PowerShell $PSScriptRoot is still empty inside param() +$root = $PSScriptRoot +if (-not $AssetsPath) { $AssetsPath = Join-Path $root 'Assets' } +if (-not $IconPath) { $IconPath = Join-Path $root '..\CursorLang\Resources\CursorLang.ico' } + +Add-Type -AssemblyName System.Drawing + +$Background = [System.Drawing.Color]::FromArgb(255, 0x20, 0x20, 0x20) +$Foreground = [System.Drawing.Color]::FromArgb(255, 0xFF, 0xFF, 0xFF) +$Glyph = 'Aя' + +function New-Logo { + param( + [int] $Width, + [int] $Height, + + # The plate under the glyph. The Windows taskbar draws its shortcut + # without one, so for those sizes only the white glyph remains + [bool] $WithPlate = $true + ) + + $bitmap = New-Object System.Drawing.Bitmap($Width, $Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + + try { + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $graphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit + $graphics.Clear([System.Drawing.Color]::Transparent) + + $side = [Math]::Min($Width, $Height) + + if ($WithPlate) { + # The corner radius is taken as a share of the side so that small + # sizes do not turn into a circle and large ones do not look like a + # plain square + $radius = [Math]::Max(2, [int]($side * 0.18)) + $plate = New-Object System.Drawing.Drawing2D.GraphicsPath + + $plate.AddArc(0, 0, $radius * 2, $radius * 2, 180, 90) + $plate.AddArc($Width - $radius * 2 - 1, 0, $radius * 2, $radius * 2, 270, 90) + $plate.AddArc($Width - $radius * 2 - 1, $Height - $radius * 2 - 1, $radius * 2, $radius * 2, 0, 90) + $plate.AddArc(0, $Height - $radius * 2 - 1, $radius * 2, $radius * 2, 90, 90) + $plate.CloseFigure() + + $brush = New-Object System.Drawing.SolidBrush($Background) + try { $graphics.FillPath($brush, $plate) } finally { $brush.Dispose(); $plate.Dispose() } + } + + # Typographic metrics leave out the side bearings MeasureString reports by + # default. Measured with those bearings the glyph looks wider than it is, + # the fitted type comes out too small, and at 16 px the strokes wash out + [System.Drawing.StringFormat] $format = [System.Drawing.StringFormat]::GenericTypographic.Clone() + + try { + # The type size is found by trying: with different tile sizes the same + # share of the side gives text that is either cramped or too small + $target = $side * 0.80 + $ceiling = $side * 0.86 + $fontSize = $side * 0.9 + $font = $null + $measured = $null + + for ($attempt = 0; $attempt -lt 20; $attempt++) { + if ($font) { $font.Dispose() } + $font = New-Object System.Drawing.Font('Segoe UI', $fontSize, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel) + $measured = $graphics.MeasureString($Glyph, $font, ([System.Drawing.PointF]::new(0, 0)), $format) + + if ($measured.Width -le $target -and $measured.Height -le $ceiling) { break } + + # Both dimensions are pulled in at once, otherwise a tall glyph + # keeps overflowing while the width already fits + $scale = [Math]::Min($target / $measured.Width, $ceiling / $measured.Height) + $fontSize = $fontSize * $scale * 0.98 + } + + try { + # AntiAliasGridFit snaps the stems onto the pixel grid, so the + # glyph is placed at a whole pixel: a fractional origin spreads + # every stem across two pixels, which is what blurred the small + # sizes when the text was centred inside a RectangleF + $x = [Math]::Round(($Width - $measured.Width) / 2.0) + $y = [Math]::Round(($Height - $measured.Height) / 2.0) + + $brush = New-Object System.Drawing.SolidBrush($Foreground) + $origin = New-Object System.Drawing.PointF($x, $y) + + try { $graphics.DrawString($Glyph, $font, $brush, $origin, $format) } + finally { $brush.Dispose() } + } + finally { + $font.Dispose() + } + } + finally { + $format.Dispose() + } + } + finally { + $graphics.Dispose() + } + + return $bitmap +} + +function Save-Logo { + param( + [string] $Path, + [int] $Width, + [int] $Height, + [bool] $WithPlate = $true + ) + + $bitmap = New-Logo -Width $Width -Height $Height -WithPlate $WithPlate + try { $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) } finally { $bitmap.Dispose() } + Write-Host " $([System.IO.Path]::GetFileName($Path))" +} + +function Save-Icon { + param( + [string] $Path, + [int[]] $Sizes + ) + + # The .ico is put together by hand: System.Drawing can only save a single + # size to that format, and the icon of the exe is needed in several at once + $images = @() + + foreach ($size in $Sizes) { + $bitmap = New-Logo -Width $size -Height $size + try { + $stream = New-Object System.IO.MemoryStream + $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png) + $images += , @{ Size = $size; Bytes = $stream.ToArray() } + $stream.Dispose() + } + finally { + $bitmap.Dispose() + } + } + + $output = New-Object System.IO.MemoryStream + $writer = New-Object System.IO.BinaryWriter($output) + + try { + $writer.Write([uint16]0) # reserved + $writer.Write([uint16]1) # type: icon + $writer.Write([uint16]$images.Count) + + # The directory of entries comes before the pictures themselves, so the + # offsets are worked out beforehand: the header plus 16 bytes per entry + $offset = 6 + 16 * $images.Count + + foreach ($image in $images) { + # 256 does not fit in a byte and is written as zero — the format + # means it that way + $dimension = if ($image.Size -ge 256) { 0 } else { $image.Size } + + $writer.Write([byte]$dimension) # width + $writer.Write([byte]$dimension) # height + $writer.Write([byte]0) # palette colours: no palette + $writer.Write([byte]0) # reserved + $writer.Write([uint16]1) # planes + $writer.Write([uint16]32) # bits per pixel + $writer.Write([uint32]$image.Bytes.Length) + $writer.Write([uint32]$offset) + + $offset += $image.Bytes.Length + } + + foreach ($image in $images) { + $writer.Write($image.Bytes) + } + + $writer.Flush() + [System.IO.File]::WriteAllBytes($Path, $output.ToArray()) + } + finally { + $writer.Dispose() + $output.Dispose() + } + + Write-Host " $([System.IO.Path]::GetFileName($Path))" +} + +New-Item -ItemType Directory -Path $AssetsPath -Force | Out-Null +New-Item -ItemType Directory -Path (Split-Path -Parent $IconPath) -Force | Out-Null + +Write-Host 'MSIX logos:' +Save-Logo -Path (Join-Path $AssetsPath 'StoreLogo.png') -Width 50 -Height 50 +Save-Logo -Path (Join-Path $AssetsPath 'Square44x44Logo.png') -Width 44 -Height 44 +Save-Logo -Path (Join-Path $AssetsPath 'Square71x71Logo.png') -Width 71 -Height 71 +Save-Logo -Path (Join-Path $AssetsPath 'Square150x150Logo.png') -Width 150 -Height 150 +Save-Logo -Path (Join-Path $AssetsPath 'Square310x310Logo.png') -Width 310 -Height 310 +Save-Logo -Path (Join-Path $AssetsPath 'Wide310x150Logo.png') -Width 310 -Height 150 + +# The taskbar and the application list take the shortcut without a plate +Save-Logo -Path (Join-Path $AssetsPath 'Square44x44Logo.targetsize-24_altform-unplated.png') -Width 24 -Height 24 -WithPlate $false +Save-Logo -Path (Join-Path $AssetsPath 'Square44x44Logo.targetsize-32_altform-unplated.png') -Width 32 -Height 32 -WithPlate $false +Save-Logo -Path (Join-Path $AssetsPath 'Square44x44Logo.targetsize-48_altform-unplated.png') -Width 48 -Height 48 -WithPlate $false + +Write-Host 'The exe icon:' +Save-Icon -Path ([System.IO.Path]::GetFullPath($IconPath)) -Sizes @(16, 24, 32, 48, 64, 128, 256) diff --git a/Packaging/Tools/SdkTools.csproj b/Packaging/Tools/SdkTools.csproj new file mode 100644 index 0000000..cf72376 --- /dev/null +++ b/Packaging/Tools/SdkTools.csproj @@ -0,0 +1,24 @@ + + + + + net10.0 + false + + + + + + all + true + + + + diff --git a/Packaging/build-msix.ps1 b/Packaging/build-msix.ps1 new file mode 100644 index 0000000..e2f4b22 --- /dev/null +++ b/Packaging/build-msix.ps1 @@ -0,0 +1,203 @@ +<# +.SYNOPSIS + Builds the CursorLang MSIX package for the Microsoft Store. + +.DESCRIPTION + Neither Visual Studio nor the Windows SDK is needed: makeappx arrives as a + NuGet package (Tools\SdkTools.csproj) and the application is built by the + plain .NET SDK. + + The package is handed to Partner Center as it comes out of here — the Store + is where it gets everything else done to it. + + The application is published with its own copy of .NET: Windows does not + carry one, and MSIX cannot install the runtime as a package dependency. + +.PARAMETER IdentityName + The identity of the package — from Partner Center, the "Product identity" + section. It is handed out there together with the reserved application name; + the default is only good enough for a check on your own machine. + +.EXAMPLE + # A check on your own machine: your architecture alone + pwsh -File Packaging\build-msix.ps1 -Architectures x64 + +.EXAMPLE + # A build for the Store — the identity comes from Partner Center + pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 ` + -IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..." +#> +[CmdletBinding()] +param( + # Four numbers, the last one has to be 0: that is what the Store requires + [string] $Version = '1.0.0.0', + + [string] $IdentityName = 'CursorLang', + [string] $Publisher = 'CN=Aleksandr Neychev', + [string] $PublisherDisplayName = 'Aleksandr Neychev', + + [ValidateSet('x64', 'arm64')] + [string[]] $Architectures = @('x64', 'arm64'), + + [string] $OutputPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$root = $PSScriptRoot +$repository = Split-Path -Parent $root +$project = Join-Path $repository 'CursorLang\CursorLang.csproj' +$assets = Join-Path $root 'Assets' +$manifestTemplate = Join-Path $root 'AppxManifest.xml' +$toolsProject = Join-Path $root 'Tools\SdkTools.csproj' + +if (-not $OutputPath) { $OutputPath = Join-Path $repository 'artifacts' } +$layoutRoot = Join-Path $OutputPath 'layout' +$packagesPath = Join-Path $OutputPath 'packages' + +if ($Version -notmatch '^\d+\.\d+\.\d+\.0$') { + throw "The version '$Version' does not fit: the Store takes four numbers ending in zero, for example 1.0.0.0." +} + +if (-not (Test-Path $assets)) { + throw "No logos found in '$assets'. Run Packaging\New-Assets.ps1 first." +} + +function Invoke-Tool { + <# + .SYNOPSIS + Runs a program and fails the build if it returned an error. + .DESCRIPTION + A wrapper of our own is needed because PowerShell does not treat the + failure of an external program as an error and quietly moves on. + #> + param( + [string] $Path, + [string[]] $Arguments + ) + + & $Path @Arguments + if ($LASTEXITCODE -ne 0) { + throw "'$([System.IO.Path]::GetFileName($Path))' exited with code $LASTEXITCODE." + } +} + +function Get-SdkToolsPath { + <# + .SYNOPSIS + Returns the folder of the restored Windows SDK Build Tools package. + .DESCRIPTION + The path is asked of MSBuild rather than searched for in the package + cache: the project pins one version of the package, and MSBuild + names exactly it. A search would also find the versions left over + from other builds. + #> + $path = (& dotnet msbuild $toolsProject -getProperty:PkgMicrosoft_Windows_SDK_BuildTools -nologo) | + Where-Object { $_ -and $_.Trim() } | + Select-Object -Last 1 + + if ($LASTEXITCODE -ne 0 -or -not $path -or -not (Test-Path $path.Trim())) { + throw 'The Windows SDK Build Tools package folder could not be found. Did the restore go through?' + } + + return $path.Trim() +} + +function Get-SdkTool { + <# + .SYNOPSIS + Returns the path of a program from the Windows SDK Build Tools package. + #> + param( + [string] $Name, + [string] $PackagePath + ) + + # The bitness of the program has nothing to do with the bitness of the + # package being built: we take the one that runs on this machine + $host64 = if ([Environment]::Is64BitOperatingSystem) { 'x64' } else { 'x86' } + + $tool = Get-ChildItem (Join-Path $PackagePath 'bin') -Recurse -Filter $Name -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "\\$host64\\" } | + Select-Object -First 1 + + if (-not $tool) { + throw "'$Name' was not found in the Windows SDK Build Tools package." + } + + return $tool.FullName +} + +Write-Host 'Fetching the Windows SDK programs...' -ForegroundColor Cyan +Invoke-Tool -Path 'dotnet' -Arguments @('restore', $toolsProject, '--nologo') + +$sdkTools = Get-SdkToolsPath +$makeappx = Get-SdkTool -Name 'makeappx.exe' -PackagePath $sdkTools + +Remove-Item $layoutRoot -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Path $packagesPath -Force | Out-Null + +$built = @() + +foreach ($architecture in $Architectures) { + Write-Host "Building $architecture..." -ForegroundColor Cyan + + $layout = Join-Path $layoutRoot $architecture + + Invoke-Tool -Path 'dotnet' -Arguments @( + 'publish', $project, + '--configuration', 'Release', + '--runtime', "win-$architecture", + '--self-contained', 'true', + "-p:Version=$($Version.Substring(0, $Version.LastIndexOf('.')))", + '--output', $layout, + '--nologo' + ) + + # Debug symbols have no place in the package: they take up room, the user + # has no use for them, and for crash reports the Store takes them separately + Get-ChildItem $layout -Recurse -Filter '*.pdb' | Remove-Item -Force + + Copy-Item $assets -Destination (Join-Path $layout 'Assets') -Recurse -Force + + $manifest = (Get-Content $manifestTemplate -Raw). + Replace('{IdentityName}', $IdentityName). + Replace('{Publisher}', $Publisher). + Replace('{PublisherDisplayName}', $PublisherDisplayName). + Replace('{Version}', $Version). + Replace('{Architecture}', $architecture) + + Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8 + + $package = Join-Path $packagesPath "CursorLang-$Version-$architecture.msix" + Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package) + + $built += $package +} + +$result = $built[0] + +if ($built.Count -gt 1) { + Write-Host 'Bringing the architectures into one package...' -ForegroundColor Cyan + + # makeappx bundle takes everything from a folder, so the separate packages + # are gathered into one of their own first — otherwise the results of + # earlier builds would end up in the bundle + $bundleInput = Join-Path $OutputPath 'bundle' + Remove-Item $bundleInput -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $bundleInput -Force | Out-Null + $built | ForEach-Object { Copy-Item $_ -Destination $bundleInput } + + $result = Join-Path $packagesPath "CursorLang-$Version.msixbundle" + Invoke-Tool -Path $makeappx -Arguments @('bundle', '/o', '/d', $bundleInput, '/p', $result, '/bv', $Version) + + Remove-Item $bundleInput -Recurse -Force +} + +Write-Host '' +Write-Host 'Done.' -ForegroundColor Green +Write-Host " $result" + +Write-Host '' +Write-Host ' This file is uploaded to Partner Center as it is.'