From d50622723989ed506f3c6af47b6937e4a64cbc85 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:48:13 -0700 Subject: [PATCH 1/5] feat(updater): defer updates on Microsoft Store installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Store requires that a product it installed update only through the Store, so a Store build must never run qrate's own updater. MSI installs already declare exactly this, because an administrator owns them. Widening `self_managed()` to cover the Store is therefore the whole change: `find_installation`'s marker check and `AutoUpdater::poll` both already read it, so no new control flow appears. The exhaustive apply match gains the arm it now requires. `initial_status` needs its own arm rather than sharing the MSI one — that text tells the reader to contact their administrator, which is not who manages an install somebody chose from the Store. The kebab-case wire string is pinned by a test because the MSIX manifest and the install marker have to agree on it later. --- crates/app/src/update_check.rs | 3 +++ crates/updater/src/lib.rs | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/app/src/update_check.rs b/crates/app/src/update_check.rs index ac22aac0..0421ea7a 100644 --- a/crates/app/src/update_check.rs +++ b/crates/app/src/update_check.rs @@ -71,6 +71,9 @@ fn initial_status(development_build: bool, kind: Option) -> UpdateS (false, Some(InstallKind::WindowsMsi)) => { UpdateStatus::Disabled("Updates are managed by your administrator".into()) } + (false, Some(InstallKind::WindowsStore)) => { + UpdateStatus::Disabled("Updates are managed by the Microsoft Store".into()) + } (false, Some(_)) => UpdateStatus::Idle, (false, None) => { UpdateStatus::Disabled("This source or unmarked build updates manually".into()) diff --git a/crates/updater/src/lib.rs b/crates/updater/src/lib.rs index 5b0c5248..691f8f47 100644 --- a/crates/updater/src/lib.rs +++ b/crates/updater/src/lib.rs @@ -101,6 +101,7 @@ pub enum InstallKind { WindowsNsis, WindowsPortable, WindowsMsi, + WindowsStore, MacosBundle, LinuxTar, } @@ -117,7 +118,7 @@ pub enum Flavor { impl InstallKind { pub fn self_managed(self) -> bool { - !matches!(self, Self::WindowsMsi) + !matches!(self, Self::WindowsMsi | Self::WindowsStore) } } @@ -507,6 +508,9 @@ fn run_job_with( InstallKind::MacosBundle => apply_macos(&job, updates, launch), InstallKind::LinuxTar => apply_tar(&job, updates, launch), InstallKind::WindowsMsi => bail!("MSI installations are administrator-managed"), + InstallKind::WindowsStore => { + bail!("Microsoft Store installations update through the Store") + } } })(); if let Err(error) = &result { @@ -978,11 +982,20 @@ mod tests { } #[test] - fn msi_installations_are_never_self_managed() { + fn externally_managed_installations_are_never_self_managed() { assert!(!InstallKind::WindowsMsi.self_managed()); + assert!(!InstallKind::WindowsStore.self_managed()); assert!(InstallKind::WindowsNsis.self_managed()); } + #[test] + fn install_kinds_serialize_as_kebab_case() { + assert_eq!( + serde_json::to_value(InstallKind::WindowsStore).unwrap(), + serde_json::json!("windows-store") + ); + } + #[test] fn hashes_files() { let file = tempfile::NamedTempFile::new().unwrap(); From 65439b542b67c710f33a109eff41a03df0e629bb Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:05:22 -0700 Subject: [PATCH 2/5] feat(release): prepare Microsoft Store installer --- .github/workflows/release.yml | 57 +++++++++++- docs/dev/microsoft-store-release.md | 63 +++++++++++++ scripts/package-store-msix.ps1 | 122 +++++++++++++++++++++++++ scripts/store/Package.appxmanifest | 45 ++++++++++ scripts/submit-store-msix.ps1 | 134 ++++++++++++++++++++++++++++ 5 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 docs/dev/microsoft-store-release.md create mode 100644 scripts/package-store-msix.ps1 create mode 100644 scripts/store/Package.appxmanifest create mode 100644 scripts/submit-store-msix.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 206f5552..670be5af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -314,9 +314,25 @@ jobs: -d "HasFfmpeg=$hasFfmpeg" ` -o "dist\qrate-$ver-x86_64.msi" if ($LASTEXITCODE -ne 0) { throw "wix build failed ($LASTEXITCODE)" } - Remove-Item -Recurse -Force dist/portable + - name: Package unsigned Microsoft Store MSIX + if: ${{ !contains(needs.version.outputs.version, '-') && vars.QRATE_STORE_APPLICATION_ID != '' && vars.QRATE_STORE_IDENTITY_NAME != '' && vars.QRATE_STORE_IDENTITY_PUBLISHER != '' && vars.QRATE_STORE_PUBLISHER_DISPLAY_NAME != '' }} + shell: pwsh + env: + QRATE_STORE_IDENTITY_NAME: ${{ vars.QRATE_STORE_IDENTITY_NAME }} + QRATE_STORE_IDENTITY_PUBLISHER: ${{ vars.QRATE_STORE_IDENTITY_PUBLISHER }} + QRATE_STORE_PUBLISHER_DISPLAY_NAME: ${{ vars.QRATE_STORE_PUBLISHER_DISPLAY_NAME }} + run: | + $ver = "${{ needs.version.outputs.version }}" + ./scripts/package-store-msix.ps1 ` + -SourceExe target/release/app.exe ` + -OutputPath "dist/qrate-$ver-x64.msix" ` + -QrateVersion $ver ` + -IdentityName $env:QRATE_STORE_IDENTITY_NAME ` + -IdentityPublisher $env:QRATE_STORE_IDENTITY_PUBLISHER ` + -PublisherDisplayName $env:QRATE_STORE_PUBLISHER_DISPLAY_NAME + # 7-Zip reads NSIS installers, which is the only way to see what one carries without running it. - name: Check what each installer carries shell: bash @@ -349,6 +365,13 @@ jobs: path: components-dist/* if-no-files-found: error + - uses: actions/upload-artifact@v7 + if: ${{ !contains(needs.version.outputs.version, '-') && vars.QRATE_STORE_APPLICATION_ID != '' && vars.QRATE_STORE_IDENTITY_NAME != '' && vars.QRATE_STORE_IDENTITY_PUBLISHER != '' && vars.QRATE_STORE_PUBLISHER_DISPLAY_NAME != '' }} + with: + name: windows-store-msix + path: dist/*.msix + if-no-files-found: error + # --------------------------------------------------------------------------- # Linux: binary + .desktop + icon in a tarball. # Built on the oldest supported runner so the glibc/OpenSSL it links against @@ -596,3 +619,35 @@ jobs: > - **Windows:** SmartScreen may warn "unknown publisher" — click *More info → Run anyway*. > - **macOS:** open it once, then choose *Open Anyway* under System Settings → Privacy & Security, or run > `xattr -dr com.apple.quarantine "/Applications/qrate.app"`. + + # Store signs the package after certification. Keep the unsigned upload as a workflow artifact, + # separate from the established GitHub release downloads. + submit-store: + needs: [version, build-windows, release] + if: ${{ !contains(needs.version.outputs.version, '-') && vars.QRATE_STORE_SUBMISSIONS_ENABLED == 'true' && vars.QRATE_STORE_APPLICATION_ID != '' && vars.QRATE_STORE_IDENTITY_NAME != '' && vars.QRATE_STORE_IDENTITY_PUBLISHER != '' && vars.QRATE_STORE_PUBLISHER_DISPLAY_NAME != '' }} + runs-on: windows-latest + environment: release-signing + concurrency: + group: microsoft-store-${{ vars.QRATE_STORE_APPLICATION_ID }} + cancel-in-progress: false + permissions: + contents: read + env: + QRATE_STORE_APPLICATION_ID: ${{ vars.QRATE_STORE_APPLICATION_ID }} + QRATE_STORE_TENANT_ID: ${{ secrets.QRATE_STORE_TENANT_ID }} + QRATE_STORE_CLIENT_ID: ${{ secrets.QRATE_STORE_CLIENT_ID }} + QRATE_STORE_CLIENT_SECRET: ${{ secrets.QRATE_STORE_CLIENT_SECRET }} + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + name: windows-store-msix + path: store-package + - name: Submit stable MSIX to Partner Center + shell: pwsh + run: | + $package = Get-ChildItem store-package/*.msix | Select-Object -First 1 + if (-not $package) { throw 'The Windows Store MSIX artifact is missing.' } + ./scripts/submit-store-msix.ps1 ` + -PackagePath $package.FullName ` + -QrateVersion "${{ needs.version.outputs.version }}" diff --git a/docs/dev/microsoft-store-release.md b/docs/dev/microsoft-store-release.md new file mode 100644 index 00000000..dc8283fc --- /dev/null +++ b/docs/dev/microsoft-store-release.md @@ -0,0 +1,63 @@ +# Microsoft Store release setup + +GitHub Releases remain qrate's public release channel. On stable tags, the Windows build can also +produce a private `windows-store-msix` workflow artifact. Once Store submission is enabled, a later +job creates and commits a Partner Center submission. The unsigned MSIX is not attached to the +GitHub release; Microsoft signs it after certification. + +## One-time Partner Center setup + +1. Reserve qrate's name and create the app in Partner Center. Fill in the age ratings, listing and + privacy policy URL. The submission API cannot create the first app submission; use Partner + Center's UI for the first package submission after the workflow builds its MSIX artifact. +2. Deploy the privacy policy update in the qrate-site checkout, then use + . The current policy now describes the optional Pi/OpenRouter + data flow and app update/catalog requests. +3. Copy the exact Store identity values from Partner Center: `Identity/Name` and + `Identity/Publisher`. These values are immutable after the first submission. +4. Associate an Entra application with Partner Center and give it the Manager role. + +Set these repository variables: + +| Variable | Value | +| --- | --- | +| `QRATE_STORE_APPLICATION_ID` | The app's Store ID from Partner Center | +| `QRATE_STORE_IDENTITY_NAME` | Exact `Identity/Name` from Partner Center | +| `QRATE_STORE_IDENTITY_PUBLISHER` | Exact `Identity/Publisher` from Partner Center | +| `QRATE_STORE_PUBLISHER_DISPLAY_NAME` | The publisher name shown to customers | +| `QRATE_STORE_SUBMISSIONS_ENABLED` | `true` only after the first submission is complete | + +Set these secrets in the `release-signing` GitHub environment: + +| Secret | Value | +| --- | --- | +| `QRATE_STORE_TENANT_ID` | Entra tenant ID | +| `QRATE_STORE_CLIENT_ID` | Entra application client ID | +| `QRATE_STORE_CLIENT_SECRET` | Entra application secret | + +With identity variables set and submissions disabled, a stable release builds the MSIX artifact so +the first package can be submitted by hand in Partner Center. After that submission has completed +with age ratings, set +`QRATE_STORE_SUBMISSIONS_ENABLED=true`; later stable tags submit automatically. Prerelease tags do +not build or submit an MSIX. The submit job keeps Partner Center's existing publish mode and listing +metadata, and stops after Partner Center accepts the submission for processing. Certification and +any manual publication step remain visible in Partner Center. + +## Package behavior + +The Store package declares `windows-store` and `base` in `qrate-install.json`. It contains only the +app, the marker and manifest images. PDFium, ffmpeg and the agent runtime remain optional components +installed after the user chooses them, using the same component manifest as the other installers. +The full-trust manifest allows qrate's native PDF loader, child processes and agent runtime to work; +the Store install directory itself remains read-only, so qrate's updater is disabled for this kind. +The package manifest targets Windows 10 version 2004 (build 19041) or later for its `uap10` +full-trust declaration. + +The Store reserves the fourth MSIX version field. It also requires a nonzero first version field, +while qrate is still `0.x`. The package script offsets qrate's major version by one (for example, +qrate `0.6.0` becomes Store package `1.6.0.0`), preserving version order. The app's own version in +the install marker remains the Cargo version. + +The Store submission job uses Microsoft's MSIX app submission API. It needs the Store ID plus the +Entra tenant, client ID and secret; no Seller ID is used by this API. Store package signing is +provided after certification, so the build does not need a code-signing certificate. diff --git a/scripts/package-store-msix.ps1 b/scripts/package-store-msix.ps1 new file mode 100644 index 00000000..c80e8b52 --- /dev/null +++ b/scripts/package-store-msix.ps1 @@ -0,0 +1,122 @@ +param( + [Parameter(Mandatory)] [string] $SourceExe, + [Parameter(Mandatory)] [string] $OutputPath, + [Parameter(Mandatory)] [string] $QrateVersion, + [Parameter(Mandatory)] [string] $IdentityName, + [Parameter(Mandatory)] [string] $IdentityPublisher, + [Parameter(Mandatory)] [string] $PublisherDisplayName +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Fail([string] $Message) { + throw "Store MSIX packaging: $Message" +} + +if (-not (Test-Path -LiteralPath $SourceExe -PathType Leaf)) { + Fail "application executable not found: $SourceExe" +} +foreach ($value in @($IdentityName, $IdentityPublisher, $PublisherDisplayName)) { + if ([string]::IsNullOrWhiteSpace($value)) { Fail 'all Partner Center identity values are required' } +} + +if ($QrateVersion -notmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { + Fail "expected a stable three-part qrate version, got '$QrateVersion'" +} +$major, $minor, $patch = $QrateVersion.Split('.') | ForEach-Object { [int] $_ } +if ($major -ge 65535 -or $minor -gt 65535 -or $patch -gt 65535) { + Fail "version '$QrateVersion' cannot be represented by the MSIX version fields" +} +# Store packages require a nonzero first field. Offset the qrate major by one, preserving +# semver ordering and leaving the Store-reserved fourth field at zero. +$storeVersion = "$($major + 1).$minor.$patch.0" + +$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$sourceIcon = Join-Path $root 'assets\icons\app-icon.png' +$manifestTemplate = Join-Path $PSScriptRoot 'store\Package.appxmanifest' +if (-not (Test-Path -LiteralPath $sourceIcon -PathType Leaf)) { Fail "icon not found: $sourceIcon" } +if (-not (Test-Path -LiteralPath $manifestTemplate -PathType Leaf)) { Fail "manifest template not found: $manifestTemplate" } + +$stage = Join-Path ([IO.Path]::GetTempPath()) ("qrate-store-msix-" + [guid]::NewGuid().ToString('N')) +$unpacked = "$stage-unpacked" +$makeAppx = Get-ChildItem -LiteralPath "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Filter makeappx.exe -File -Recurse | + Where-Object { $_.Directory.Name -eq 'x64' -and $_.Directory.Parent.Name -match '^\d+(\.\d+){3}$' } | + Sort-Object { [version] $_.Directory.Parent.Name } -Descending | + Select-Object -First 1 -ExpandProperty FullName +if (-not $makeAppx) { Fail 'Windows SDK MakeAppx.exe was not found' } + +try { + New-Item -ItemType Directory -Path (Join-Path $stage 'Assets') -Force | Out-Null + Copy-Item -LiteralPath $SourceExe -Destination (Join-Path $stage 'qrate.exe') + $marker = [ordered]@{ + schema = 1 + kind = 'windows-store' + packaged_version = $QrateVersion + flavor = 'base' + } | ConvertTo-Json + [IO.File]::WriteAllText((Join-Path $stage 'qrate-install.json'), $marker + "`n", [Text.UTF8Encoding]::new($false)) + + Add-Type -AssemblyName System.Drawing + $sourceBitmap = [System.Drawing.Bitmap]::new($sourceIcon) + try { + foreach ($size in @(50, 150, 44)) { + $name = switch ($size) { + 50 { 'StoreLogo.png' } + 150 { 'Square150x150Logo.png' } + 44 { 'Square44x44Logo.png' } + } + $bitmap = [System.Drawing.Bitmap]::new($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + try { + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.Clear([System.Drawing.Color]::Transparent) + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality + $graphics.DrawImage($sourceBitmap, 0, 0, $size, $size) + } finally { + $graphics.Dispose() + } + $bitmap.Save((Join-Path $stage "Assets\$name"), [System.Drawing.Imaging.ImageFormat]::Png) + } finally { + $bitmap.Dispose() + } + } + } finally { + $sourceBitmap.Dispose() + } + + $manifest = [xml](Get-Content -LiteralPath $manifestTemplate -Raw) + $ns = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable) + $ns.AddNamespace('a', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10') + $identity = $manifest.SelectSingleNode('/a:Package/a:Identity', $ns) + $identity.SetAttribute('Name', $IdentityName) + $identity.SetAttribute('Publisher', $IdentityPublisher) + $identity.SetAttribute('Version', $storeVersion) + $manifest.SelectSingleNode('/a:Package/a:Properties/a:PublisherDisplayName', $ns).InnerText = $PublisherDisplayName + $manifest.Save((Join-Path $stage 'AppxManifest.xml')) + + $resolvedOutput = [IO.Path]::GetFullPath($OutputPath) + New-Item -ItemType Directory -Path (Split-Path -Parent $resolvedOutput) -Force | Out-Null + if (Test-Path -LiteralPath $resolvedOutput) { Remove-Item -LiteralPath $resolvedOutput -Force } + & $makeAppx pack /d $stage /p $resolvedOutput /o + if ($LASTEXITCODE -ne 0) { Fail "MakeAppx pack failed with exit code $LASTEXITCODE" } + & $makeAppx validate /p $resolvedOutput + if ($LASTEXITCODE -ne 0) { Fail "MakeAppx validation failed with exit code $LASTEXITCODE" } + + & $makeAppx unpack /p $resolvedOutput /d $unpacked /o + if ($LASTEXITCODE -ne 0) { Fail "MakeAppx unpack failed with exit code $LASTEXITCODE" } + $unpackedMarker = Get-Content -LiteralPath (Join-Path $unpacked 'qrate-install.json') -Raw | ConvertFrom-Json + if ($unpackedMarker.kind -ne 'windows-store' -or $unpackedMarker.flavor -ne 'base' -or $unpackedMarker.packaged_version -ne $QrateVersion) { + Fail 'the package install marker does not declare this Store base build' + } + foreach ($excluded in @('qrate-update-helper.exe', 'pdfium.dll', 'ffmpeg.exe', 'agent')) { + if (Test-Path -LiteralPath (Join-Path $unpacked $excluded)) { Fail "base package unexpectedly contains $excluded" } + } + Write-Host "Built unsigned x64 MSIX: $resolvedOutput" + Write-Host "qrate version $QrateVersion -> Store package version $storeVersion" +} finally { + Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $unpacked -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/store/Package.appxmanifest b/scripts/store/Package.appxmanifest new file mode 100644 index 00000000..738e9dac --- /dev/null +++ b/scripts/store/Package.appxmanifest @@ -0,0 +1,45 @@ + + + + + qrate + qrate-publisher + A research reference manager. + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + .qrate + + + + + + + + + + diff --git a/scripts/submit-store-msix.ps1 b/scripts/submit-store-msix.ps1 new file mode 100644 index 00000000..02a4ffa8 --- /dev/null +++ b/scripts/submit-store-msix.ps1 @@ -0,0 +1,134 @@ +param( + [Parameter(Mandatory)] [string] $PackagePath, + [Parameter(Mandatory)] [string] $QrateVersion +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Require-Environment([string] $Name) { + $value = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($value)) { throw "Required release-signing environment value '$Name' is missing." } + return $value +} + +function Invoke-StoreApi([string] $Method, [string] $Uri, [string] $Token, [object] $Body = $null) { + $parameters = @{ + Method = $Method + Uri = $Uri + Headers = @{ Authorization = "Bearer $Token" } + ErrorAction = 'Stop' + } + if ($null -ne $Body) { + $parameters.ContentType = 'application/json' + $parameters.Body = ConvertTo-Json -InputObject $Body -Depth 100 -Compress + } + try { + return Invoke-RestMethod @parameters + } catch { + $status = $null + if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } + $detail = if ($status) { "HTTP $status" } else { 'transport failure' } + $response = $_.ErrorDetails.Message + if ([string]::IsNullOrWhiteSpace($response)) { $response = 'no response details' } + throw "Microsoft Store API request failed ($detail): $response" + } +} + +$applicationId = Require-Environment 'QRATE_STORE_APPLICATION_ID' +$tenantId = Require-Environment 'QRATE_STORE_TENANT_ID' +$clientId = Require-Environment 'QRATE_STORE_CLIENT_ID' +$clientSecret = Require-Environment 'QRATE_STORE_CLIENT_SECRET' +if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { throw "MSIX package not found: $PackagePath" } +if ($QrateVersion -notmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { + throw "Store submission only accepts a stable three-part qrate version; got '$QrateVersion'." +} + +$tokenEndpoint = "https://login.microsoftonline.com/$tenantId/oauth2/token" +try { + $tokenResponse = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -ContentType 'application/x-www-form-urlencoded' ` + -Body @{ grant_type = 'client_credentials'; client_id = $clientId; client_secret = $clientSecret; resource = 'https://manage.devcenter.microsoft.com' } ` + -ErrorAction Stop +} catch { + throw 'Could not obtain a Microsoft Store submission token. Check the Entra tenant, application, secret, and Partner Center Manager role.' +} +$token = $tokenResponse.access_token +if ([string]::IsNullOrWhiteSpace($token)) { throw 'Microsoft Entra returned no access token.' } + +$baseUri = "https://manage.devcenter.microsoft.com/v1.0/my/applications/$applicationId/submissions" +$submission = Invoke-StoreApi -Method Post -Uri $baseUri -Token $token -Body @{} +$submissionId = [string]$submission.id +if ([string]::IsNullOrWhiteSpace($submissionId)) { throw 'The Store API created no submission ID.' } +if ([string]::IsNullOrWhiteSpace([string]$submission.fileUploadUrl)) { + throw "Submission $submissionId has no package upload URL. It may need attention in Partner Center." +} + +$packages = @() +foreach ($oldPackage in @($submission.applicationPackages)) { + if ([string]$oldPackage.fileName -ne [IO.Path]::GetFileName($PackagePath)) { + $packages += [ordered]@{ + fileName = [string]$oldPackage.fileName + fileStatus = 'PendingDelete' + minimumDirectXVersion = 'None' + minimumSystemRam = 'None' + } + } +} +$packages += [ordered]@{ + fileName = [IO.Path]::GetFileName($PackagePath) + fileStatus = 'PendingUpload' + minimumDirectXVersion = 'None' + minimumSystemRam = 'None' +} +$submission.applicationPackages = $packages + +$body = [ordered]@{} +foreach ($property in $submission.PSObject.Properties) { + if ($property.Name -notin @('id', 'status', 'statusDetails', 'fileUploadUrl', 'friendlyName')) { + $body[$property.Name] = $property.Value + } +} +$submissionUri = "$baseUri/$submissionId" +$null = Invoke-StoreApi -Method Put -Uri $submissionUri -Token $token -Body $body + +$zipPath = Join-Path ([IO.Path]::GetTempPath()) ("qrate-store-upload-" + [guid]::NewGuid().ToString('N') + '.zip') +try { + Compress-Archive -LiteralPath (Resolve-Path -LiteralPath $PackagePath).Path -DestinationPath $zipPath -CompressionLevel Optimal + try { + Invoke-WebRequest -Method Put -Uri ([string]$submission.fileUploadUrl) -InFile $zipPath ` + -ContentType 'application/zip' -Headers @{ + 'x-ms-blob-type' = 'BlockBlob' + 'x-ms-date' = [DateTime]::UtcNow.ToString('R') + 'x-ms-version' = '2019-12-12' + } -ErrorAction Stop | Out-Null + } catch { + throw "Could not upload the package archive for Store submission $submissionId. The SAS URL was omitted from logs." + } +} finally { + Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue +} + +$null = Invoke-StoreApi -Method Post -Uri "$submissionUri/commit" -Token $token -Body @{} +$statusUri = "$submissionUri/status" +$deadline = (Get-Date).AddMinutes(8) +do { + Start-Sleep -Seconds 15 + $status = Invoke-StoreApi -Method Get -Uri $statusUri -Token $token + switch ([string]$status.status) { + 'PreProcessing' { + Write-Host "Submitted qrate $QrateVersion to Microsoft Store (submission $submissionId). Certification continues in Partner Center." + exit 0 + } + { $_ -in @('PendingPublication', 'Publishing', 'Published', 'Certification', 'Release') } { + Write-Host "Submitted qrate $QrateVersion to Microsoft Store (submission $submissionId; status $($status.status)). Certification or publication continues in Partner Center." + exit 0 + } + 'CommitFailed' { + $messages = @($status.statusDetails.errors | ForEach-Object { "[$($_.code)] $($_.details)" }) -join "`n" + throw "Microsoft Store rejected the submission commit ($submissionId):`n$messages" + } + { $_ -in @('CommitStarted', 'PendingCommit') } { continue } + default { throw "Store submission $submissionId entered unexpected commit status '$($status.status)'." } + } +} while ((Get-Date) -lt $deadline) +throw "Store submission $submissionId is still committing. Check its status in Partner Center before retrying." From 44d76aa3f5b949ddc711b98c2d8d2ea3bdff1ca4 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:21:32 -0700 Subject: [PATCH 3/5] feat(release): update Homebrew and winget packages --- .github/workflows/package-managers.yml | 116 +++++++++++++++++++++++++ .github/workflows/release.yml | 7 +- README.md | 14 ++- docs/dev/SETUP.md | 12 ++- docs/dev/microsoft-store-release.md | 10 ++- docs/dev/package-manager-releases.md | 50 +++++++++++ docs/getting-started/index.md | 5 +- scripts/update-homebrew-cask.py | 30 +++++++ 8 files changed, 226 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/package-managers.yml create mode 100644 docs/dev/package-manager-releases.md create mode 100644 scripts/update-homebrew-cask.py diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml new file mode 100644 index 00000000..59853a7f --- /dev/null +++ b/.github/workflows/package-managers.yml @@ -0,0 +1,116 @@ +name: Update package managers + +on: + release: + types: [released] + +permissions: + contents: read + +concurrency: + group: package-managers-${{ github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + homebrew: + if: ${{ vars.HOMEBREW_TAP_UPDATES_ENABLED == 'true' && !github.event.release.prerelease }} + runs-on: ubuntu-latest + concurrency: + group: homebrew-tap + cancel-in-progress: false + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name }} + path: qrate + persist-credentials: false + + - uses: actions/checkout@v7 + with: + repository: devnull03/homebrew-tap + ref: main + path: homebrew-tap + persist-credentials: false + + - name: Update Cask from the published release checksum + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF_NAME}" + if [[ ! "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Homebrew updates require a stable vX.Y.Z tag: $tag" + exit 1 + fi + version="${tag#v}" + mkdir -p "$RUNNER_TEMP/qrate-release" + gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ + --pattern SHA256SUMS.txt --dir "$RUNNER_TEMP/qrate-release" + asset="qrate-${version}-base-universal.dmg" + sha256="$(awk -v asset="$asset" '$2 == asset { print $1 }' "$RUNNER_TEMP/qrate-release/SHA256SUMS.txt")" + if [[ ! "$sha256" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "::error::SHA256SUMS.txt has no valid checksum for $asset" + exit 1 + fi + python3 qrate/scripts/update-homebrew-cask.py \ + --cask homebrew-tap/Casks/qrate.rb --version "$version" --sha256 "$sha256" + echo "Qrate release $version -> Homebrew Cask checksum updated" + + - name: Commit Cask update to the tap + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + if [[ -z "$GH_TOKEN" ]]; then + echo "::error::Set the HOMEBREW_TAP_TOKEN repository secret before enabling Homebrew updates." + exit 1 + fi + git -C homebrew-tap config user.name "github-actions[bot]" + git -C homebrew-tap config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -C homebrew-tap add Casks/qrate.rb + if git -C homebrew-tap diff --cached --quiet; then + echo "Cask already matches this release" + exit 0 + fi + version="${GITHUB_REF_NAME#v}" + git -C homebrew-tap commit -m "qrate $version" + gh auth setup-git + git -C homebrew-tap push origin HEAD:main + + winget: + if: ${{ vars.WINGET_PACKAGE_ID != '' && !github.event.release.prerelease }} + runs-on: windows-latest + concurrency: + group: winget-qrate + cancel-in-progress: false + steps: + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 6.0.x + + - name: Download WinGetCreate and submit the MSI update + shell: pwsh + env: + WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_CREATE_GITHUB_TOKEN }} + WINGET_PACKAGE_ID: ${{ vars.WINGET_PACKAGE_ID }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_NOTES_URL: ${{ github.event.release.html_url }} + run: | + if ([string]::IsNullOrWhiteSpace($env:WINGET_CREATE_GITHUB_TOKEN)) { + throw 'Set the WINGET_CREATE_GITHUB_TOKEN repository secret before enabling WinGet updates.' + } + if ($env:WINGET_PACKAGE_ID -ne 'devnull03.qrate') { + throw "Expected WINGET_PACKAGE_ID=devnull03.qrate, got '$env:WINGET_PACKAGE_ID'." + } + if ($env:RELEASE_TAG -notmatch '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { + throw "WinGet updates require a stable vX.Y.Z tag: $env:RELEASE_TAG" + } + $version = $env:RELEASE_TAG.Substring(1) + $installerUrl = "https://github.com/devnull03/qrate/releases/download/$($env:RELEASE_TAG)/qrate-$version-x86_64.msi" + $installer = "$installerUrl|x64|machine" + $tool = Join-Path $env:RUNNER_TEMP 'wingetcreate.exe' + Invoke-WebRequest -Uri 'https://aka.ms/wingetcreate/latest' -OutFile $tool + & $tool update $env:WINGET_PACKAGE_ID ` + --urls $installer ` + --version $version ` + --release-notes-url $env:RELEASE_NOTES_URL ` + --submit --no-open + if ($LASTEXITCODE -ne 0) { throw "WinGetCreate failed with exit code $LASTEXITCODE" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 670be5af..b457cc1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -614,11 +614,10 @@ jobs: too. Without ffmpeg those files fall back to a file-type icon; nothing else is affected. > [!NOTE] - > These builds are **unsigned**. This is about who Windows and macOS think - > published the app, and is separate from the signature qrate checks on an update. + > These builds do not have a Windows publisher signature or an Apple Developer ID + > signature and notarization. This is separate from the signature qrate checks on an update. > - **Windows:** SmartScreen may warn "unknown publisher" — click *More info → Run anyway*. - > - **macOS:** open it once, then choose *Open Anyway* under System Settings → Privacy & Security, or run - > `xattr -dr com.apple.quarantine "/Applications/qrate.app"`. + > - **macOS:** try to open it once, then choose *Open Anyway* under System Settings → Privacy & Security. # Store signs the package after certification. Keep the unsigned upload as a workflow artifact, # separate from the established GitHub release downloads. diff --git a/README.md b/README.md index fd7af993..c3d3aaf9 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,19 @@ Collection data rarely lives in just one place. A spreadsheet names an object, a ## Get qrate -Download the installer or portable build for your platform from [GitHub Releases](https://github.com/devnull03/qrate/releases). Release assets are available for Windows, macOS, and Linux. Releases are currently unsigned, so Windows SmartScreen or macOS Gatekeeper may ask for confirmation the first time you open qrate. +Download the installer or portable build for your platform from [GitHub Releases](https://github.com/devnull03/qrate/releases). Release assets are available for Windows, macOS, and Linux. The builds do not have a Windows publisher signature or an Apple Developer ID signature and notarization, so Windows SmartScreen or macOS Gatekeeper may ask you to allow qrate the first time you open it. + +On macOS, install the base app from the Homebrew tap: + +```sh +brew install --cask devnull03/tap/qrate +``` + +On Windows, install qrate with WinGet after its first community manifest is accepted: + +```powershell +winget install --id devnull03.qrate --exact +``` PDF preview support is included in release builds. Video previews and some less-common image formats need `ffmpeg`. The Windows downloads include it; on macOS and Linux, install `ffmpeg` and make it available on your system `PATH`. diff --git a/docs/dev/SETUP.md b/docs/dev/SETUP.md index 9250ad4c..25a208bc 100644 --- a/docs/dev/SETUP.md +++ b/docs/dev/SETUP.md @@ -186,13 +186,11 @@ produce nothing visible. `CI` checks — but note `ci.yml` only runs on PRs / `dev` pushes today (§4), and tag pushes bypass protection by design. -4. **Code signing (optional, currently OFF).** Releases are **unsigned**: - - Windows: SmartScreen "unknown publisher" warning. - - macOS: Gatekeeper quarantine (`xattr -dr com.apple.quarantine ...`). - To sign later you'd add secrets (Apple Developer ID cert + notarization creds, - a Windows code-signing cert) and signing steps in `release.yml`. None exist yet, - so no code-signing secrets are required today. This is separate from the update - signing key in step 6. +4. **Platform signing.** macOS bundles are ad-hoc signed to seal the app, but they do + not have an Apple Developer ID signature or notarization; users may need to choose + **Open Anyway** under System Settings → Privacy & Security. Windows installers remain + unsigned and can show the SmartScreen "unknown publisher" warning. No platform-signing + secrets are required. This is separate from the update signing key in step 6. 5. **Google credentials.** Actions secrets, mapped into the `QRATE_*` build vars by `release.yml` — nothing else needs editing. Only one of the three is required: diff --git a/docs/dev/microsoft-store-release.md b/docs/dev/microsoft-store-release.md index dc8283fc..a7f99d1b 100644 --- a/docs/dev/microsoft-store-release.md +++ b/docs/dev/microsoft-store-release.md @@ -1,9 +1,11 @@ # Microsoft Store release setup -GitHub Releases remain qrate's public release channel. On stable tags, the Windows build can also -produce a private `windows-store-msix` workflow artifact. Once Store submission is enabled, a later -job creates and commits a Partner Center submission. The unsigned MSIX is not attached to the -GitHub release; Microsoft signs it after certification. +GitHub Releases remain qrate's primary release record and download channel. Stable releases can +also update the Homebrew tap and open WinGet update submissions; see +[package manager releases](package-manager-releases.md). The Windows build can also produce a +private `windows-store-msix` workflow artifact. Once Store submission is enabled, a later job +creates and commits a Partner Center submission. The unsigned MSIX is not attached to the GitHub +release; Microsoft signs it after certification. ## One-time Partner Center setup diff --git a/docs/dev/package-manager-releases.md b/docs/dev/package-manager-releases.md new file mode 100644 index 00000000..40c5aa09 --- /dev/null +++ b/docs/dev/package-manager-releases.md @@ -0,0 +1,50 @@ +# Package manager releases + +GitHub Releases remain the source for signed update metadata and downloadable assets. Once a +release is published, the package-manager workflow updates qrate's Homebrew Cask and opens a WinGet +manifest update pull request. It runs on stable releases only; beta tags do not change either +package manager. + +## Homebrew + +The Cask lives in [`devnull03/homebrew-tap`](https://github.com/devnull03/homebrew-tap), at +`Casks/qrate.rb`. It installs the base universal app from the release DMG. qrate installs optional +components when the user requests them. The Cask declares `auto_updates true` because qrate also +has an in-app updater. The app is not notarized; the Cask shows a first-launch Gatekeeper note. + +Set the `HOMEBREW_TAP_UPDATES_ENABLED` repository variable to `true` and add the +`HOMEBREW_TAP_TOKEN` repository secret. The token needs Contents read/write access to +`devnull03/homebrew-tap`. For each published stable release, the workflow reads the DMG checksum +from `SHA256SUMS.txt`, updates the Cask version and checksum, and pushes a commit to the tap's +`main` branch. It does not push a release that is still a draft. + +The current tap entry points at qrate 0.6.0-beta.1 so the Cask can be used before the first stable +release. The update workflow replaces it with the next stable version. + +## WinGet + +The WinGet listing uses the per-machine MSI, which is the Windows package-manager build and updates +through WinGet. The community repository must accept the first manifest before the release workflow +can update it. + +For the first stable release, create and submit the initial manifest on Windows with WinGetCreate: + +```powershell +wingetcreate new https://github.com/devnull03/qrate/releases/download/vX.Y.Z/qrate-X.Y.Z-x86_64.msi +``` + +Use package identifier `devnull03.qrate`, publisher `devnull03`, and package name `qrate`; submit +the manifest to the [WinGet community repository](https://github.com/microsoft/winget-pkgs). Wait +for that PR to merge before enabling automation. Microsoft documents that new manifests are +submitted through a pull request and then validated by the repository. + +After the first manifest is accepted, set the `WINGET_PACKAGE_ID` repository variable to +`devnull03.qrate` and add the `WINGET_CREATE_GITHUB_TOKEN` repository secret. The token must be a +GitHub personal access token (classic) with the `repo` scope, as required by WinGetCreate's +submission flow. On each published stable release, the workflow downloads the official +WinGetCreate CLI, reads the version and public MSI URL from the release tag, and asks WinGetCreate +to calculate the MSI metadata and open an update PR. The PR still goes through Microsoft's normal +validation and review. + +The first WinGet manifest is an external prerequisite, so the update job is skipped while +`WINGET_PACKAGE_ID` is unset. The GitHub release and the Homebrew update do not depend on WinGet. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index f0194d9d..56422045 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -63,8 +63,9 @@ row's file, for example `IMG_0042.jpg`. qrate uses this column to link rows to f 3. Run the installer, or unpack the portable build. 4. Start qrate. -Releases are not signed yet. The first time you open qrate, Windows SmartScreen or macOS -Gatekeeper can ask you to confirm. +The builds do not have a Windows publisher signature or an Apple Developer ID signature and +notarization. Windows SmartScreen can ask you to confirm. On macOS, try to open qrate once, then +choose **Open Anyway** under System Settings → Privacy & Security. Every release shows PDF previews. The Windows downloads also include ffmpeg, which qrate uses for video previews. On macOS and Linux, install ffmpeg and put it on your `PATH` to see video diff --git a/scripts/update-homebrew-cask.py b/scripts/update-homebrew-cask.py new file mode 100644 index 00000000..fb694e2b --- /dev/null +++ b/scripts/update-homebrew-cask.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Update the pinned version and release checksum in the qrate Homebrew Cask.""" + +import argparse +import re +from pathlib import Path + + +def replace_once(pattern: str, replacement: str, text: str, label: str) -> str: + updated, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if count != 1: + raise SystemExit(f"expected one {label} stanza in the qrate Cask, found {count}") + return updated + + +parser = argparse.ArgumentParser() +parser.add_argument("--cask", type=Path, required=True) +parser.add_argument("--version", required=True) +parser.add_argument("--sha256", required=True) +args = parser.parse_args() + +if not re.fullmatch(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", args.version): + raise SystemExit("Homebrew Cask updates require a stable three-part version") +if not re.fullmatch(r"[0-9a-fA-F]{64}", args.sha256): + raise SystemExit("the DMG checksum must contain 64 hexadecimal characters") + +text = args.cask.read_text(encoding="utf-8") +text = replace_once(r'^ version "[^"\r\n]+"$', f' version "{args.version}"', text, "version") +text = replace_once(r'^ sha256 "[^"\r\n]+"$', f' sha256 "{args.sha256.lower()}"', text, "sha256") +args.cask.write_text(text, encoding="utf-8", newline="") From d8e2af3d703ca84f414ed3cdba8ded231c03ca7f Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:34:43 -0700 Subject: [PATCH 4/5] docs(install): list Homebrew and upcoming Windows channels --- .gitignore | 1 + docs/dev/microsoft-store-release.md | 9 +++++++-- docs/getting-started/index.md | 31 ++++++++++++++++++++++------- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 18908acf..2da0f50a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Packaging output, plus the runtime binaries scripts/fetch-binaries.sh drops beside a build. /dist +.env pdfium.dll libpdfium.so libpdfium.dylib diff --git a/docs/dev/microsoft-store-release.md b/docs/dev/microsoft-store-release.md index a7f99d1b..60246a74 100644 --- a/docs/dev/microsoft-store-release.md +++ b/docs/dev/microsoft-store-release.md @@ -10,8 +10,9 @@ release; Microsoft signs it after certification. ## One-time Partner Center setup 1. Reserve qrate's name and create the app in Partner Center. Fill in the age ratings, listing and - privacy policy URL. The submission API cannot create the first app submission; use Partner - Center's UI for the first package submission after the workflow builds its MSIX artifact. + privacy policy URL, and provide at least one screenshot. The submission API cannot create the + first app submission; use Partner Center's UI for the first package submission after the workflow + builds its MSIX artifact. 2. Deploy the privacy policy update in the qrate-site checkout, then use . The current policy now describes the optional Pi/OpenRouter data flow and app update/catalog requests. @@ -45,6 +46,10 @@ not build or submit an MSIX. The submit job keeps Partner Center's existing publ metadata, and stops after Partner Center accepts the submission for processing. Certification and any manual publication step remain visible in Partner Center. +The installer branch must be merged before its release tag is pushed. The current published 0.6 +release is a prerelease, so it cannot produce the first Store MSIX under this workflow. Publish the +updated site privacy policy before entering its URL in the first Store submission. + ## Package behavior The Store package declares `windows-store` and `base` in `qrate-install.json`. It contains only the diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 56422045..890755b0 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -58,18 +58,35 @@ row's file, for example `IMG_0042.jpg`. qrate uses this column to link rows to f ## Install qrate -1. Go to [qrate releases](https://github.com/devnull03/qrate/releases). -2. Download the installer or the portable build for your platform. -3. Run the installer, or unpack the portable build. -4. Start qrate. +Download an installer or portable build for Windows, macOS, or Linux from +[qrate releases](https://github.com/devnull03/qrate/releases). Run the installer, or unpack the +portable build, then start qrate. + +On macOS, you can also install the base app from the [qrate Homebrew tap](https://github.com/devnull03/homebrew-tap): + +```sh +brew install --cask devnull03/tap/qrate +``` + +WinGet and the Microsoft Store are planned Windows install options. They will be available after +their first package submissions are accepted. Until then, use a Windows download from GitHub +Releases. Once the WinGet listing is live, install it with: + +```powershell +winget install --id devnull03.qrate --exact +``` + +The Homebrew Cask installs the base app. Optional components can be installed from within qrate. +The WinGet package uses the per-machine MSI. The builds do not have a Windows publisher signature or an Apple Developer ID signature and notarization. Windows SmartScreen can ask you to confirm. On macOS, try to open qrate once, then choose **Open Anyway** under System Settings → Privacy & Security. -Every release shows PDF previews. The Windows downloads also include ffmpeg, which qrate uses -for video previews. On macOS and Linux, install ffmpeg and put it on your `PATH` to see video -previews. Without it, qrate shows a file-type icon for a video, and everything else works. +The full downloads include PDF previews. Base downloads offer to install PDF support when you +need it. Windows and Linux can also install ffmpeg on first use, or carry it in the full download. +On macOS, install ffmpeg and put it on your `PATH` to see video previews. Without it, qrate shows +a file-type icon for a video, and everything else works. ## What you see first From fa636e864e16cb6d5927bbf3797c797afcb160de Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:10:40 -0700 Subject: [PATCH 5/5] fix(store): describe qrate in package manifest --- scripts/store/Package.appxmanifest | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/store/Package.appxmanifest b/scripts/store/Package.appxmanifest index 738e9dac..3b6bf7c7 100644 --- a/scripts/store/Package.appxmanifest +++ b/scripts/store/Package.appxmanifest @@ -10,7 +10,7 @@ qrate qrate-publisher - A research reference manager. + A desktop catalog for collections. Assets\StoreLogo.png @@ -24,7 +24,7 @@ -