diff --git a/.github/actions/cask-audit/action.yml b/.github/actions/cask-audit/action.yml new file mode 100644 index 000000000..27a768fef --- /dev/null +++ b/.github/actions/cask-audit/action.yml @@ -0,0 +1,68 @@ +name: Generate and audit the Homebrew cask +description: > + Generates the cask for a tag, styles it and audits it inside a throwaway tap. + Used both by the release job that publishes to VoltiusApp/homebrew-voltius and + by the packaging check that runs the stricter new-cask audit homebrew-cask + core applies to a submission. + +inputs: + tag: + description: Release tag to generate the cask for + required: true + core: + description: > + Generate the homebrew-cask core variant (drops the --no-quarantine hint, + which core rejects) instead of the tap variant. + required: false + default: 'false' + audit-args: + description: Extra flags for `brew audit`, e.g. --new for a first submission + required: false + default: '' + tap: + description: Throwaway tap name to audit inside + required: false + default: voltiusapp/audit + github-token: + description: > + Token for the audit's GitHub API calls. Without one it uses the + unauthenticated 60 req/hr limit and flakes ("API rate limit exceeded") on + back-to-back runs. + required: true + +outputs: + path: + description: Path of the generated cask file + value: out/Casks/voltius.rb + +runs: + using: composite + steps: + - name: Generate cask + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + TAG: ${{ inputs.tag }} + CORE: ${{ inputs.core }} + run: | + # The path matters: `brew style` only applies the cask cops when the + # file sits under a Casks/ directory. On a bare .rb it falls back to + # generic Ruby cops and fails on Sorbet sigils and frozen_string_literal. + mkdir -p out/Casks + variant="" + [ "$CORE" = "true" ] && variant="--core" + bash scripts/gen-homebrew-cask.sh "$TAG" $variant > out/Casks/voltius.rb + cat out/Casks/voltius.rb + + - name: Style and audit + shell: bash + env: + HOMEBREW_GITHUB_API_TOKEN: ${{ inputs.github-token }} + TAP: ${{ inputs.tap }} + AUDIT_ARGS: ${{ inputs.audit-args }} + run: | + brew style out/Casks/voltius.rb + brew tap-new "$TAP" --no-git + mkdir -p "$(brew --repository "$TAP")/Casks" + cp out/Casks/voltius.rb "$(brew --repository "$TAP")/Casks/voltius.rb" + brew audit --cask --online $AUDIT_ARGS "$TAP/voltius" diff --git a/.github/workflows/publish-installers.yml b/.github/workflows/publish-installers.yml index fc78c46cf..d1add3e3e 100644 --- a/.github/workflows/publish-installers.yml +++ b/.github/workflows/publish-installers.yml @@ -1,8 +1,15 @@ -# Publishes the Homebrew cask (tap VoltiusApp/homebrew-voltius) and the winget -# manifest (microsoft/winget-pkgs) from a published GitHub release. +# Publishes the Homebrew cask (tap VoltiusApp/homebrew-voltius), the winget +# manifest (microsoft/winget-pkgs) and the AUR package (voltius-bin) from a +# published GitHub release. +# +# Scoop is deliberately absent: its manifest carries checkver + autoupdate, so +# once merged into ScoopInstaller/Extras their excavator bot follows releases on +# its own. See scripts/gen-scoop-manifest.sh. # # Required repository secrets: # HOMEBREW_TAP_TOKEN PAT with push access to VoltiusApp/homebrew-voltius +# AUR_SSH_PRIVATE_KEY private key registered on the AUR account that owns +# voltius-bin. Absent: the AUR job warns and skips. # WINGET_PKGS_TOKEN classic PAT (public_repo + workflow) for komac to fork # winget-pkgs. Must be CLASSIC — winget-releaser does not # support fine-grained PATs, which cannot authorize a PR @@ -36,12 +43,13 @@ concurrency: cancel-in-progress: false jobs: - homebrew: - runs-on: macos-latest + # One place resolves the tag for every channel below. It used to be repeated + # per job, which meant each new channel copied the same three lines. + tag: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} steps: - - name: Checkout repository - uses: actions/checkout@v7 - - name: Resolve tag id: tag env: @@ -49,31 +57,24 @@ jobs: RELEASE_TAG: ${{ github.event.release.tag_name }} run: echo "tag=${DISPATCH_TAG:-$RELEASE_TAG}" >> "$GITHUB_OUTPUT" - - name: Generate cask - env: - GH_TOKEN: ${{ github.token }} - run: | - mkdir -p out/Casks - bash scripts/gen-homebrew-cask.sh "${{ steps.tag.outputs.tag }}" > out/Casks/voltius.rb - cat out/Casks/voltius.rb + homebrew: + needs: tag + runs-on: macos-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 - - name: Audit cask - # brew audit --online calls the GitHub API; without a token it uses the - # unauthenticated 60 req/hr limit and flakes ("API rate limit exceeded") - # on back-to-back runs. The default token lifts this to 5000/hr. - env: - HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - brew style out/Casks/voltius.rb - brew tap-new voltiusapp/voltius --no-git - mkdir -p "$(brew --repository voltiusapp/voltius)/Casks" - cp out/Casks/voltius.rb "$(brew --repository voltiusapp/voltius)/Casks/voltius.rb" - brew audit --cask --online voltiusapp/voltius/voltius + - name: Generate and audit the cask + uses: ./.github/actions/cask-audit + with: + tag: ${{ needs.tag.outputs.tag }} + tap: voltiusapp/voltius + github-token: ${{ secrets.GITHUB_TOKEN }} - name: Push cask to tap env: TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - TAG: ${{ steps.tag.outputs.tag }} + TAG: ${{ needs.tag.outputs.tag }} run: | git clone "https://x-access-token:${TAP_TOKEN}@github.com/VoltiusApp/homebrew-voltius" tap mkdir -p tap/Casks @@ -89,15 +90,9 @@ jobs: git push origin HEAD:main winget: + needs: tag runs-on: ubuntu-latest steps: - - name: Resolve tag - id: tag - env: - DISPATCH_TAG: ${{ inputs.tag }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: echo "tag=${DISPATCH_TAG:-$RELEASE_TAG}" >> "$GITHUB_OUTPUT" - # komac branches the fork from upstream, and once kipavy/winget-pkgs falls # behind microsoft/winget-pkgs it fails with "kipavy does not have the # correct permissions to execute `CreateRef`" — a misleading message that @@ -133,7 +128,7 @@ jobs: uses: vedantmgoyal9/winget-releaser@v2 with: identifier: Voltius.Voltius - release-tag: ${{ steps.tag.outputs.tag }} + release-tag: ${{ needs.tag.outputs.tag }} installers-regex: '_(x64|arm64)-setup\.exe$' token: ${{ secrets.WINGET_PKGS_TOKEN }} # The winget-pkgs fork lives under the token account (kipavy), not @@ -141,3 +136,74 @@ jobs: # KOMAC_FORK_OWNER to github.repository_owner (VoltiusApp) and fails # with "Could not resolve to a Repository VoltiusApp/winget-pkgs". fork-user: kipavy + + # Publishes the `voltius-bin` AUR package. The AUR is a plain git remote over + # ssh: pushing a commit that carries PKGBUILD + .SRCINFO IS the release. + # + # .SRCINFO must come from `makepkg --printsrcinfo`, and makepkg refuses to run + # as root, hence the throwaway user inside the arch container. The container + # is only used for that one command — the PKGBUILD itself is generated on the + # runner, where `gh` is already authenticated. + aur: + needs: tag + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Skip when the AUR key is not configured + id: guard + env: + KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: | + if [ -z "$KEY" ]; then + echo "::warning::AUR_SSH_PRIVATE_KEY is not set — skipping the AUR push." + echo "ok=false" >> "$GITHUB_OUTPUT" + else + echo "ok=true" >> "$GITHUB_OUTPUT" + fi + + - name: Generate PKGBUILD + if: steps.guard.outputs.ok == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p out/aur + bash scripts/gen-aur-pkgbuild.sh "${{ needs.tag.outputs.tag }}" > out/aur/PKGBUILD + cat out/aur/PKGBUILD + + - name: Generate .SRCINFO + if: steps.guard.outputs.ok == 'true' + run: | + docker run --rm -v "$PWD/out/aur:/w" archlinux:base-devel bash -euc ' + useradd -m b && chown -R b /w + su b -c "cd /w && makepkg --printsrcinfo > .SRCINFO" + ' + cat out/aur/.SRCINFO + + - name: Push to the AUR + if: steps.guard.outputs.ok == 'true' + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + TAG: ${{ needs.tag.outputs.tag }} + run: | + install -d -m 700 ~/.ssh + printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur + chmod 600 ~/.ssh/aur + ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null + export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o IdentitiesOnly=yes" + + git clone ssh://aur@aur.archlinux.org/voltius-bin.git aur + cp out/aur/PKGBUILD out/aur/.SRCINFO aur/ + cd aur + git config user.name 'voltius-bot' + git config user.email 'bot@voltius.app' + git add PKGBUILD .SRCINFO + # --cached, after `git add`: on the very first push the files are + # untracked, and an unstaged `git diff` would report no change and + # skip the push that creates the package. + if git diff --cached --quiet; then + echo "AUR package already up to date for ${TAG}"; exit 0 + fi + git commit -m "Update to ${TAG#v}" + git push origin HEAD:master diff --git a/.github/workflows/publish-msix.yml b/.github/workflows/publish-msix.yml new file mode 100644 index 000000000..074c1ff33 --- /dev/null +++ b/.github/workflows/publish-msix.yml @@ -0,0 +1,246 @@ +# Builds the Microsoft Store MSIX packages, bundles them, and publishes the +# bundle to the Store through the Microsoft Store Developer CLI. +# +# The MSIX path needs no code-signing certificate — the Store re-signs MSIX on +# submission. Submitting the NSIS .exe instead would require a certificate +# chaining to the Microsoft Trusted Root Program. +# +# Required repository secrets (all four, or the publish step warns and skips so +# a release can never fail on it): +# AZURE_AD_TENANT_ID Entra tenant associated with Partner Center +# AZURE_AD_APPLICATION_CLIENT_ID app registration with the Manager role +# AZURE_AD_APPLICATION_SECRET that registration's client secret +# SELLER_ID Partner Center publisher/seller id +# +# Required repository variables: +# MSSTORE_PRODUCT_ID Store product id of the listing +# MSIX_IDENTITY_NAME \ +# MSIX_PUBLISHER } assigned by Partner Center on reservation +# MSIX_PUBLISHER_DISPLAY_NAME / +# +# TWO THINGS THIS CANNOT DO: +# 1. Create the listing. The app must already be published and live in the +# Store before the CLI can update it, so the first submission is manual. +# 2. Update a paid product. Microsoft supports app updates through this action +# for free products only. Voltius is free in the Store (Pro is billed +# outside it), so this applies today but would break if that ever changed. +name: publish-msix + +on: + workflow_call: + inputs: + ref: + description: 'Ref to build (tag for a release run)' + required: true + type: string + publish: + # Defaults to false so a caller has to opt in. `github.event_name` cannot + # tell the two callers apart — inside a reusable workflow it is the + # CALLER's event, so a packaging check triggered by a push read as "not a + # workflow_dispatch, therefore publish", and would have submitted a + # branch build to the Store the moment the credentials were set. + description: 'Submit the bundle to the Store as well as building it' + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + ref: + description: 'Ref to build (branch, tag or SHA). Defaults to this branch.' + required: false + default: '' + publish: + description: 'Publish to the Store as well as building' + required: false + default: true + type: boolean + +concurrency: + # Keyed on the ref being BUILT, not on `github.ref`: inside a reusable workflow + # that is the caller's ref, and tag-release runs on a push to main, so a release + # and a packaging check both resolved to refs/heads/main and queued behind each + # other anyway. With a single shared group, three pushes in a row left the + # release path's builds cancelled as merely-pending members of the same group. + group: publish-msix-${{ inputs.ref || github.ref }} + cancel-in-progress: false + +jobs: + build: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + target: x86_64-pc-windows-msvc + - arch: arm64 + target: aarch64-pc-windows-msvc + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup node + uses: actions/setup-node@v7 + with: + node-version: lts/* + cache: 'pnpm' + + - name: Read toolchain channel from rust-toolchain.toml + id: rust + shell: bash + run: echo "channel=$(sed -nE 's/^channel[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ steps.rust.outputs.channel }} + targets: ${{ matrix.target }} + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + # The Cargo workspace root is the repository root, so the target dir is + # ./target. Pointing this at src-tauri/target cached nothing and made + # every MSIX build a cold half-hour Windows compile. + workspaces: '. -> target' + + - name: Install frontend dependencies + run: pnpm install + + - name: Install sccache + uses: mozilla/sccache-action@v0.0.11 + + # --no-bundle: the MSIX is packed from the raw binary, so none of the + # installer bundlers need to run. + - name: Build + env: + SCCACHE_GHA_ENABLED: "true" + RUSTC_WRAPPER: "sccache" + run: pnpm tauri build --target ${{ matrix.target }} --no-bundle + + - name: Pack MSIX + shell: pwsh + env: + MSIX_IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }} + MSIX_PUBLISHER: ${{ vars.MSIX_PUBLISHER }} + MSIX_PUBLISHER_DISPLAY_NAME: ${{ vars.MSIX_PUBLISHER_DISPLAY_NAME }} + run: | + # target/ sits at the workspace root, which is the repository root. + ./scripts/build-msix.ps1 ` + -Arch ${{ matrix.arch }} ` + -ExePath "target/${{ matrix.target }}/release/voltius.exe" + + - name: Upload MSIX + uses: actions/upload-artifact@v4 + with: + name: msix-${{ matrix.arch }} + path: target/msix/*.msix + if-no-files-found: error + + publish: + needs: build + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - name: Download the per-architecture packages + uses: actions/download-artifact@v4 + with: + pattern: msix-* + merge-multiple: true + path: target/msix + + - name: Bundle + shell: pwsh + run: ./scripts/bundle-msix.ps1 + + # The packaged version comes from tauri.conf.json, and that file only + # carries the released version at a release tag — on dev it lags behind, + # because the bump lands on main. A check running against a branch + # therefore produces a bundle labelled with a stale version, which must + # never reach Partner Center: Store versions only ever move forward, so a + # first submission at the wrong version cannot be taken back. + - name: Check the packaged version against the latest release + id: staleness + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + packaged=$(node -e "console.log(require('./src-tauri/tauri.conf.json').version)") + latest=$(gh release view -R "$GITHUB_REPOSITORY" --json tagName -q '.tagName' 2>/dev/null || echo "") + latest="${latest#v}" + echo "packaged: $packaged, latest release: ${latest:-unknown}" + stale=false + if [ -n "$latest" ] && [ "$packaged" != "$latest" ]; then + newest=$(printf '%s\n%s\n' "$packaged" "$latest" | sort -V | tail -1) + if [ "$newest" != "$packaged" ]; then + stale=true + echo "::warning::Packaged version $packaged trails the latest release $latest. This bundle is fine for checking that the package builds, but it will not be submitted — build from a release tag instead." + fi + fi + echo "stale=$stale" >> "$GITHUB_OUTPUT" + + - name: Upload bundle + uses: actions/upload-artifact@v4 + with: + name: msixbundle + path: target/msix/*.msixbundle + if-no-files-found: error + + - name: Decide whether to submit + id: guard + shell: bash + env: + TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} + CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} + SELLER_ID: ${{ secrets.SELLER_ID }} + PRODUCT_ID: ${{ vars.MSSTORE_PRODUCT_ID }} + WANTED: ${{ inputs.publish }} + STALE: ${{ steps.staleness.outputs.stale }} + run: | + if [ "$WANTED" != "true" ]; then + echo "::notice::publish input is false — built the bundle only." + echo "ok=false" >> "$GITHUB_OUTPUT" + elif [ "$STALE" = "true" ]; then + # Refusing is the whole point: a submission at a version below the + # latest release cannot be withdrawn, and the Store only ever moves + # versions forward. + echo "::error::Refusing to submit a bundle whose version trails the latest release." + echo "ok=false" >> "$GITHUB_OUTPUT" + elif [ -z "$TENANT_ID" ] || [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ] || [ -z "$SELLER_ID" ] || [ -z "$PRODUCT_ID" ]; then + echo "::warning::Store credentials or MSSTORE_PRODUCT_ID are not set — the bundle is attached as an artifact but was not submitted." + echo "ok=false" >> "$GITHUB_OUTPUT" + else + echo "ok=true" >> "$GITHUB_OUTPUT" + fi + + - name: Set up the Microsoft Store Developer CLI + if: steps.guard.outputs.ok == 'true' + uses: microsoft/microsoft-store-apppublisher@v1.1 + + - name: Configure Store credentials + if: steps.guard.outputs.ok == 'true' + shell: pwsh + run: | + msstore reconfigure ` + --tenantId ${{ secrets.AZURE_AD_TENANT_ID }} ` + --sellerId ${{ secrets.SELLER_ID }} ` + --clientId ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} ` + --clientSecret ${{ secrets.AZURE_AD_APPLICATION_SECRET }} + + - name: Publish to the Store + if: steps.guard.outputs.ok == 'true' + shell: pwsh + run: | + $bundle = (Get-ChildItem target/msix/*.msixbundle | Select-Object -First 1).FullName + msstore publish $bundle -id ${{ vars.MSSTORE_PRODUCT_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 907204f10..f9d6eca8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,7 +177,7 @@ jobs: uses: swatinem/rust-cache@v2 with: key: ${{ matrix.target }} - workspaces: 'src-tauri -> target' + workspaces: '. -> target' - name: Install dependencies (Ubuntu native) if: matrix.platform == 'ubuntu-24.04' || matrix.platform == 'ubuntu-24.04-arm' @@ -276,7 +276,7 @@ jobs: uses: swatinem/rust-cache@v2 with: key: aarch64-linux-android - workspaces: 'src-tauri -> target' + workspaces: '. -> target' - name: Install frontend dependencies run: pnpm install diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 39cad23b8..b3c6eaea6 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -123,9 +123,10 @@ jobs: echo "::warning::Release $TAG is not published (publish-release said '$REPORTED', isDraft=$draft) — skipping the distribution channels." fi - # Publish Homebrew cask + winget manifest once the release and its assets - # exist. Called explicitly here rather than via `release: published`, which - # never fires for a GITHUB_TOKEN-authored release (see publish-installers.yml). + # Publish Homebrew cask + winget manifest + AUR package once the release and + # its assets exist. Called explicitly here rather than via `release: + # published`, which never fires for a GITHUB_TOKEN-authored release (see + # publish-installers.yml). publish-installers: needs: [tag, release-published] if: ${{ !cancelled() && needs.release-published.outputs.ok == 'true' }} @@ -143,3 +144,22 @@ jobs: if: ${{ !cancelled() && needs.release-published.outputs.ok == 'true' }} uses: ./.github/workflows/publish-repo.yml secrets: inherit + + # Build and submit the Microsoft Store bundle. This one rebuilds the Windows + # binaries rather than reusing the release assets: the release ships NSIS/MSI + # installers, and an MSIX is packed from the bare .exe, which is not published. + # + # It is gated on the release only so that a version bump and a Store + # submission always describe the same tag; it uploads nothing to the release. + # The submission step skips with a warning when the Store credentials are + # absent, so this can never fail a release. + publish-msix: + needs: [tag, release-published] + if: ${{ !cancelled() && needs.release-published.outputs.ok == 'true' }} + uses: ./.github/workflows/publish-msix.yml + with: + ref: ${{ needs.tag.outputs.tag }} + # The one caller allowed to submit. It builds from the release tag, so the + # packaged version is the released version. + publish: true + secrets: inherit diff --git a/.github/workflows/verify-packaging.yml b/.github/workflows/verify-packaging.yml new file mode 100644 index 000000000..c45fad7ba --- /dev/null +++ b/.github/workflows/verify-packaging.yml @@ -0,0 +1,118 @@ +# Exercises the package manifests that nothing else can check. +# +# The generators are pure text and easy to eyeball; what is NOT easy is knowing +# whether a Scoop manifest actually installs, or whether a cask passes the audit +# homebrew-cask core runs on a new submission. Both need an OS this project is +# not built on, and both are cheap on a hosted runner. +# +# Runs on changes to the packaging inputs, so a manifest cannot rot silently +# between releases. +name: verify-packaging + +on: + push: + paths: + - 'packaging/**' + - 'scripts/gen-*.sh' + - 'scripts/*msix*' + - 'scripts/lib/**' + - '.github/workflows/verify-packaging.yml' + workflow_dispatch: + inputs: + tag: + description: 'Release tag whose assets the manifests point at' + required: false + default: '' + +concurrency: + # A push while a check is running supersedes it. The MSIX job is a full + # Windows Rust build, so leaving stale ones running is expensive. + group: verify-packaging-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Proves the manifest installs, puts a working binary on disk, and uninstalls + # cleanly. The install and uninstall scripts drive the NSIS installer with /S + # and assume Tauri's per-user layout, which is exactly the assumption that + # cannot be checked by reading it. + scoop: + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + # The manifests are generated against a published release, not against this + # commit — they reference release assets by sha256. Resolved rather than + # pinned: a stale pin would keep validating an installer nobody ships, and + # would miss the asset renaming this job exists to catch. + - name: Resolve the release tag + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INPUT_TAG: ${{ inputs.tag }} + run: | + tag="${INPUT_TAG:-$(gh release view -R "$GITHUB_REPOSITORY" --json tagName -q '.tagName')}" + [ -n "$tag" ] || { echo "no published release to validate against" >&2; exit 1; } + echo "validating against $tag" + echo "TAG=$tag" >> "$GITHUB_ENV" + + - name: Generate the manifest + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: bash scripts/gen-scoop-manifest.sh "$TAG" > voltius.json + + - name: Install Scoop + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Invoke-RestMethod -Uri https://get.scoop.sh -OutFile install-scoop.ps1 + # The runner user is an administrator, which the installer refuses + # without this switch. + ./install-scoop.ps1 -RunAsAdmin + "$env:USERPROFILE\scoop\shims" | Out-File -FilePath $env:GITHUB_PATH -Append + + - name: Install from the manifest + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + scoop install .\voltius.json + $exe = "$env:LOCALAPPDATA\Voltius\voltius.exe" + if (-not (Test-Path $exe)) { + throw "installer ran but $exe is missing — the /S install or the install path assumption is wrong" + } + Write-Host "installed $((Get-Item $exe).Length) bytes to $exe" + + - name: Uninstall + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + scoop uninstall voltius + $exe = "$env:LOCALAPPDATA\Voltius\voltius.exe" + if (Test-Path $exe) { + throw "uninstall left $exe behind — the uninstaller lookup is wrong" + } + Write-Host "uninstalled cleanly" + + # There is deliberately no homebrew-cask core job. `brew audit --cask --new` + # runs a signature scan that Voltius cannot pass while it is ad-hoc signed + # rather than notarized: + # + # Signature verification failed: Scan completed, but failed because the + # software is not signed by a distributor that meets the system Gatekeeper + # requirements. + # + # That is a hard gate, not a style nit, so a core submission is blocked until + # there is an Apple Developer account to notarize with. `brew style` and the + # rest of the audit do pass — see gen-homebrew-cask.sh --core, kept for the + # day that changes. The tap keeps being audited by publish-installers.yml. + + # Proves the MSIX packs at all: it needs Windows plus the Windows SDK, so + # nothing else in this repo can. This is a check, never a submission: it builds + # from a branch, where tauri.conf.json still carries the previous version. + msix: + uses: ./.github/workflows/publish-msix.yml + with: + ref: ${{ github.sha }} + publish: false + secrets: inherit diff --git a/.gitignore b/.gitignore index dc0dd2287..f0c7c1e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ packages/plugin-types/index.d.ts # This project uses pnpm; an npm lockfile would silently diverge from CI. package-lock.json + +# flatpak-builder output from a local test build of packaging/flatpak. Written +# by a container running as root, so it is also awkward to clean up by accident. +packaging/flatpak/build-dir/ +packaging/flatpak/.flatpak-builder/ diff --git a/README.md b/README.md index 5fef1e012..ad90879da 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@

