diff --git a/.github/actions/sign/action.yml b/.github/actions/sign/action.yml index 39f1757..62d6231 100644 --- a/.github/actions/sign/action.yml +++ b/.github/actions/sign/action.yml @@ -1,5 +1,5 @@ name: Sign Windows artifacts -description: Sign and verify binaries or MSIs using psign and Azure Trusted Signing. +description: Sign and verify Windows binaries and packages using psign and Azure Trusted Signing. inputs: files: description: Newline-separated file paths or patterns to sign. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8a5e01..67cfa37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -300,3 +300,153 @@ jobs: name: jiffshot-${{ matrix.rid }}-msi path: msi/*.msi if-no-files-found: error + + msix: + name: ${{ matrix.rid }} MSIX + environment: + name: ${{ inputs.signing_environment || '' }} + permissions: + contents: read + id-token: write + needs: build + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + architecture: x64 + test: true + - rid: win-arm64 + architecture: arm64 + test: false + steps: + - uses: actions/checkout@v7 + + - uses: actions/download-artifact@v8 + with: + name: jiffshot-${{ matrix.rid }} + path: bin + + - uses: actions/download-artifact@v8 + with: + name: jiffshot-win-x64 + path: logo-bin + + - name: Build the MSIX + shell: pwsh + env: + EXPECTED_VERSION: ${{ inputs.version }} + PACKAGE_ARCHITECTURE: ${{ matrix.architecture }} + run: | + $ErrorActionPreference = 'Stop' + $cargoToml = Get-Content -LiteralPath Cargo.toml -Raw + if ($cargoToml -notmatch '(?ms)^\[workspace\.package\].*?^\s*version\s*=\s*"(?[0-9]+\.[0-9]+\.[0-9]+)"') { + throw 'Could not read [workspace.package] version from Cargo.toml' + } + $workspaceVersion = $Matches['v'] + $expectedVersion = if ([string]::IsNullOrWhiteSpace($env:EXPECTED_VERSION)) { + $workspaceVersion + } elseif ($env:EXPECTED_VERSION -ne $workspaceVersion) { + throw "Input version '$env:EXPECTED_VERSION' does not match Cargo.toml '$workspaceVersion'" + } else { + $env:EXPECTED_VERSION + } + $stage = 'msix\stage' + $assets = Join-Path $stage 'Assets' + New-Item -ItemType Directory -Force -Path $assets | Out-Null + $binaries = @(Get-ChildItem -LiteralPath bin -File | + Where-Object { $_.Name -eq 'jiffshot.exe' -or $_.Extension -eq '.dll' }) + if (-not ($binaries | Where-Object { $_.Name -eq 'jiffshot.exe' })) { throw 'jiffshot.exe is missing from the build artifact' } + if (-not ($binaries | Where-Object { $_.Name -eq 'rustolonia_host.dll' })) { throw 'rustolonia_host.dll is missing from the build artifact' } + $binaries | Copy-Item -Destination $stage + # Logo rendering is architecture-independent, but the ARM64 executable + # cannot run on the x64 GitHub-hosted runner. + $exe = (Resolve-Path 'logo-bin\jiffshot.exe').Path + foreach ($logo in @( + @{ Size = 150; Name = 'Square150x150Logo.png' }, + @{ Size = 44; Name = 'Square44x44Logo.png' }, + @{ Size = 50; Name = 'StoreLogo.png' } + )) { + $logoPath = Join-Path $assets $logo.Name + $process = Start-Process -FilePath $exe ` + -ArgumentList @('--write-logo', [string]$logo.Size, "`"$logoPath`"") ` + -Wait -PassThru + if ($process.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $logoPath -PathType Leaf)) { + throw "Failed to generate MSIX logo $($logo.Name)" + } + } + $signature = Get-AuthenticodeSignature -LiteralPath $exe + $publisher = if ($signature.Status -eq 'Valid' -and $null -ne $signature.SignerCertificate) { + $signature.SignerCertificate.Subject + } else { + 'CN=Devolutions Jiffshot CI' + } + [xml]$manifest = Get-Content -LiteralPath package\AppxManifest.xml -Raw + $namespace = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable) + $namespace.AddNamespace('appx', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10') + $identity = $manifest.SelectSingleNode('/appx:Package/appx:Identity', $namespace) + if ($null -eq $identity) { throw 'Package identity is missing from package\AppxManifest.xml' } + $identity.SetAttribute('Version', "$expectedVersion.0") + $identity.SetAttribute('ProcessorArchitecture', $env:PACKAGE_ARCHITECTURE) + $identity.SetAttribute('Publisher', $publisher) + $manifest.Save((Join-Path $stage 'AppxManifest.xml')) + $sdkBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $makeAppx = Get-ChildItem -LiteralPath $sdkBin -Directory | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'x64\makeappx.exe') } | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -First 1 | + ForEach-Object { Join-Path $_.FullName 'x64\makeappx.exe' } + if (-not $makeAppx) { throw "Windows SDK MakeAppx.exe was not found under $sdkBin" } + $output = "msix\Devolutions.Jiffshot-$env:PACKAGE_ARCHITECTURE-$expectedVersion.msix" + & $makeAppx pack /d $stage /p $output /o + if ($LASTEXITCODE -ne 0) { throw "MakeAppx failed with exit code $LASTEXITCODE" } + Get-Item -LiteralPath $output | Format-Table Name, Length + + - name: Sign MSIX + if: github.event_name != 'pull_request' && inputs.signing_environment != '' + uses: ./.github/actions/sign + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + TRUSTED_SIGNING_ENDPOINT: ${{ secrets.TRUSTED_SIGNING_ENDPOINT }} + TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.TRUSTED_SIGNING_ACCOUNT_NAME }} + TRUSTED_SIGNING_PROFILE_NAME: ${{ secrets.TRUSTED_SIGNING_PROFILE_NAME }} + TRUSTED_SIGNING_TIMESTAMP_SERVER: ${{ vars.TRUSTED_SIGNING_TIMESTAMP_SERVER || 'http://timestamp.acs.microsoft.com/' }} + with: + install_test_signing_root: ${{ inputs.signing_environment == 'publish-test' }} + files: msix/*.msix + + - name: Install and uninstall + if: matrix.test && github.event_name != 'pull_request' && inputs.signing_environment != '' + shell: pwsh + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: | + $ErrorActionPreference = 'Stop' + $version = if ([string]::IsNullOrWhiteSpace($env:EXPECTED_VERSION)) { + if ((Get-Content -LiteralPath Cargo.toml -Raw) -notmatch '(?ms)^\[workspace\.package\].*?^\s*version\s*=\s*"(?[0-9]+\.[0-9]+\.[0-9]+)"') { + throw 'Could not read [workspace.package] version from Cargo.toml' + } + $Matches['v'] + } else { + $env:EXPECTED_VERSION + } + $msix = "msix\Devolutions.Jiffshot-x64-$version.msix" + Add-AppxPackage -Path $msix -ErrorAction Stop + $installed = Get-AppxPackage -Name Devolutions.Jiffshot + if ($null -eq $installed -or $installed.Version.ToString() -ne "$version.0") { + throw "The installed Jiffshot MSIX does not match version $version" + } + if (-not (Test-Path -LiteralPath (Join-Path $installed.InstallLocation 'jiffshot.exe'))) { + throw 'The installed MSIX does not contain jiffshot.exe' + } + Remove-AppxPackage -Package $installed.PackageFullName -ErrorAction Stop + if (Get-AppxPackage -Name Devolutions.Jiffshot) { throw 'Jiffshot MSIX is still installed after removal' } + + - uses: actions/upload-artifact@v7 + with: + name: jiffshot-${{ matrix.rid }}-msix + path: msix/*.msix + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a626fe..cbcdaa1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -194,14 +194,16 @@ jobs: $portable = "artifacts\jiffshot-win-$arch" $msiName = "Devolutions.Jiffshot-$arch-$env:RELEASE_VERSION.msi" $msi = "artifacts\jiffshot-win-$arch-msi\$msiName" - foreach ($required in @("$portable\jiffshot.exe", "$portable\rustolonia_host.dll", "$portable\README-portable.txt", $msi)) { + $msixName = "Devolutions.Jiffshot-$arch-$env:RELEASE_VERSION.msix" + $msix = "artifacts\jiffshot-win-$arch-msix\$msixName" + foreach ($required in @("$portable\jiffshot.exe", "$portable\rustolonia_host.dll", "$portable\README-portable.txt", $msi, $msix)) { if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing release file: $required" } } $signedTargets = @( Get-ChildItem -Path $portable -File | Where-Object { $_.Extension -in @('.exe', '.dll') } | ForEach-Object { $_.FullName } - ) + @($msi) + ) + @($msi, $msix) foreach ($path in $signedTargets) { $signature = Get-AuthenticodeSignature -LiteralPath $path if ($signature.Status -ne 'Valid' ` @@ -215,8 +217,9 @@ jobs: throw "Release jiffshot.exe ($arch) version ($($exeInfo.FileVersion) / $($exeInfo.ProductVersion)) does not match $env:RELEASE_VERSION" } Copy-Item -LiteralPath $msi -Destination "release\$msiName" + Copy-Item -LiteralPath $msix -Destination "release\$msixName" Compress-Archive -Path "$portable\*" ` - -DestinationPath "release\Devolutions.Jiffshot-$arch-$env:RELEASE_VERSION-portable.zip" + -DestinationPath "release\Devolutions.Jiffshot-$arch-$env:RELEASE_VERSION.zip" } Get-ChildItem release -File | Sort-Object Name | ForEach-Object { '{0} {1}' -f (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant(), $_.Name diff --git a/README.md b/README.md index e317190..a309a47 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,10 @@ Prebuilt, Authenticode-signed packages for **Windows 10 and Windows 11** (`x64` |---|---|---| | **`Devolutions.Jiffshot-x64-.msi`** | Windows x64 (Intel / AMD) | Standard Windows Installer. Installs to `C:\Program Files\Devolutions\Jiffshot`, adds a Start Menu shortcut, and associates `.jsd` Jiffshot Document files. | | **`Devolutions.Jiffshot-arm64-.msi`** | Windows ARM64 | Standard Windows Installer for Windows on ARM devices. | -| **`Devolutions.Jiffshot-x64--portable.zip`** | Windows x64 (Intel / AMD) | Portable archive—unzip anywhere and run `jiffshot.exe`. Nothing is installed in the system. | -| **`Devolutions.Jiffshot-arm64--portable.zip`** | Windows ARM64 | Portable archive for Windows on ARM devices. | +| **`Devolutions.Jiffshot-x64-.msix`** | Windows x64 (Intel / AMD) | Signed MSIX package. Install it to add Jiffshot to Windows; uninstall it from Installed apps. | +| **`Devolutions.Jiffshot-arm64-.msix`** | Windows ARM64 | Signed MSIX package for Windows on ARM devices. | +| **`Devolutions.Jiffshot-x64-.zip`** | Windows x64 (Intel / AMD) | Portable archive—unzip anywhere and run `jiffshot.exe`. Nothing is installed in the system. | +| **`Devolutions.Jiffshot-arm64-.zip`** | Windows ARM64 | Portable archive for Windows on ARM devices. | > **Note for Windows "N" editions:** Video recording uses Windows Media Foundation, which is omitted from Windows "N" SKUs by default. Install Microsoft's *Media Feature Pack* in Windows Optional Features to enable MP4 recording and playback. @@ -266,7 +268,7 @@ Jiffshot is written in Rust with its UI built on [Rustolonia](https://github.com | `crates/jiffshot-doc/` | Documents: annotation objects, rendering, editing and undo, effects, smart tools, `.jsd` and image files | | `crates/jiffshot-workflow/` | The capture side: tray, hotkeys, selection overlay, recording, capture panels, post-capture workflow, command-line modes | | `app/` | `jiffshot.exe`: the editor's model (`src/app.rs`, view models in `src/vm.rs`), every window (`src/ui/`) and the capture side, in one process | -| `package/` | The MSI installer, built with WixSharp | +| `package/` | The MSI installer (WixSharp) and MSIX manifest | ### Compiling @@ -285,7 +287,7 @@ rustup target add aarch64-pc-windows-msvc cargo build --release -p jiffshot --target aarch64-pc-windows-msvc ``` -CI (`.github/workflows/ci.yml`) builds both and uploads them as portable zips (`jiffshot-win-x64`, `jiffshot-win-arm64`). To move to a newer Rustolonia, change the tag on the two lines in `Cargo.toml`'s `[workspace.dependencies]`. +CI (`.github/workflows/ci.yml`) builds both architectures and uploads portable ZIP, MSI, and MSIX artifacts (`jiffshot-win-x64`, `jiffshot-win-arm64`, and their `-msi` / `-msix` variants). To move to a newer Rustolonia, change the tag on the two lines in `Cargo.toml`'s `[workspace.dependencies]`. ### Logo Assets @@ -316,11 +318,11 @@ The MSI lands in `package/out` as `Devolutions.Jiffshot-x64-0.1.0.msi` (with the ### Releases & Code Signing -`ci.yml` builds and tests unsigned portable packages and MSIs for pushes to master, pull requests, and manual runs. `release.yml` reuses those jobs with signing enabled: it signs the portable executable and Rustolonia host DLL before building the MSI, then signs the MSI. The x64 MSI is installed and uninstalled before the release can proceed. +`ci.yml` builds and tests unsigned portable packages, MSIs, and MSIX packages for pushes to master, pull requests, and manual runs. `release.yml` reuses those jobs with signing enabled: it signs the portable executable and Rustolonia host DLL before building the installers, then signs the MSI and MSIX. The x64 MSI is installed and uninstalled before the release can proceed; signed MSIX release builds are also installed and uninstalled. Run **Release** from GitHub Actions with a version matching `Cargo.toml` (for example, `0.1.0`, without `v`). Update and commit the workspace version before releasing a new version. Dry runs are enabled by default and upload signed packages without creating a tag or release. The `github_env` input selects the signing environment independently of `dry_run`: on master, `auto` (the default) and `prod` use `publish-prod`, while `test` uses `publish-test`. Other branches always use `publish-test` and only permit dry runs, even if `prod` is requested. For test signing, the workflow retrieves the `publish-test` profile root with psign and trusts it only in each ephemeral runner's current user certificate store, so Authenticode validation remains enabled. A non-dry-run dispatch with `github_env=test` publishes test-signed artifacts that are not publicly trusted. The workflow rejects non-dry-run releases from other branches, version mismatches, and existing tags. -Both modes retain the portable and MSI Actions artifacts plus a `release-assets` artifact containing x64/ARM64 MSIs, portable ZIPs, and `SHA256SUMS.txt`. After all builds, signing, and validation succeed, a non-dry-run creates `v` at the dispatched commit and publishes a GitHub Release with those same files attached. Do not create the tag manually. +Both modes retain the portable, MSI, and MSIX Actions artifacts plus a `release-assets` artifact containing x64/ARM64 MSIs, MSIX packages, ZIP archives, and `SHA256SUMS.txt`. After all builds, signing, and validation succeed, a non-dry-run creates `v` at the dispatched commit and publishes a GitHub Release with those same files attached. Do not create the tag manually. ```powershell # Validate with test signing without publishing (defaults to dry_run=true). diff --git a/package/AppxManifest.xml b/package/AppxManifest.xml new file mode 100644 index 0000000..832beb1 --- /dev/null +++ b/package/AppxManifest.xml @@ -0,0 +1,36 @@ + + + + + Devolutions Jiffshot + Devolutions Inc. + Assets\StoreLogo.png + + + + + + + + + + + + + + + +