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.
+
+
@@ -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:
+
+ - SSH with jump hosts, port forwarding and agent support
+ - Dual-pane SFTP with drag and drop, including host-to-host transfers
+ - Split panes, broadcast input, and a command palette over your whole fleet
+ - Sessions that survive disconnects through tmux or screen on the host
+ - Serial console for hardware on a local port
+ - End-to-end encrypted sync between your devices, over your own GitHub Gist or the Voltius relay
+ - Team vaults with roles and an audit log
+ - Docker and Proxmox LXC browsing, process manager, live host metrics
+ - A plugin system with an open registry
+ - One-click import from Termius and MobaXterm, and JSON export at any time
+
+
+
+ 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": {