Voltius

-

📢 Latest Update: Huge thanks for the 200+ stars in 24h!

A local-first SSH/SFTP/Serial client with E2EE sync, plugins, and no account required — a modern alternative to Termius.

+ Latest release + Stars Beta Rust Tauri @@ -36,7 +37,7 @@ No account required. Everything below is free, forever. - **Process Manager** — View and kill processes on connected hosts. - **System Monitoring** — Live CPU, memory, and disk stats from connected hosts. -> Full feature list at [docs.voltius.app](https://docs.voltius.app) *(coming soon)* · **Pro · Teams · Business** — see [voltius.app/#pricing](https://voltius.app/#pricing) for paid plans. +> Full feature list at [docs.voltius.app](https://docs.voltius.app) · **Pro · Teams · Business** — see [voltius.app/#pricing](https://voltius.app/#pricing) for paid plans. ## 📸 Screenshots @@ -122,37 +123,39 @@ Direct installers (`.dmg`, `.msi`, `.exe`, `.AppImage`) are on [voltius.app/download](https://voltius.app/download). Voltius updates itself in-app on macOS and Windows after installation. -## ⚖️ Comparison (WIP) +## ⚖️ Comparison + +✅ yes · ❌ no · 🟡 partial or paid-tier · ? not tested | Feature | Voltius | Termius | [Reach](https://github.com/alexandrosnt/Reach) | [Termix](https://github.com/Termix-SSH/Termix) | Tabby | | --- | --- | --- | --- | --- | --- | | **Engine** | **Rust + Tauri** 🦀 | likely Electron (closed-source) | **Rust + Tauri** 🦀 | Web (React + Node.js) | Electron / Node.js | -| **RAM Usage** | ~300MB | ~500MB+ | ~300MB | NOT TESTED | NOT TESTED | -| **Installed Size** | ~40MB | ~1GB | ~40MB | NOT TESTED | NOT TESTED | +| **RAM Usage** | ~300MB | ~500MB+ | ~300MB | ? | ? | +| **Installed Size** | ~40MB | ~1GB | ~40MB | ? | ? | | **Cloud Sync** | Gist (Free) / Real-Time (Paid) | 🟡 Only Pro | 🟡 Via Turso (own account) | ❌ | Community Plugins | -| **Import/Export** | ✅ 1-click import from Termius/MobaXterm, JSON Export | 🟡 Strong Import Integrations but no Export | ✅ | | | +| **Import/Export** | ✅ 1-click import from Termius/MobaXterm, JSON Export | 🟡 Strong Import Integrations but no Export | ✅ | ? | ? | | **Port Forwarding** | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Snippets** | ✅ + multi-exec | 🟡 (Multi-exec + startup snippets only Pro) | ✅ + multi-exec | ✅ + multi-exec | | -| **Command Palette** | ✅ | ✅ | | | ✅ | +| **Snippets** | ✅ + multi-exec | 🟡 (Multi-exec + startup snippets only Pro) | ✅ + multi-exec | ✅ + multi-exec | ? | +| **Command Palette** | ✅ | ✅ | ? | ? | ✅ | | **Split panes** | ✅ | ✅ | ✅ | ✅ | ✅ | -| **X11 Forwarding** | ❌ | | ❌ | | ✅ | -| **MCP server (AI agents)** | ✅ Built in, 52 tools, off by default | | | | | -| **Docker Integration** | ✅ | | | | 🟡 (community plugin) | +| **X11 Forwarding** | ❌ | ? | ❌ | ? | ✅ | +| **MCP server (AI agents)** | ✅ Built in, 52 tools, off by default | ? | ? | ? | ? | +| **Docker Integration** | ✅ | ? | ? | ? | 🟡 (community plugin) | | **Proxmox LXC Integration** | ✅ | ❌ | ❌ | ❌ | ❌ | -| **System Monitoring** | ✅ | | ✅ | ✅ | | -| **Jump Hosts** | ✅ | ✅ | ✅ | | ✅ | -| **Team vaults** | ✅ Teams or self-hosted | ✅ Teams plan | ✅ Free but complex | | | -| **Audit logs** | ✅ | 🟡 Teams plan | | | | -| **Custom Themes** | ✅ | | | ✅ | ✅ | -| **Folders & Tags** | ✅ | ✅ | ✅ | ✅ | | -| **Auto-Updates** | ✅ | ✅ | ✅ | | | +| **System Monitoring** | ✅ | ? | ✅ | ✅ | ? | +| **Jump Hosts** | ✅ | ✅ | ✅ | ? | ✅ | +| **Team vaults** | ✅ Teams or self-hosted | ✅ Teams plan | ✅ Free but complex | ? | ? | +| **Audit logs** | ✅ | 🟡 Teams plan | ? | ? | ? | +| **Custom Themes** | ✅ | ? | ? | ✅ | ✅ | +| **Folders & Tags** | ✅ | ✅ | ✅ | ✅ | ? | +| **Auto-Updates** | ✅ | ✅ | ✅ | ? | ? | | **Modern UI/UX** | ✅ | ✅ | 🟡 | ✅ | 🟡 | -| **AI assistant** | ❌ | ✅ | ✅ | | | -| **Permissions** | ✅ Teams RBAC / Business custom roles | ✅ Granular perms | | | | -| **Terminal sharing** | ✅ Pro (1 guest) / Teams (unlimited) | ✅ needs Teams plan | | | | -| **Security** | **End-to-End Encrypted** | Proprietary E2EE | **End-to-End Encrypted** | | Local Only / Manual | -| **SFTP host<->host** | ✅ | ✅ | ❌ | | ❌ | -| **Serial Console** | ✅ | ✅ | ✅ | | ✅ | +| **AI assistant** | ❌ | ✅ | ✅ | ? | ? | +| **Permissions** | ✅ Teams RBAC / Business custom roles | ✅ Granular perms | ? | ? | ? | +| **Terminal sharing** | ✅ Pro (1 guest) / Teams (unlimited) | ✅ needs Teams plan | ? | ? | ? | +| **Security** | **End-to-End Encrypted** | Proprietary E2EE | **End-to-End Encrypted** | ? | Local Only / Manual | +| **SFTP host<->host** | ✅ | ✅ | ❌ | ? | ❌ | +| **Serial Console** | ✅ | ✅ | ✅ | ? | ✅ | | **Persistent sessions** | ✅ uses tmux/screen, default behavior | 🟡 (via Mosh, must be installed on the host; not built-in) | ❌ | ❌ | ❌ | | **Cross-device live resume** | ✅ Seamless pickup | ❌ | ❌ | ❌ | ❌ | | **Local-first** | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 000000000..668ca4efd --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,186 @@ +# Packaging channels + +Where Voltius is published, and what each channel needs from a human. + +Automated channels live in `.github/workflows/publish-installers.yml`, which +`tag-release.yml` calls once a release is published. Their generators are in +`scripts/`, sharing `scripts/lib/release-assets.sh` for the "read the sha256 of +a named release asset" step every channel needs. + +| Channel | Manifest source | Kept current by | +| --- | --- | --- | +| Homebrew tap | `scripts/gen-homebrew-cask.sh` | `publish-installers.yml` on every release | +| winget | komac | `publish-installers.yml` on every release | +| AUR (`voltius-bin`) | `scripts/gen-aur-pkgbuild.sh` | `publish-installers.yml` on every release | +| apt / yum | `scripts/build-apt-repo.sh`, `scripts/build-yum-repo.sh` | `publish-repo.yml` on every release | +| Microsoft Store | `packaging/msix/`, `scripts/build-msix.ps1` | `publish-msix.yml` on every release | +| Scoop | `scripts/gen-scoop-manifest.sh` | Scoop's excavator bot, after the first merge | +| Flathub | `scripts/gen-flatpak-manifest.sh` | Flathub's external-data-checker, after the first merge | + +Scoop and Flathub have no job here on purpose: their manifests carry `checkver` +and `x-checker-data`, so each ecosystem's own bot follows new releases once the +manifest is merged. For Flathub that bot opens a PR against +`flathub/app.voltius.Voltius`; add `{"automerge-flathubbot-prs": true}` to +`flathub.json` in that repo to let it land unattended. + +## One-time setup, per channel + +### AUR + +The release job pushes to `ssh://aur@aur.archlinux.org/voltius-bin.git`, which +requires: + +1. An AUR account with an SSH public key registered on it. +2. The matching private key stored as the `AUR_SSH_PRIVATE_KEY` repository + secret. Without it the `aur` job warns and skips, so releases keep working. +3. The first push creates the package — the AUR has no separate "submit" step. + +Verify a PKGBUILD locally before the first push: + +```bash +bash scripts/gen-aur-pkgbuild.sh v0.27.0 > /tmp/aur/PKGBUILD +docker run --rm -v /tmp/aur:/w archlinux:base-devel bash -euc ' + useradd -m b && chown -R b /w + su b -c "cd /w && makepkg --printsrcinfo > .SRCINFO && makepkg -f --nodeps --noconfirm" +' +``` + +### Scoop + +One PR adding the generated manifest to +[`ScoopInstaller/Extras`](https://github.com/ScoopInstaller/Extras) as +`bucket/voltius.json`: + +```bash +bash scripts/gen-scoop-manifest.sh v0.27.0 +``` + +The manifest carries `checkver` + `autoupdate`, so Scoop's excavator bot follows +releases afterwards and nothing here has to run again. + +The install and uninstall scripts drive the NSIS installer with `/S` and assume +Tauri's per-user install location (`%LOCALAPPDATA%\Voltius`). **Test both on a +real Windows machine before opening the PR** — nothing in CI exercises them. + +### Flathub + +`packaging/flatpak/` holds the three files a submission needs. Regenerate the +manifest with `scripts/gen-flatpak-manifest.sh ` if the release asset +naming changes; otherwise Flathub's bot keeps it current. + +The bot updates the source `url` and `sha256`, and only those. Add a `` +entry to `app.voltius.Voltius.metainfo.xml` per release and open a PR for it — +that block is what the Flathub page shows as the current version, so without it +the page advertises an old version while serving the new binary. + +Validate before submitting: + +```bash +docker run --rm -v "$PWD/packaging/flatpak:/w" -w /w \ + ghcr.io/flathub-infra/flatpak-builder-lint:latest manifest app.voltius.Voltius.yml +``` + +Three linter errors are expected and each needs a written justification in the +submission PR, which Flathub records as an exception: + +- `finish-args-home-filesystem-access` — SFTP has to reach the local side of a + transfer, and `~/.ssh` is where the keys users import already live. +- `finish-args-has-socket-ssh-auth` — agent forwarding, so the passphrase is not + re-asked on every connection. +- `finish-args-flatpak-spawn-access` — the local-terminal feature opens the + user's real shell with their real toolchain. Without host spawn it silently + becomes a shell inside the sandbox. Same permission, for the same reason, that + the terminal emulators already on Flathub carry. + +The app id is `app.voltius.Voltius`, matching the `voltius.app` domain rather +than the Tauri identifier (`com.voltius.app`), because Flathub verification +checks the id against a domain the publisher controls. + +The in-app updater needs no change: a Flatpak install has no `APPIMAGE` +environment variable, so `classify_install` already reports it as +externally-updated and the app tells the user to update through their package +manager instead of writing to the read-only `/app`. + +### Microsoft Store + +Registration is free for both individual and company accounts. The MSIX path +needs **no code-signing certificate** — the Store re-signs MSIX on submission. +(Submitting the NSIS `.exe` instead would require a certificate chaining to the +Microsoft Trusted Root Program, which is the recurring cost this avoids.) + +Releases submit themselves through `publish-msix.yml`, but the listing has to +exist first — the Store CLI can only *update* an app that is already live. + +**Build the first submission from a release tag, not from a branch.** The MSIX +version comes from `tauri.conf.json`, and that file only carries the released +version at a tag — on `dev` it lags, because the bump lands on `main`. A bundle +built from a branch is therefore labelled with a stale version. Store versions +only ever move forward, so a first submission at the wrong version cannot be +taken back. `publish-msix` refuses to submit a bundle whose version trails the +latest release, and only the release job passes `publish: true` — the packaging +check builds the bundle and stops there. + +One-time: + +1. Reserve the app name in Partner Center. +2. Copy the assigned identity values into repository variables: + `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER`, `MSIX_PUBLISHER_DISPLAY_NAME`. + Partner Center rejects a package whose identity does not match the + reservation exactly. +3. Run `publish-msix` manually with `publish: false`, download the `msixbundle` + artifact, and complete the first submission by hand in Partner Center. +4. Once that submission is live, wire up the automation: + - Associate a Microsoft Entra tenant with the Partner Center account. + - Register an Entra application and give it the **Manager** role under + Account settings → User management → Microsoft Entra applications. + - Add the secrets `AZURE_AD_TENANT_ID`, `AZURE_AD_APPLICATION_CLIENT_ID`, + `AZURE_AD_APPLICATION_SECRET`, `SELLER_ID`, and the repository variable + `MSSTORE_PRODUCT_ID`. + +After that every release builds both architectures, bundles them into one +`.msixbundle` and submits it. Until the secrets exist the submission step warns +and skips, so a release cannot fail on it. + +Every image the listing form asks for is in `packaging/msix/store-listing`, +numbered in upload order, with the captions to go with them and the rules they +satisfy. Refresh the screenshots from the docs repo with +`scripts/copy-store-screenshots.sh`. + +Two limits worth knowing: + +- The CLI cannot create a listing, only update one. That is why step 3 is + manual and cannot be automated away. +- Microsoft supports app updates through this action for **free products only**. + Voltius is free in the Store — Pro is billed outside it — so this holds today, + but adding a paid Store product would break the automation. + +The packages are unsigned by design (the Store signs them), so they are workflow +artifacts rather than release assets — a user who downloaded one could not +install it. + +`classify_install` treats an install under `C:\Program Files\WindowsApps` as +externally-updated, so the Store build does not try to self-update into a tree +it cannot write. + +### Homebrew + +`VoltiusApp/homebrew-voltius` is a third-party tap: `brew search voltius` finds +nothing until the user has tapped it. A submission to homebrew-cask core is what +would make it discoverable, and the notability bar (75 stars) is cleared. + +**Core is blocked on notarization.** `brew audit --cask --new`, the gate a +submission has to pass, runs a signature scan and fails: + +``` +Signature verification failed: Scan completed, but failed because the software +is not signed by a distributor that meets the system Gatekeeper requirements. +``` + +Voltius is ad-hoc signed, not notarized, so this cannot pass without an Apple +Developer account. `brew style` and the rest of the audit are clean, so this is +the only thing standing in the way. + +`scripts/gen-homebrew-cask.sh --core` emits the variant for that PR, kept +ready for the day notarization exists: core rejects casks whose caveats tell the +user to reinstall with `--no-quarantine`, so that hint is dropped, leaving the +right-click-Open instructions. diff --git a/packaging/flatpak/app.voltius.Voltius.desktop b/packaging/flatpak/app.voltius.Voltius.desktop new file mode 100644 index 000000000..6e4f1a959 --- /dev/null +++ b/packaging/flatpak/app.voltius.Voltius.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Voltius +GenericName=SSH Client +Comment=Local-first SSH, SFTP and serial client +Exec=voltius %u +Icon=app.voltius.Voltius +Terminal=false +Categories=Network;RemoteAccess; +Keywords=ssh;sftp;terminal;serial;console;remote;sysadmin; +StartupWMClass=voltius +MimeType=x-scheme-handler/voltius; diff --git a/packaging/flatpak/app.voltius.Voltius.metainfo.xml b/packaging/flatpak/app.voltius.Voltius.metainfo.xml new file mode 100644 index 000000000..896982a78 --- /dev/null +++ b/packaging/flatpak/app.voltius.Voltius.metainfo.xml @@ -0,0 +1,101 @@ + + + app.voltius.Voltius + + Voltius +

Local-first SSH, SFTP and serial client + + CC0-1.0 + AGPL-3.0-or-later + + + Killian Pavy + + + +

+ Voltius is an SSH, SFTP and serial console client that keeps your fleet on your + own machine. Hosts, keys and passwords are encrypted locally before they touch + a disk or a network, and no account is required to use it. +

+

Features:

+ +
+ + app.voltius.Voltius.desktop + + https://voltius.app + https://github.com/VoltiusApp/voltius/issues + https://docs.voltius.app + https://github.com/VoltiusApp/voltius + + + + https://raw.githubusercontent.com/VoltiusApp/voltius/main/.github/media/panes-grid.png + Split terminal panes in a grid + + + https://raw.githubusercontent.com/VoltiusApp/voltius/main/.github/media/sftp-dual-pane.png + Dual-pane SFTP file manager + + + https://raw.githubusercontent.com/VoltiusApp/voltius/main/.github/media/command-palette.png + Command palette open over the workspace + + + https://raw.githubusercontent.com/VoltiusApp/voltius/main/.github/media/folders-tags.png + Hosts organized into folders with tags + + + https://raw.githubusercontent.com/VoltiusApp/voltius/main/.github/media/teams-roles.png + Team vault members and role permissions + + + https://raw.githubusercontent.com/VoltiusApp/voltius/main/.github/media/themes-creator.png + Theme editor with color groups and terminal palette + + + + + Development + Network + + + + ssh + sftp + terminal + serial + console + remote + sysadmin + + + + pointing + keyboard + + + + 768 + + + + + + + https://github.com/VoltiusApp/voltius/releases/tag/v0.27.0 + + + diff --git a/packaging/flatpak/app.voltius.Voltius.yml b/packaging/flatpak/app.voltius.Voltius.yml new file mode 100644 index 000000000..950e11940 --- /dev/null +++ b/packaging/flatpak/app.voltius.Voltius.yml @@ -0,0 +1,89 @@ +# Generated by scripts/gen-flatpak-manifest.sh in VoltiusApp/voltius. +# Once merged into Flathub, x-checker-data keeps it current — regenerate only if +# the release asset naming changes. +app-id: app.voltius.Voltius +runtime: org.gnome.Platform +runtime-version: '50' +sdk: org.gnome.Sdk +command: voltius +separate-locales: false + +finish-args: + # WebKitGTK rendering + - --share=ipc + - --socket=wayland + - --socket=fallback-x11 + - --device=dri + + # SSH, SFTP, sync relay + - --share=network + + # Agent forwarding: reach the host's ssh-agent instead of asking for the + # passphrase on every connection. + - --socket=ssh-auth + + # SFTP transfers need the local side of the copy, and ~/.ssh is where users + # already keep the keys they want to import. + - --filesystem=home + + # Credentials go to the Secret Service (gnome-keyring, KWallet) when the user + # picks the OS-keychain tier rather than a master password. + - --talk-name=org.freedesktop.secrets + + # Serial console: /dev/ttyUSB*, /dev/ttyACM*. Flatpak has no narrower way to + # expose a serial port than --device=all. + - --device=all + + # The local-terminal feature opens the user's real shell (bash, zsh, fish, + # ...) with their real toolchain. Without host spawn that feature silently + # becomes "a shell inside the sandbox", which is not what it says it is. Same + # permission the terminal emulators on Flathub carry for the same reason. + - --talk-name=org.freedesktop.Flatpak + +modules: + - name: voltius + buildsystem: simple + build-commands: + - ar x voltius.deb + # Glob rather than data.tar.gz: the payload compression is dpkg-deb's + # choice, not ours. + - tar -xf data.tar.* + - install -Dm755 usr/bin/voltius /app/bin/voltius + # The icon basename has to match the app-id. Tauri also emits a + # "256x256@2" directory, which is not a hicolor size (hicolor spells it + # "@2x"), so it is skipped rather than installed under a bad name. + - install -Dm644 usr/share/icons/hicolor/32x32/apps/voltius.png + /app/share/icons/hicolor/32x32/apps/app.voltius.Voltius.png + - install -Dm644 usr/share/icons/hicolor/128x128/apps/voltius.png + /app/share/icons/hicolor/128x128/apps/app.voltius.Voltius.png + - install -Dm644 app.voltius.Voltius.desktop + /app/share/applications/app.voltius.Voltius.desktop + - install -Dm644 app.voltius.Voltius.metainfo.xml + /app/share/metainfo/app.voltius.Voltius.metainfo.xml + sources: + - type: file + only-arches: [x86_64] + url: https://github.com/VoltiusApp/voltius/releases/download/v0.27.0/Voltius_0.27.0_amd64.deb + sha256: 4bf6c15bc8f89d0375dd5adedf73ad5467fd4432c60f952b7a909fe6bd695f8e + dest-filename: voltius.deb + x-checker-data: + type: json + url: https://api.github.com/repos/VoltiusApp/voltius/releases/latest + version-query: .tag_name | sub("^v"; "") + url-query: '"https://github.com/VoltiusApp/voltius/releases/download/v" + $version + + "/Voltius_" + $version + "_amd64.deb"' + - type: file + only-arches: [aarch64] + url: https://github.com/VoltiusApp/voltius/releases/download/v0.27.0/Voltius_0.27.0_arm64.deb + sha256: 4a6dc1eba337796c70fd5391cddcb21c7f1ba9cd52517d1a1868e44d617d2211 + dest-filename: voltius.deb + x-checker-data: + type: json + url: https://api.github.com/repos/VoltiusApp/voltius/releases/latest + version-query: .tag_name | sub("^v"; "") + url-query: '"https://github.com/VoltiusApp/voltius/releases/download/v" + $version + + "/Voltius_" + $version + "_arm64.deb"' + - type: file + path: app.voltius.Voltius.desktop + - type: file + path: app.voltius.Voltius.metainfo.xml diff --git a/packaging/msix/AppxManifest.xml b/packaging/msix/AppxManifest.xml new file mode 100644 index 000000000..50e7c7b3a --- /dev/null +++ b/packaging/msix/AppxManifest.xml @@ -0,0 +1,77 @@ + + + + + + + + Voltius + {{PUBLISHER_DISPLAY_NAME}} + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + Voltius + + + + + + + + + + + diff --git a/packaging/msix/store-listing/01-sftp-dual-pane.png b/packaging/msix/store-listing/01-sftp-dual-pane.png new file mode 100644 index 000000000..a763b2b09 Binary files /dev/null and b/packaging/msix/store-listing/01-sftp-dual-pane.png differ diff --git a/packaging/msix/store-listing/02-folders-tags.png b/packaging/msix/store-listing/02-folders-tags.png new file mode 100644 index 000000000..93aaeeb9d Binary files /dev/null and b/packaging/msix/store-listing/02-folders-tags.png differ diff --git a/packaging/msix/store-listing/03-panes-grid.png b/packaging/msix/store-listing/03-panes-grid.png new file mode 100644 index 000000000..0bf894e64 Binary files /dev/null and b/packaging/msix/store-listing/03-panes-grid.png differ diff --git a/packaging/msix/store-listing/04-command-palette.png b/packaging/msix/store-listing/04-command-palette.png new file mode 100644 index 000000000..b144704cf Binary files /dev/null and b/packaging/msix/store-listing/04-command-palette.png differ diff --git a/packaging/msix/store-listing/05-teams-roles.png b/packaging/msix/store-listing/05-teams-roles.png new file mode 100644 index 000000000..cd14e7301 Binary files /dev/null and b/packaging/msix/store-listing/05-teams-roles.png differ diff --git a/packaging/msix/store-listing/06-themes-creator.png b/packaging/msix/store-listing/06-themes-creator.png new file mode 100644 index 000000000..e170b67e5 Binary files /dev/null and b/packaging/msix/store-listing/06-themes-creator.png differ diff --git a/packaging/msix/store-listing/README.md b/packaging/msix/store-listing/README.md new file mode 100644 index 000000000..66c105119 --- /dev/null +++ b/packaging/msix/store-listing/README.md @@ -0,0 +1,59 @@ +# Microsoft Store listing assets + +Everything the Partner Center listing form asks for, in one place. Upload the +screenshots in filename order — the Store shows them in the order they are +listed, and the first one is what a browsing customer sees. + +Regenerate the screenshots with `scripts/copy-store-screenshots.sh`; they are +copies of captures that live in the docs repo. + +| File | Field | Caption to use | +| --- | --- | --- | +| `01-sftp-dual-pane.png` | Screenshot 1 | Dual-pane SFTP: drag and drop between local and remote | +| `02-folders-tags.png` | Screenshot 2 | Folders and tags keep a growing fleet navigable | +| `03-panes-grid.png` | Screenshot 3 | Split panes, with input broadcast to every pane | +| `04-command-palette.png` | Screenshot 4 | Jump to any host, session or snippet from one palette | +| `05-teams-roles.png` | Screenshot 5 | Shared vaults with roles and an audit log | +| `06-themes-creator.png` | Screenshot 6 | Theme editor for window colors and the terminal palette | +| `StorePoster1440x2160.png` | Store logos → 2:3 poster art | — | +| `StorePoster720x1080.png` | same field, smaller accepted size | — | +| `StoreLogo300x300.png` | Store logos → 1:1 App tile icon | — | + +Regenerate the two logos with `scripts/make-store-poster.sh`. + +## Rules these already satisfy + +- Desktop screenshots must be PNG and **1366x768 or larger** — not one of two + exact sizes. These are 1600x1148 (1600x1045 for the palette), so they qualify + unchanged. +- Up to 10 screenshots, four recommended. +- Under 50 MB each. +- Nothing important sits in the bottom third, where the Store draws its own text + overlays. +- No logos or marketing copy pasted onto the captures, which the Store rejects. + +## Known blemish + +`03-panes-grid.png` shows `tmux/screen not found - session will not survive +disconnects` in three panes, because the host used for the capture had neither +installed. It is honest and small, but it is a warning about a missing feature +sitting in a shop window. Worth recapturing against a host that has tmux, at +which point the pane also demonstrates the persistent-sessions feature instead +of contradicting it. + +## The poster is not optional, whatever the docs say + +Microsoft's documentation states that 2:3 poster art "does not apply to apps" +and is for games. The Partner Center form asks for it anyway, at **exactly** +720x1080 or 1440x2160 — no "or larger" here, unlike the screenshots. The form +wins; both sizes are provided. + +The bolt sits in the top two-thirds and the bottom third is left empty, because +the Store draws its own text over that band. There is no wordmark on the image: +the docs allow one, but a rendered-in font would not match the brand and the +mark is distinctive on its own. + +## Not needed + +1:1 box art is for games. 16:9 hero art (1920x1080) is optional and must carry +no text and not show the app's UI — a marketing image rather than a screenshot. diff --git a/packaging/msix/store-listing/StoreLogo300x300.png b/packaging/msix/store-listing/StoreLogo300x300.png new file mode 100644 index 000000000..54108cdf5 Binary files /dev/null and b/packaging/msix/store-listing/StoreLogo300x300.png differ diff --git a/packaging/msix/store-listing/StorePoster1440x2160.png b/packaging/msix/store-listing/StorePoster1440x2160.png new file mode 100644 index 000000000..b533c7f5f Binary files /dev/null and b/packaging/msix/store-listing/StorePoster1440x2160.png differ diff --git a/packaging/msix/store-listing/StorePoster720x1080.png b/packaging/msix/store-listing/StorePoster720x1080.png new file mode 100644 index 000000000..2c06587af Binary files /dev/null and b/packaging/msix/store-listing/StorePoster720x1080.png differ diff --git a/scripts/build-msix.ps1 b/scripts/build-msix.ps1 new file mode 100644 index 000000000..49f403b85 --- /dev/null +++ b/scripts/build-msix.ps1 @@ -0,0 +1,113 @@ +<# +.SYNOPSIS + Packs an already-built Voltius binary into an .msix for the Microsoft Store. + +.DESCRIPTION + The Store re-signs MSIX packages itself, which is the whole reason this path + exists: it needs no code-signing certificate, whereas submitting the NSIS .exe + would require one that chains to the Microsoft Trusted Root Program. + + Run `pnpm tauri build` first. The payload is just voltius.exe — the seeded + plugins are compiled into the binary (see src-tauri/build.rs), so there is no + resource tree to copy. + + IDENTITY_NAME / PUBLISHER / PUBLISHER_DISPLAY_NAME come from Partner Center + once the app name is reserved. The defaults below build a package that can be + installed locally with a self-signed certificate, but Partner Center rejects a + submission whose identity does not match the reservation exactly. + +.PARAMETER Arch + x64 or arm64. Must match the binary in -ExePath. + +.EXAMPLE + pnpm tauri build + ./scripts/build-msix.ps1 -Arch x64 +#> +[CmdletBinding()] +param( + [ValidateSet('x64', 'arm64')] + [string]$Arch = 'x64', + + # The Cargo workspace root is the repository root (see the [workspace] table + # in ./Cargo.toml), so the target directory is ./target, NOT src-tauri/target. + [string]$ExePath = "target/release/voltius.exe", + + [string]$OutDir = "target/msix", + + [string]$IdentityName = $(if ($env:MSIX_IDENTITY_NAME) { $env:MSIX_IDENTITY_NAME } else { "Voltius.Voltius" }), + + [string]$Publisher = $(if ($env:MSIX_PUBLISHER) { $env:MSIX_PUBLISHER } else { "CN=Voltius" }), + + [string]$PublisherDisplayName = $(if ($env:MSIX_PUBLISHER_DISPLAY_NAME) { $env:MSIX_PUBLISHER_DISPLAY_NAME } else { "Voltius" }) +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $repoRoot +try { + if (-not (Test-Path $ExePath)) { + # Name what was actually built rather than only what was expected: the + # difference is usually a target triple or the workspace-root target dir, + # and the build that produced it took half an hour. + $found = Get-ChildItem -Path . -Recurse -Filter voltius.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\release\\' } | + Select-Object -ExpandProperty FullName + $hint = if ($found) { "Found instead:`n " + ($found -join "`n ") } else { "No release voltius.exe anywhere under $repoRoot." } + throw "$ExePath not found. Run 'pnpm tauri build' first.`n$hint" + } + + . "$PSScriptRoot/lib/makeappx.ps1" + $version = Get-MsixVersion + + $layout = Join-Path $OutDir "layout-$Arch" + if (Test-Path $layout) { Remove-Item $layout -Recurse -Force } + New-Item -ItemType Directory -Path (Join-Path $layout "Assets") -Force | Out-Null + + Copy-Item $ExePath (Join-Path $layout "voltius.exe") + + # `tauri icon` emits every square logo. Wide310x150Logo is not square, so it + # comes from scripts/make-store-assets.sh — and makeappx rejects the package + # without it once Square310x310Logo is declared. + $logos = @( + 'Square44x44Logo.png', + 'Square71x71Logo.png', + 'Square150x150Logo.png', + 'Square310x310Logo.png', + 'Wide310x150Logo.png', + 'StoreLogo.png' + ) + foreach ($logo in $logos) { + Copy-Item (Join-Path "src-tauri/icons" $logo) (Join-Path $layout "Assets/$logo") + } + + $manifest = Get-Content "packaging/msix/AppxManifest.xml" -Raw + $manifest = $manifest. + Replace('{{IDENTITY_NAME}}', $IdentityName). + Replace('{{PUBLISHER}}', $Publisher). + Replace('{{PUBLISHER_DISPLAY_NAME}}', $PublisherDisplayName). + Replace('{{VERSION}}', $version). + Replace('{{ARCH}}', $Arch) + Set-Content -Path (Join-Path $layout "AppxManifest.xml") -Value $manifest -Encoding UTF8 + + $makeappx = Get-MakeAppxPath + + New-Item -ItemType Directory -Path $OutDir -Force | Out-Null + # Clear every package for this architecture, not just the one about to be + # written: bundle-msix.ps1 globs *.msix, so a leftover from before a version + # bump would be bundled alongside the new one as a second package for the + # same architecture. + Remove-Item (Join-Path $OutDir "Voltius_*_$Arch.msix") -Force -ErrorAction SilentlyContinue + $msix = Join-Path $OutDir "Voltius_${version}_$Arch.msix" + + & $makeappx pack /d $layout /p $msix /o + if ($LASTEXITCODE -ne 0) { throw "makeappx failed with exit code $LASTEXITCODE" } + + Write-Host "Built $msix" + Write-Host "Unsigned by design — the Store signs it on submission. To install it" + Write-Host "locally for testing, sign it first with a self-signed certificate whose" + Write-Host "subject matches Publisher ('$Publisher') and trust that certificate." +} +finally { + Pop-Location +} diff --git a/scripts/bundle-msix.ps1 b/scripts/bundle-msix.ps1 new file mode 100644 index 000000000..736ea4ee9 --- /dev/null +++ b/scripts/bundle-msix.ps1 @@ -0,0 +1,58 @@ +<# +.SYNOPSIS + Bundles the per-architecture .msix packages into one .msixbundle. + +.DESCRIPTION + A Store listing serves one artifact per architecture from a single bundle, and + `msstore publish` takes exactly one path — so the x64 and arm64 packages built + by build-msix.ps1 have to be bundled before submission. + +.PARAMETER InDir + Directory holding the .msix files to bundle. Anything else in it is ignored. + +.EXAMPLE + ./scripts/bundle-msix.ps1 -InDir target/msix +#> +[CmdletBinding()] +param( + [string]$InDir = "target/msix", + [string]$OutDir = "target/msix" +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $repoRoot +try { + $packages = @(Get-ChildItem (Join-Path $InDir "*.msix") -ErrorAction SilentlyContinue) + if ($packages.Count -eq 0) { + throw "No .msix packages in $InDir. Run build-msix.ps1 first." + } + + # makeappx bundles every .msix in the directory it is given, so stage exactly + # the packages and nothing else. + $staging = Join-Path $OutDir "bundle-staging" + if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } + New-Item -ItemType Directory -Path $staging -Force | Out-Null + $packages | ForEach-Object { Copy-Item $_.FullName $staging } + + . "$PSScriptRoot/lib/makeappx.ps1" + $makeappx = Get-MakeAppxPath + $version = Get-MsixVersion + + $bundle = Join-Path $OutDir "Voltius_$version.msixbundle" + if (Test-Path $bundle) { Remove-Item $bundle -Force } + + # /bv is not optional: without it makeappx stamps the bundle identity with the + # current date-time (2026.819.1209.0) instead of the app version. Store + # versions only ever move forward, so one submission at a date-derived version + # would lock the listing out of every semantic version for good. + & $makeappx bundle /bv $version /d $staging /p $bundle /o + if ($LASTEXITCODE -ne 0) { throw "makeappx bundle failed with exit code $LASTEXITCODE" } + + Remove-Item $staging -Recurse -Force + Write-Host "Bundled $($packages.Count) package(s) into $bundle" +} +finally { + Pop-Location +} diff --git a/scripts/copy-store-screenshots.sh b/scripts/copy-store-screenshots.sh new file mode 100755 index 000000000..9593c17ce --- /dev/null +++ b/scripts/copy-store-screenshots.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Refresh the Microsoft Store listing screenshots from the docs repo captures. +# +# The Store listing needs its own copies: the docs repo is a separate checkout, +# the upload order is part of the listing, and a caption is tied to each file. +# Numbering is that order — the first one is what a browsing customer sees. +# +# Usage: copy-store-screenshots.sh [docs-screenshots-dir] +set -euo pipefail + +SRC="${1:-$HOME/fourretout/voltius-dev/docs/docs/assets/screenshots}" +DEST="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/packaging/msix/store-listing" + +[ -d "$SRC" ] || { echo "no such directory: $SRC" >&2; exit 1; } + +# source:destination, in upload order. +SHOTS=( + "sftp-dual-pane.png:01-sftp-dual-pane.png" + "folders-tags.png:02-folders-tags.png" + "panes-grid.png:03-panes-grid.png" + "command-palette.png:04-command-palette.png" + "teams-roles.png:05-teams-roles.png" + "themes-creator.png:06-themes-creator.png" +) + +# Every source is checked before anything is removed. Failing partway through +# the copies would leave the listing directory holding some of the old set and +# some of the new, which only git could sort out. +missing=() +for shot in "${SHOTS[@]}"; do + [ -f "$SRC/${shot%%:*}" ] || missing+=("$SRC/${shot%%:*}") +done +if [ ${#missing[@]} -gt 0 ]; then + printf 'missing capture: %s\n' "${missing[@]}" >&2 + exit 1 +fi + +mkdir -p "$DEST" +rm -f "$DEST"/[0-9][0-9]-*.png + +echo "copied into packaging/msix/store-listing:" +for shot in "${SHOTS[@]}"; do + cp "$SRC/${shot%%:*}" "$DEST/${shot#*:}" + echo " ${shot#*:}" +done diff --git a/scripts/gen-aur-pkgbuild.sh b/scripts/gen-aur-pkgbuild.sh new file mode 100755 index 000000000..22314b2b0 --- /dev/null +++ b/scripts/gen-aur-pkgbuild.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Emits the PKGBUILD for the `voltius-bin` AUR package to stdout. +# Usage: gen-aur-pkgbuild.sh e.g. gen-aur-pkgbuild.sh v0.27.0 +# +# Repackages the released .deb rather than building from source: the release +# already ships x86_64 and aarch64 binaries, and a from-source AUR package would +# make every user compile the whole Rust + pnpm tree. `-bin` is the conventional +# name for that on the AUR. +# +# The .SRCINFO that must sit beside the PKGBUILD is NOT generated here — it has +# to come from `makepkg --printsrcinfo`, which needs an Arch environment. The +# publish-aur job does that step in an archlinux container. +# +# Requires: gh (authenticated), awk. +set -euo pipefail + +TAG="${1:?usage: gen-aur-pkgbuild.sh }" + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/release-assets.sh" +release_assets_init "$TAG" + +DEB_X86="$(release_asset amd64.deb)" +DEB_ARM="$(release_asset arm64.deb)" + +SHA_X86="$(release_sha "$DEB_X86")" +SHA_ARM="$(release_sha "$DEB_ARM")" + +cat < +# This file is generated by scripts/gen-aur-pkgbuild.sh in VoltiusApp/voltius. +# Edit it there, not here. + +pkgname=voltius-bin +_pkgname=voltius +pkgver=${VERSION} +pkgrel=1 +pkgdesc="Local-first SSH/SFTP/Serial client with E2EE sync, team vaults and plugins" +arch=('x86_64' 'aarch64') +url="https://voltius.app" +license=('AGPL-3.0-or-later') +depends=('webkit2gtk-4.1' 'gtk3' 'libsecret') +optdepends=('tmux: persistent sessions that survive disconnects' + 'screen: persistent sessions that survive disconnects') +provides=("\${_pkgname}") +conflicts=("\${_pkgname}") +options=('!strip' '!debug') +source_x86_64=("\${pkgname}-\${pkgver}-x86_64.deb::https://github.com/${REPO}/releases/download/${TAG}/${DEB_X86}") +source_aarch64=("\${pkgname}-\${pkgver}-aarch64.deb::https://github.com/${REPO}/releases/download/${TAG}/${DEB_ARM}") +sha256sums_x86_64=('${SHA_X86}') +sha256sums_aarch64=('${SHA_ARM}') + +package() { + # makepkg unpacks the .deb into its members; the payload name varies with the + # compression dpkg-deb chose, so match rather than hardcode data.tar.gz. + local payload + payload=\$(find "\${srcdir}" -maxdepth 1 -name 'data.tar.*' -print -quit) + [ -n "\${payload}" ] || { echo "no data.tar.* in \${srcdir}" >&2; return 1; } + + bsdtar -xf "\${payload}" -C "\${pkgdir}" +} +EOF diff --git a/scripts/gen-flatpak-manifest.sh b/scripts/gen-flatpak-manifest.sh new file mode 100755 index 000000000..c4583d1e2 --- /dev/null +++ b/scripts/gen-flatpak-manifest.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Emits the Flatpak manifest for Voltius to stdout. +# Usage: gen-flatpak-manifest.sh e.g. gen-flatpak-manifest.sh v0.27.0 +# +# Repackages the released .deb, same reasoning as the AUR package: the release +# already ships x86_64 and aarch64 binaries, and Flathub's builders should not +# have to compile the whole Rust + pnpm tree. +# +# Like the Scoop manifest, this is a bootstrapping tool rather than a release +# step. The manifest carries x-checker-data, so once it is merged into +# flathub/app.voltius.Voltius, Flathub's flatpak-external-data-checker bot +# follows new releases on its own — nothing here has to run again unless the +# release asset naming changes. +# +# What the bot does NOT touch is the metainfo: it rewrites the source url and +# sha256 and nothing else. The block in +# packaging/flatpak/app.voltius.Voltius.metainfo.xml is what the store page +# shows as the current version, so it needs an entry per release by hand. +# +# Requires: gh (authenticated), awk. +set -euo pipefail + +TAG="${1:?usage: gen-flatpak-manifest.sh }" + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/release-assets.sh" +release_assets_init "$TAG" + +DEB_X86="$(release_asset amd64.deb)" +DEB_ARM="$(release_asset arm64.deb)" + +SHA_X86="$(release_sha "$DEB_X86")" +SHA_ARM="$(release_sha "$DEB_ARM")" + +cat < e.g. gen-homebrew-cask.sh v0.4.0 +# +# Usage: gen-homebrew-cask.sh [--core] +# e.g. gen-homebrew-cask.sh v0.4.0 +# +# --core emit the variant intended for a homebrew-cask *core* submission +# rather than for the VoltiusApp/homebrew-voltius tap. Core rejects +# casks whose caveats tell the user to reinstall with +# --no-quarantine, so that hint is dropped there; the right-click +# Open instructions stay, which core does accept for an app that is +# ad-hoc signed but not notarized. +# # Requires: gh (authenticated), awk. Reads the .dmg.sha256 release assets. set -euo pipefail -TAG="${1:?usage: gen-homebrew-cask.sh }" -REPO="${REPO:-VoltiusApp/voltius}" -VERSION="${TAG#v}" +TAG="${1:?usage: gen-homebrew-cask.sh [--core]}" +VARIANT="${2:-}" -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/release-assets.sh" +release_assets_init "$TAG" -fetch_sha() { - # $1 = arch token in the asset filename (aarch64|x64) - local arch="$1" name="Voltius_${VERSION}_$1.dmg.sha256" - gh release download "$TAG" -R "$REPO" -p "$name" -O "$tmp/$arch.sha256" >/dev/null - awk '{print $1}' "$tmp/$arch.sha256" -} +SHA_ARM="$(release_sha "$(release_asset aarch64.dmg)")" +SHA_INTEL="$(release_sha "$(release_asset x64.dmg)")" -SHA_ARM="$(fetch_sha aarch64)" -SHA_INTEL="$(fetch_sha x64)" +if [ "$VARIANT" = "--core" ]; then + QUARANTINE_HINT="" +else + QUARANTINE_HINT=" + To skip the warning entirely, install with: + brew install --cask --no-quarantine voltiusapp/voltius/voltius +" +fi cat < e.g. gen-scoop-manifest.sh v0.27.0 +# +# The manifest carries `checkver` + `autoupdate`, so once it is merged into +# ScoopInstaller/Extras their excavator bot bumps the version and re-reads the +# .sha256 assets on every release without any push from us. This script only +# has to produce the FIRST submission (and to regenerate it if the asset naming +# ever changes) — it is deliberately not wired into the release workflow. +# +# Requires: gh (authenticated), awk. +set -euo pipefail + +TAG="${1:?usage: gen-scoop-manifest.sh }" + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/release-assets.sh" +release_assets_init "$TAG" + +SHA_X64="$(release_sha "$(release_asset x64-setup.exe)")" +SHA_ARM64="$(release_sha "$(release_asset arm64-setup.exe)")" + +cat < + +function Get-MakeAppxPath { + # The SDK installs one copy per version; the newest wins. + $makeappx = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\makeappx.exe" -ErrorAction SilentlyContinue | + Sort-Object FullName | + Select-Object -Last 1 + + if (-not $makeappx) { + throw "makeappx.exe not found. Install the Windows 10/11 SDK." + } + + return $makeappx.FullName +} + +function Get-MsixVersion { + # MSIX versions are 4-part and the Store requires the revision to be 0. The + # package and the bundle must carry the same one, so both scripts read it here. + # Relative to the repository root, which every caller has pushed into. + $conf = Get-Content "src-tauri/tauri.conf.json" -Raw | ConvertFrom-Json + return "$($conf.version).0" +} diff --git a/scripts/lib/release-assets.sh b/scripts/lib/release-assets.sh new file mode 100755 index 000000000..752b7989c --- /dev/null +++ b/scripts/lib/release-assets.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Shared helpers for the per-channel package generators (Homebrew cask, AUR, +# Scoop, Flatpak). Source it, then call the functions below. +# +# source "$(dirname "$0")/lib/release-assets.sh" +# release_assets_init "$TAG" +# sha="$(release_sha "Voltius_${VERSION}_amd64.deb")" +# +# Requires: gh (authenticated), awk. +# +# Every generator needs the same thing: the sha256 of a named release asset, +# read from the `.sha256` file the release job uploads beside it. Kept in +# one place so a change to the asset naming or the checksum format is made once +# rather than per channel. + +# release_assets_init +# Sets TAG, REPO and VERSION, and prepares a scratch dir for the checksum files. +release_assets_init() { + TAG="${1:?usage: release_assets_init }" + REPO="${REPO:-VoltiusApp/voltius}" + VERSION="${TAG#v}" + _RA_TMP="$(mktemp -d)" + # shellcheck disable=SC2064 # expand _RA_TMP now, not when the trap fires + trap "rm -rf '$_RA_TMP'" EXIT + export TAG REPO VERSION +} + +# release_asset +# Prints the name of a release asset from its arch/format suffix, e.g. +# release_asset amd64.deb -> Voltius_0.27.0_amd64.deb +# release_asset x64-setup.exe -> Voltius_0.27.0_x64-setup.exe +# The `Voltius__` prefix is tauri-action's, and every generator would +# otherwise spell it out again. +release_asset() { + printf 'Voltius_%s_%s' "$VERSION" "${1:?usage: release_asset }" +} + +# release_sha +# Prints the sha256 of the named release asset. Fails when the checksum asset is +# missing rather than emitting an empty hash into a package manifest. +release_sha() { + local name="${1:?usage: release_sha }" + local out="$_RA_TMP/$name.sha256" + if [ ! -f "$out" ]; then + gh release download "$TAG" -R "$REPO" -p "$name.sha256" -O "$out" >/dev/null + fi + local sha + sha="$(awk '{print $1; exit}' "$out")" + [ -n "$sha" ] || { echo "empty checksum for $name" >&2; return 1; } + printf '%s' "$sha" +} + +# release_url +# Prints the public download URL of a release asset. +release_url() { + printf 'https://github.com/%s/releases/download/%s/%s' \ + "$REPO" "$TAG" "${1:?usage: release_url }" +} diff --git a/scripts/make-store-assets.sh b/scripts/make-store-assets.sh new file mode 100755 index 000000000..513620f54 --- /dev/null +++ b/scripts/make-store-assets.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Render the Microsoft Store art that `tauri icon` does not produce. +# +# Two listing assets — Partner Center accepts the poster at exactly 720x1080 or +# 1440x2160, and the app tile at exactly 300x300 — plus one package asset: +# Wide310x150Logo. `tauri icon` emits only square logos, and makeappx rejects +# the package outright if DefaultTile declares Square310x310Logo without the +# wide one: +# +# error 80080204: App manifest validation error: The DefaultTile element must +# specify the Wide310x150Logo attribute if the Square310x310Logo attribute is +# specified. +# +# Runs ImageMagick and rsvg-convert in a container: neither is needed on the +# host, and the alternative is hand-edited binaries nobody can regenerate. +# +# Usage: scripts/make-store-assets.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +UID_GID="$(id -u):$(id -g)" + +docker run --rm -v "$REPO_ROOT:/w" -w /w alpine:latest sh -euc " +apk add --no-cache imagemagick rsvg-convert >/dev/null 2>&1 + +OUT=packaging/msix/store-listing +mkdir -p \"\$OUT\" +TMP=\$(mktemp -d) + +# The 1:1 app tile, from the 512x512 app icon. +magick src-tauri/icons/icon.png -resize 300x300 \ + -background none -gravity center -extent 300x300 \"\$OUT/StoreLogo300x300.png\" + +# The wide Start tile. Transparent behind the mark, matching the square logos +# tauri emits: the manifest sets BackgroundColor=\"transparent\", so Windows +# paints the accent colour behind it. 110px leaves the tile breathing room +# rather than filling its whole height. +magick src-tauri/icons/icon.png -resize 110x110 \ + -background none -gravity center -extent 310x150 \ + src-tauri/icons/Wide310x150Logo.png + +# Bolt at 700px wide; logo.svg is 466x766, so height lands at ~1150. +rsvg-convert -w 700 logo.svg -o \"\$TMP/bolt.png\" + +# Flat brand background, sampled from the app icon. +magick -size 1440x2160 xc:'#010318' \"\$TMP/bg.png\" + +# Glow behind the mark. The outer stop MUST be black: this is composited with +# 'screen', under which black contributes nothing, so the glow's bounding box +# fades out invisibly. An outer stop of #010318 instead lifts every pixel inside +# the box and leaves a hard horizontal seam where the box ends. +magick -size 1440x1440 radial-gradient:'#2A5FA8-black' \"\$TMP/glow.png\" +magick \"\$TMP/bg.png\" \"\$TMP/glow.png\" -geometry +0+0 -compose screen -composite \"\$TMP/base.png\" + +# 370 = (1440 - 700) / 2. 145 puts the bolt's centre at y=720, the middle of the +# top two-thirds — the Store draws text overlays across the bottom third, so +# nothing important may sit there. +magick \"\$TMP/base.png\" \"\$TMP/bolt.png\" -geometry +370+145 -compose over -composite \ + \"\$OUT/StorePoster1440x2160.png\" + +magick \"\$OUT/StorePoster1440x2160.png\" -resize 720x1080! \"\$OUT/StorePoster720x1080.png\" + +chown ${UID_GID} \"\$OUT/StoreLogo300x300.png\" \"\$OUT/StorePoster1440x2160.png\" \ + \"\$OUT/StorePoster720x1080.png\" src-tauri/icons/Wide310x150Logo.png + +echo 'wrote:' +magick identify -format ' %f %wx%h\n' \ + \"\$OUT/StoreLogo300x300.png\" \"\$OUT/StorePoster1440x2160.png\" \ + \"\$OUT/StorePoster720x1080.png\" src-tauri/icons/Wide310x150Logo.png +rm -rf \"\$TMP\" +" diff --git a/src-tauri/icons/Wide310x150Logo.png b/src-tauri/icons/Wide310x150Logo.png new file mode 100644 index 000000000..5ba6da2c2 Binary files /dev/null and b/src-tauri/icons/Wide310x150Logo.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 01cf61f50..befbb3ad5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -111,7 +111,23 @@ fn classify_install( // Per-user NSIS self-updates. A per-machine install still attempts the // update and surfaces any UAC-elevation failure via the error path; // no proactive install-scope detection here. - Os::Windows => InstallKind::SelfUpdate, + // + // The exception is an MSIX package from the Microsoft Store, which + // lands under C:\Program Files\WindowsApps. That tree is locked down + // even for administrators, so the download would always fail at the + // install step; the Store owns updates for that install anyway. + // + // Matched on the raw string rather than on Path components: this + // function is unit-tested for every platform from one host, and a + // Linux Path does not split a Windows path on its backslashes. + Os::Windows => { + let p = exe_path.to_string_lossy().to_ascii_lowercase(); + if p.contains("\\windowsapps\\") || p.contains("/windowsapps/") { + InstallKind::External + } else { + InstallKind::SelfUpdate + } + } } } @@ -874,4 +890,19 @@ mod updater_tests { InstallKind::SelfUpdate ); } + + #[test] + fn windows_msix_package_is_external() { + assert_eq!( + classify_install( + Os::Windows, + false, + Path::new( + r"C:\Program Files\WindowsApps\Voltius_0.27.0.0_x64__abcdefg\voltius.exe" + ), + true + ), + InstallKind::External + ); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a915f567f..924861d9a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -34,6 +34,7 @@ "bundle": { "active": true, "createUpdaterArtifacts": true, + "category": "DeveloperTool", "targets": [ "nsis", "msi", @@ -52,6 +53,18 @@ ], "macOS": { "signingIdentity": "-" + }, + "linux": { + "deb": { + "depends": [ + "libsecret-1-0" + ] + }, + "rpm": { + "depends": [ + "libsecret" + ] + } } }, "plugins": {