Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions .github/workflows/package-managers.yml
Original file line number Diff line number Diff line change
@@ -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" }
64 changes: 59 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
3 changes: 3 additions & 0 deletions crates/app/src/update_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ fn initial_status(development_build: bool, kind: Option<InstallKind>) -> 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())
Expand Down
17 changes: 15 additions & 2 deletions crates/updater/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ pub enum InstallKind {
WindowsNsis,
WindowsPortable,
WindowsMsi,
WindowsStore,
MacosBundle,
LinuxTar,
}
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 5 additions & 7 deletions docs/dev/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
70 changes: 70 additions & 0 deletions docs/dev/microsoft-store-release.md
Original file line number Diff line number Diff line change
@@ -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
<https://qrate.dvnl.work/privacy>. 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.
Loading
Loading