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 206f5552..b457cc1e 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 @@ -591,8 +614,39 @@ 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. + 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/.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/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/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(); 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 new file mode 100644 index 00000000..60246a74 --- /dev/null +++ b/docs/dev/microsoft-store-release.md @@ -0,0 +1,70 @@ +# Microsoft Store release setup + +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 + +1. Reserve qrate's name and create the app in Partner Center. Fill in the age ratings, listing and + 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. +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. + +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 +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/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..890755b0 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -58,17 +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. -Releases are not signed yet. The first time you open qrate, Windows SmartScreen or macOS -Gatekeeper can ask you to confirm. +On macOS, you can also install the base app from the [qrate Homebrew tap](https://github.com/devnull03/homebrew-tap): -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. +```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. + +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 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..3b6bf7c7 --- /dev/null +++ b/scripts/store/Package.appxmanifest @@ -0,0 +1,45 @@ + + + + + qrate + qrate-publisher + A desktop catalog for collections. + 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." 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="")