diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..df42e34 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - switchify-signing diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa720c6..cbad498 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,19 +13,56 @@ jobs: frontend: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Reject retired product identity run: | retired_identity="pre""view" if git grep -n -i "$retired_identity" -- ':!src-tauri/Cargo.lock'; then exit 1 fi - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 24 cache: npm cache-dependency-path: package-lock.json + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: 1.97.1 - run: npm ci + - name: Validate release updater configuration + shell: bash + run: | + set -euo pipefail + npx tauri signer generate --ci --password validation-only --write-keys "$RUNNER_TEMP/updater.key" + SWITCHIFY_UPDATER_PUBLIC_KEY="$(cat "$RUNNER_TEMP/updater.key.pub")" node scripts/render-updater-config.mjs "$RUNNER_TEMP/tauri.release.json" + node -e 'const c=require(process.argv[1]); if (!c.bundle.createUpdaterArtifacts || c.plugins.updater.endpoints.length !== 1 || !c.plugins.updater.pubkey) process.exit(1)' "$RUNNER_TEMP/tauri.release.json" + cargo build --locked --manifest-path tools/updater-signature-verifier/Cargo.toml + verifier="$GITHUB_WORKSPACE/tools/updater-signature-verifier/target/debug/switchify-updater-signature-verifier" + mkdir -p "$RUNNER_TEMP/artifacts/macos-release" "$RUNNER_TEMP/artifacts/windows-release" + mac_artifact="$RUNNER_TEMP/artifacts/macos-release/Switchify.PC.app.tar.gz" + windows_artifact="$RUNNER_TEMP/artifacts/windows-release/Switchify.PC_1.0.0_x64-setup.exe" + node -e 'require("fs").writeFileSync(process.argv[1], require("zlib").gzipSync("archive fixture"))' "$mac_artifact" + node -e 'const b=Buffer.alloc(128); b.write("MZ"); b.writeUInt32LE(64, 0x3c); b.write("PE\0\0", 64); require("fs").writeFileSync(process.argv[1], b)' "$windows_artifact" + npx tauri signer sign --private-key-path "$RUNNER_TEMP/updater.key" --password validation-only "$mac_artifact" + npx tauri signer sign --private-key-path "$RUNNER_TEMP/updater.key" --password validation-only "$windows_artifact" + export SWITCHIFY_UPDATER_PUBLIC_KEY="$(cat "$RUNNER_TEMP/updater.key.pub")" + node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 v1.0.0-beta.1 "$RUNNER_TEMP/latest.json" "$verifier" + node -e 'const f=require(process.argv[1]); if (!f.platforms["darwin-aarch64"].signature || !f.platforms["windows-x86_64"].signature) process.exit(1)' "$RUNNER_TEMP/latest.json" + if node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 v1.0.0-beta.2 "$RUNNER_TEMP/invalid.json" "$verifier"; then + echo 'Mismatched update tag was accepted.' >&2 + exit 1 + fi + printf 'not-a-tauri-signature' > "$mac_artifact.sig" + if node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 v1.0.0-beta.1 "$RUNNER_TEMP/invalid.json" "$verifier"; then + echo 'Invalid updater signature was accepted.' >&2 + exit 1 + fi + printf 'not an updater archive' > "$mac_artifact" + npx tauri signer sign --private-key-path "$RUNNER_TEMP/updater.key" --password validation-only "$mac_artifact" + if node scripts/create-update-feed.mjs "$RUNNER_TEMP/artifacts" 1.0.0-beta.1 v1.0.0-beta.1 "$RUNNER_TEMP/invalid.json" "$verifier"; then + echo 'Wrong-format updater payload was accepted.' >&2 + exit 1 + fi - run: npm run lint - run: npm test - run: npm run build @@ -41,13 +78,13 @@ jobs: bundles: app,dmg runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 24 cache: npm cache-dependency-path: package-lock.json - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: 1.97.1 components: rustfmt, clippy @@ -55,6 +92,9 @@ jobs: - run: cargo fmt --manifest-path src-tauri/Cargo.toml --check - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings - run: cargo test --locked --manifest-path src-tauri/Cargo.toml + - run: cargo fmt --manifest-path tools/updater-signature-verifier/Cargo.toml --check + - run: cargo clippy --locked --manifest-path tools/updater-signature-verifier/Cargo.toml --all-targets -- -D warnings + - run: cargo test --locked --manifest-path tools/updater-signature-verifier/Cargo.toml - if: runner.os == 'Windows' run: cargo fmt --manifest-path src-tauri/startup-launcher/Cargo.toml --check - if: runner.os == 'Windows' diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml deleted file mode 100644 index 244de37..0000000 --- a/.github/workflows/release-macos.yml +++ /dev/null @@ -1,230 +0,0 @@ -name: Release macOS - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - tag: - description: Release tag to publish, for example v1.0.0-beta.1 - required: true - type: string - -permissions: - contents: write - -concurrency: - group: release-macos-${{ github.ref_name || inputs.tag }} - cancel-in-progress: false - -jobs: - release-macos: - name: Sign, notarize, and publish Apple Silicon DMG - runs-on: macos-15 - environment: production - timeout-minutes: 60 - - env: - RELEASE_EVENT_NAME: ${{ github.event_name }} - RELEASE_INPUT_TAG: ${{ inputs.tag }} - RELEASE_REF_NAME: ${{ github.ref_name }} - APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} - APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }} - SWITCHIFY_TELEMETRY_ENDPOINT: ${{ vars.TIMBERLOGS_ENDPOINT }} - - steps: - - name: Resolve release tag - shell: bash - run: | - set -euo pipefail - release_tag="$RELEASE_REF_NAME" - if [[ "$RELEASE_EVENT_NAME" == 'workflow_dispatch' ]]; then - release_tag="$RELEASE_INPUT_TAG" - fi - if [[ ! "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then - echo "Release tag must be a semantic version beginning with v. Received: $release_tag" >&2 - exit 1 - fi - echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV" - - - name: Checkout release tag - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} - - - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: 24 - cache: npm - cache-dependency-path: package-lock.json - - - name: Set up Rust - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable - with: - toolchain: 1.97.1 - targets: aarch64-apple-darwin - - - name: Install dependencies - run: npm ci - - - name: Verify release configuration - shell: bash - run: | - set -euo pipefail - node_version="$(node -p "require('./package.json').version")" - tauri_version="$(node -p "require('./src-tauri/tauri.conf.json').version")" - expected_tag="v${node_version}" - if [[ "$node_version" != "$tauri_version" ]]; then - echo "package.json version $node_version does not match Tauri version $tauri_version." >&2 - exit 1 - fi - if [[ "$RELEASE_TAG" != "$expected_tag" ]]; then - echo "Release tag $RELEASE_TAG does not match app version $expected_tag." >&2 - exit 1 - fi - if [[ "$APPLE_SIGNING_IDENTITY" != 'Developer ID Application: '* ]]; then - echo 'APPLE_SIGNING_IDENTITY must name a Developer ID Application certificate.' >&2 - exit 1 - fi - if [[ "$APPLE_SIGNING_IDENTITY" != *"($APPLE_TEAM_ID)" ]]; then - echo 'APPLE_SIGNING_IDENTITY does not contain the configured APPLE_TEAM_ID.' >&2 - exit 1 - fi - for required_name in APPLE_API_ISSUER APPLE_API_KEY TIMBERLOGS_API_KEY SWITCHIFY_TELEMETRY_ENDPOINT; do - if [[ -z "${!required_name:-}" ]]; then - echo "$required_name is not configured for the production environment." >&2 - exit 1 - fi - done - - - name: Install Developer ID certificate - shell: bash - env: - APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - run: | - set -euo pipefail - if [[ -z "$APPLE_CERTIFICATE_BASE64" || -z "$APPLE_CERTIFICATE_PASSWORD" ]]; then - echo 'The production Developer ID certificate is not configured.' >&2 - exit 1 - fi - certificate_path="$RUNNER_TEMP/switchify-developer-id.p12" - keychain_path="$RUNNER_TEMP/switchify-signing.keychain-db" - keychain_password="$(openssl rand -base64 32)" - echo "::add-mask::$keychain_password" - printf '%s' "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$certificate_path" - chmod 600 "$certificate_path" - security create-keychain -p "$keychain_password" "$keychain_path" - security set-keychain-settings -lut 21600 "$keychain_path" - security unlock-keychain -p "$keychain_password" "$keychain_path" - security import "$certificate_path" \ - -P "$APPLE_CERTIFICATE_PASSWORD" \ - -A -t cert -f pkcs12 -k "$keychain_path" - security set-key-partition-list \ - -S apple-tool:,apple:,codesign: \ - -s -k "$keychain_password" "$keychain_path" - security list-keychains -d user -s "$keychain_path" - identity_output="$(security find-identity -v -p codesigning "$keychain_path")" - if ! grep -Fq "\"$APPLE_SIGNING_IDENTITY\"" <<< "$identity_output"; then - echo "The configured Developer ID identity was not found in the imported certificate." >&2 - exit 1 - fi - echo "APPLE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV" - - - name: Install notarization API key - shell: bash - env: - APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} - run: | - set -euo pipefail - if [[ -z "$APPLE_API_PRIVATE_KEY" ]]; then - echo 'APPLE_API_PRIVATE_KEY is not configured for the production environment.' >&2 - exit 1 - fi - api_key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8" - printf '%s' "$APPLE_API_PRIVATE_KEY" > "$api_key_path" - chmod 600 "$api_key_path" - echo "APPLE_API_KEY_PATH=$api_key_path" >> "$GITHUB_ENV" - - - name: Build signed and notarized app and signed DMG - run: npm run tauri build -- --bundles app,dmg --target aarch64-apple-darwin - - - name: Notarize and staple DMG - shell: bash - run: | - set -euo pipefail - dmg_path="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)" - if [[ -z "$dmg_path" ]]; then - echo 'No signed DMG was produced for notarization.' >&2 - exit 1 - fi - xcrun notarytool submit "$dmg_path" \ - --key "$APPLE_API_KEY_PATH" \ - --key-id "$APPLE_API_KEY" \ - --issuer "$APPLE_API_ISSUER" \ - --wait \ - --timeout 20m - xcrun stapler staple "$dmg_path" - - - name: Verify signed release - shell: bash - run: ./scripts/verify-macos-release.sh "$RELEASE_TAG" "$APPLE_SIGNING_IDENTITY" "$APPLE_TEAM_ID" - - - name: Stage release assets - shell: bash - run: | - set -euo pipefail - mkdir -p dist - dmg_path="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)" - if [[ -z "$dmg_path" ]]; then - echo 'No notarized DMG was produced.' >&2 - exit 1 - fi - cp "$dmg_path" dist/ - asset_name="$(basename "$dmg_path")" - asset_hash="$(shasum -a 256 "dist/$asset_name" | awk '{print $1}')" - printf '%s %s\n' "$asset_hash" "$asset_name" > dist/SHA256SUMS-macos.txt - - - name: Publish GitHub release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - shopt -s nullglob - assets=(dist/*) - if (( ${#assets[@]} == 0 )); then - echo 'No release assets found in dist.' >&2 - exit 1 - fi - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber - else - gh release create "$RELEASE_TAG" "${assets[@]}" \ - --title "$RELEASE_TAG" \ - --generate-notes \ - --verify-tag - fi - - - name: Upload workflow artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: switchify-pc-macos-${{ env.RELEASE_TAG }} - path: dist/* - if-no-files-found: error - - - name: Remove temporary signing material - if: always() - shell: bash - run: | - if [[ -n "${APPLE_KEYCHAIN_PATH:-}" ]]; then - security delete-keychain "$APPLE_KEYCHAIN_PATH" 2>/dev/null || true - fi - rm -f \ - "$RUNNER_TEMP/switchify-developer-id.p12" \ - "$RUNNER_TEMP/AuthKey_${APPLE_API_KEY:-missing}.p8" diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml new file mode 100644 index 0000000..b7f6797 --- /dev/null +++ b/.github/workflows/release-tauri.yml @@ -0,0 +1,418 @@ +name: Release Switchify PC + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: Existing release tag to publish, for example v1.0.0-beta.1 + required: true + type: string + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref_name || inputs.tag }} + cancel-in-progress: false + +jobs: + prepare: + name: Validate release + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + ref: ${{ steps.release.outputs.ref }} + env: + RELEASE_EVENT_NAME: ${{ github.event_name }} + RELEASE_INPUT_TAG: ${{ inputs.tag }} + RELEASE_REF_NAME: ${{ github.ref_name }} + UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + steps: + - name: Resolve release tag + id: release + shell: bash + run: | + set -euo pipefail + release_tag="$RELEASE_REF_NAME" + release_ref="$GITHUB_REF" + if [[ "$RELEASE_EVENT_NAME" == 'workflow_dispatch' ]]; then + release_tag="$RELEASE_INPUT_TAG" + release_ref="refs/tags/$release_tag" + fi + if [[ ! "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "Release tag must be a semantic version beginning with v. Received: $release_tag" >&2 + exit 1 + fi + if [[ -z "$UPDATER_PUBLIC_KEY" ]]; then + echo 'TAURI_UPDATER_PUBLIC_KEY is not configured.' >&2 + exit 1 + fi + echo "tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "version=${release_tag#v}" >> "$GITHUB_OUTPUT" + echo "ref=$release_ref" >> "$GITHUB_OUTPUT" + + - name: Checkout release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ steps.release.outputs.ref }} + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Verify source versions + shell: bash + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + node_version="$(node -p "require('./package.json').version")" + tauri_version="$(node -p "require('./src-tauri/tauri.conf.json').version")" + cargo_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' src-tauri/Cargo.toml | head -1)" + if [[ "$node_version" != "$tauri_version" || "$node_version" != "$cargo_version" ]]; then + echo "Version mismatch: package=$node_version tauri=$tauri_version cargo=$cargo_version" >&2 + exit 1 + fi + if [[ "$RELEASE_TAG" != "v$node_version" ]]; then + echo "Release tag $RELEASE_TAG does not match app version v$node_version." >&2 + exit 1 + fi + + macos: + name: Sign and notarize macOS + needs: prepare + runs-on: macos-15 + environment: production + timeout-minutes: 60 + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_REF: ${{ needs.prepare.outputs.ref }} + SWITCHIFY_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + SWITCHIFY_TELEMETRY_ENDPOINT: ${{ vars.TIMBERLOGS_ENDPOINT }} + steps: + - name: Checkout release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ env.RELEASE_REF }} + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Set up Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: 1.97.1 + targets: aarch64-apple-darwin + + - name: Install dependencies + run: npm ci + + - name: Verify macOS release configuration + shell: bash + env: + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + if [[ "$APPLE_SIGNING_IDENTITY" != 'Developer ID Application: '* ]] || \ + [[ "$APPLE_SIGNING_IDENTITY" != *"($APPLE_TEAM_ID)" ]]; then + echo 'APPLE_SIGNING_IDENTITY must be a Developer ID Application identity for APPLE_TEAM_ID.' >&2 + exit 1 + fi + for required_name in APPLE_API_ISSUER APPLE_API_KEY TIMBERLOGS_API_KEY \ + SWITCHIFY_TELEMETRY_ENDPOINT TAURI_SIGNING_PRIVATE_KEY \ + TAURI_SIGNING_PRIVATE_KEY_PASSWORD SWITCHIFY_UPDATER_PUBLIC_KEY; do + if [[ -z "${!required_name:-}" ]]; then + echo "Missing macOS release configuration: $required_name" >&2 + exit 1 + fi + done + + - name: Install Developer ID certificate + shell: bash + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$APPLE_CERTIFICATE_BASE64" || -z "$APPLE_CERTIFICATE_PASSWORD" ]]; then + echo 'The production Developer ID certificate is not configured.' >&2 + exit 1 + fi + certificate_path="$RUNNER_TEMP/switchify-developer-id.p12" + keychain_path="$RUNNER_TEMP/switchify-signing.keychain-db" + keychain_password="$(openssl rand -base64 32)" + echo "::add-mask::$keychain_password" + printf '%s' "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$certificate_path" + chmod 600 "$certificate_path" + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security import "$certificate_path" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 -k "$keychain_path" + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$keychain_password" "$keychain_path" + security list-keychains -d user -s "$keychain_path" + identity_output="$(security find-identity -v -p codesigning "$keychain_path")" + if ! grep -Fq "\"$APPLE_SIGNING_IDENTITY\"" <<< "$identity_output"; then + echo 'The configured Developer ID identity was not found after import.' >&2 + exit 1 + fi + echo "APPLE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV" + + - name: Install notarization API key + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} + run: | + set -euo pipefail + if [[ -z "$APPLE_API_PRIVATE_KEY" ]]; then + echo 'APPLE_API_PRIVATE_KEY is not configured.' >&2 + exit 1 + fi + api_key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8" + printf '%s' "$APPLE_API_PRIVATE_KEY" > "$api_key_path" + chmod 600 "$api_key_path" + echo "APPLE_API_KEY_PATH=$api_key_path" >> "$GITHUB_ENV" + + - name: Render updater configuration + run: node scripts/render-updater-config.mjs src-tauri/tauri.release.generated.json + + - name: Build signed and notarized app, updater, and DMG + env: + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: npm run tauri build -- --bundles app,dmg --target aarch64-apple-darwin --config src-tauri/tauri.release.generated.json + + - name: Notarize and staple DMG + shell: bash + env: + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + run: | + set -euo pipefail + dmg_path="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)" + if [[ -z "$dmg_path" ]]; then + echo 'No signed DMG was produced for notarization.' >&2 + exit 1 + fi + xcrun notarytool submit "$dmg_path" --key "$APPLE_API_KEY_PATH" \ + --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \ + --wait --timeout 20m + xcrun stapler staple "$dmg_path" + + - name: Verify and stage macOS release + shell: bash + run: | + set -euo pipefail + ./scripts/verify-macos-release.sh "$RELEASE_TAG" "$APPLE_SIGNING_IDENTITY" "$APPLE_TEAM_ID" + bundle_root='src-tauri/target/aarch64-apple-darwin/release/bundle' + dmg_path="$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' -print -quit)" + updater_path="$(find "$bundle_root/macos" -maxdepth 1 -type f -name '*.app.tar.gz' -print -quit)" + if [[ -z "$updater_path" || ! -s "$updater_path.sig" ]]; then + echo 'The signed macOS updater artifact is missing.' >&2 + exit 1 + fi + mkdir -p dist/macos + cp "$dmg_path" "$updater_path" "$updater_path.sig" dist/macos/ + (cd dist/macos && shasum -a 256 -- *.dmg *.app.tar.gz > SHA256SUMS-macos.txt) + + - name: Upload macOS release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: macos-release + path: dist/macos/* + if-no-files-found: error + + - name: Remove temporary signing material + if: always() + shell: bash + run: | + if [[ -n "${APPLE_KEYCHAIN_PATH:-}" ]]; then + security delete-keychain "$APPLE_KEYCHAIN_PATH" 2>/dev/null || true + fi + rm -f "$RUNNER_TEMP/switchify-developer-id.p12" "$RUNNER_TEMP"/AuthKey_*.p8 + + windows: + name: Sign Windows installer + needs: prepare + runs-on: [self-hosted, Windows, X64, switchify-signing] + environment: production + timeout-minutes: 60 + env: + RELEASE_REF: ${{ needs.prepare.outputs.ref }} + SWITCHIFY_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + SWITCHIFY_CERTUM_CERT_THUMBPRINT: ${{ vars.CERTUM_CERT_THUMBPRINT }} + SWITCHIFY_TELEMETRY_ENDPOINT: ${{ vars.TIMBERLOGS_ENDPOINT }} + steps: + - name: Checkout release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ env.RELEASE_REF }} + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Set up Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: 1.97.1 + targets: x86_64-pc-windows-msvc + + - name: Install dependencies + run: npm ci + + - name: Verify Windows release configuration + shell: powershell + env: + TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + foreach ($name in @( + 'SWITCHIFY_UPDATER_PUBLIC_KEY', + 'SWITCHIFY_CERTUM_CERT_THUMBPRINT', + 'SWITCHIFY_TELEMETRY_ENDPOINT', + 'TIMBERLOGS_API_KEY', + 'TAURI_SIGNING_PRIVATE_KEY', + 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD' + )) { + if (-not [Environment]::GetEnvironmentVariable($name)) { + throw "Missing Windows release configuration: $name" + } + } + + - name: Render updater configuration + run: node scripts/render-updater-config.mjs src-tauri/tauri.release.windows.generated.json --windows + + - name: Build signed Windows package + shell: powershell + env: + TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: ./scripts/Build-WindowsUiAccess.ps1 -TauriConfig src-tauri/tauri.release.windows.generated.json + + - name: Verify Windows package + shell: powershell + run: ./scripts/Verify-WindowsUiAccessPackage.ps1 + + - name: Stage Windows release + shell: powershell + run: | + $installer = Get-ChildItem 'src-tauri/target/release/bundle/nsis' -Filter '*-setup.exe' | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if (-not $installer -or -not (Test-Path -LiteralPath "$($installer.FullName).sig")) { + throw 'The signed Windows updater artifact is missing.' + } + New-Item -ItemType Directory -Path dist/windows -Force | Out-Null + Copy-Item -LiteralPath $installer.FullName -Destination dist/windows + Copy-Item -LiteralPath "$($installer.FullName).sig" -Destination dist/windows + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer.FullName).Hash.ToLowerInvariant() + "$hash $($installer.Name)" | Set-Content -NoNewline dist/windows/SHA256SUMS-windows.txt + + - name: Upload Windows release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: windows-release + path: dist/windows/* + if-no-files-found: error + + publish: + name: Publish release and updater feed + needs: [prepare, macos, windows] + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + RELEASE_REF: ${{ needs.prepare.outputs.ref }} + SWITCHIFY_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + steps: + - name: Checkout release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ env.RELEASE_REF }} + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: 1.97.1 + + - name: Download release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: release-artifacts + + - name: Build updater signature verifier + run: cargo build --locked --release --manifest-path tools/updater-signature-verifier/Cargo.toml + + - name: Create updater feed + run: >- + node scripts/create-update-feed.mjs release-artifacts "$RELEASE_VERSION" "$RELEASE_TAG" latest.json + tools/updater-signature-verifier/target/release/switchify-updater-signature-verifier + + - name: Publish verified release assets + shell: bash + run: | + set -euo pipefail + mapfile -d '' assets < <(find release-artifacts -type f -print0) + if (( ${#assets[@]} == 0 )); then + echo 'No release assets were downloaded.' >&2 + exit 1 + fi + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber + else + release_args=(--title "$RELEASE_TAG" --generate-notes --verify-tag) + if [[ "$RELEASE_VERSION" == *-* ]]; then + release_args+=(--prerelease) + fi + gh release create "$RELEASE_TAG" "${release_args[@]}" "${assets[@]}" + fi + + - name: Publish updater feed last + shell: bash + run: | + set -euo pipefail + if ! gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/update-feed" >/dev/null 2>&1; then + release_sha="$(git rev-parse HEAD)" + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref=refs/heads/update-feed -f sha="$release_sha" + fi + content="$(base64 -w0 latest.json)" + current_sha="$(gh api "repos/${GITHUB_REPOSITORY}/contents/latest.json?ref=update-feed" --jq .sha 2>/dev/null || true)" + args=(-f message="Update Tauri feed to $RELEASE_VERSION" -f content="$content" -f branch=update-feed) + if [[ -n "$current_sha" ]]; then + args+=(-f sha="$current_sha") + fi + gh api --method PUT "repos/${GITHUB_REPOSITORY}/contents/latest.json" "${args[@]}" diff --git a/.gitignore b/.gitignore index f706db5..dfcf379 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ src-tauri/target/ src-tauri/binaries/ src-tauri/startup-launcher/target/ +tools/updater-signature-verifier/target/ src-tauri/gen/ .certs/ .env @@ -12,3 +13,4 @@ Thumbs.db *.log src-tauri/icons/android/ src-tauri/icons/ios/ +src-tauri/tauri.release*.generated.json diff --git a/README.md b/README.md index 908b259..b963648 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,18 @@ cargo test --manifest-path src-tauri/Cargo.toml Rust tests use fake input adapters and never control the local pointer or keyboard. Native checks and unsigned bundles run on Windows and macOS in `.github/workflows/ci.yml`. -## macOS releases +## Production releases and signed updates Production macOS releases are Apple Silicon DMGs signed with an Apple-issued Developer ID Application certificate, submitted to Apple's notarization service, and stapled for offline Gatekeeper verification. A `v*` tag or manual release run creates or updates the matching GitHub Release after verifying the tag, app version, architecture, nested-code signatures, hardened runtime, secure timestamp, and notarization tickets. The production certificate and App Store Connect API key are held only in the GitHub `production` environment and imported into an ephemeral runner keychain. They are separate from the machine-local `Switchify PC Development` identity used by `npm run macos:run`. See [macOS production releases](docs/macos-releases.md) for certificate creation, GitHub configuration, release, recovery, and rotation instructions. +Packaged release builds check a dedicated Tauri feed at `update-feed/latest.json` shortly after startup, every six hours, and on demand. Settings shows availability, verified download progress, cancellation, retry, installation, and restart state. Concurrent checks, downloads, and installs are deduplicated. Development and unsigned CI builds intentionally keep the updater unconfigured and report that state without crashing. + +The `Release Switchify PC` workflow accepts existing `v` tags, renders release-only updater configuration, and publishes signed Apple-silicon macOS and x64 Windows artifacts into one GitHub release. It updates the dedicated feed only after both platform packages pass verification. It does not alter the public C# `v0.10.0` release or its `latest.yml` feed. + +Before the workflow can run, configure `TAURI_UPDATER_PUBLIC_KEY`, `APPLE_SIGNING_IDENTITY`, and `CERTUM_CERT_THUMBPRINT` as repository variables; configure the Tauri updater private key/password and platform signing credentials as protected secrets. The Windows job targets the self-hosted signing runner with SimplySign available. Private signing material is never generated by or committed to this repository. + ## Diagnostics Switchify keeps up to 500 sanitized diagnostic events locally in `diagnostic-history.jsonl`. The history covers application startup, Bluetooth and Accessibility transitions, disconnects, runtime failures, and update checks. It never stores typed text, command payloads, pairing secrets, device names, or full paths; malformed or unwritable history is ignored so diagnostics cannot prevent startup. @@ -92,7 +98,7 @@ Existing public Git history, tags, releases, update metadata, and installer down ## Development boundaries -- Pull-request and `main` macOS CI remains unsigned. Only authorized release tags and manual recovery runs can access the production Developer ID and notarization credentials. Windows production packages continue to use the locally available Certum/SimplySign identity and are not published automatically. +- The macOS development identity is local-only. Production Developer ID signing, notarization, Certum/SimplySign signing, and release publication run only through the credential-gated release workflow; ordinary CI remains unsigned. - Linux may appear in capability data but is not a supported Bluetooth target. - Windows Grid 3 output uses the native `Sensory_SwitchInput` broadcast contract. Grid 3 is omitted from macOS capabilities and profiles. -- Update installation requires a signed Tauri update feed. Local development builds can only report updater configuration errors. +- Update installation requires the credential-gated signed Tauri feed. Local development builds can only report updater configuration errors. diff --git a/docs/macos-releases.md b/docs/macos-releases.md index f064047..19a7210 100644 --- a/docs/macos-releases.md +++ b/docs/macos-releases.md @@ -26,6 +26,8 @@ Configure these environment secrets: | `APPLE_API_KEY` | App Store Connect API key ID | | `APPLE_API_PRIVATE_KEY` | Complete contents of the downloaded `.p8` file | | `TIMBERLOGS_API_KEY` | Production telemetry API key | +| `TAURI_SIGNING_PRIVATE_KEY` | Password-protected Tauri updater private key | +| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the updater private key | Configure these environment variables: @@ -35,6 +37,8 @@ Configure these environment variables: | `APPLE_TEAM_ID` | Apple Developer team ID from the identity | | `TIMBERLOGS_ENDPOINT` | Production HTTPS telemetry endpoint | +Configure `TAURI_UPDATER_PUBLIC_KEY` as a repository variable. Keep an encrypted offline backup of the updater private key and password: installed applications cannot accept future updates if that key is lost. + Encode the certificate without line wrapping on macOS: ```bash @@ -45,11 +49,9 @@ The workflow writes credentials only beneath the ephemeral runner directory, imp ## Publish a release -Keep `package.json` and `src-tauri/tauri.conf.json` versions identical, then create and push the corresponding tag, for example `v1.0.0-beta.1`. The release workflow checks out that exact tag, builds on an Apple Silicon runner, lets Tauri sign and notarize the app, then separately submits and staples the finished signed DMG. It validates Gatekeeper and both stapled tickets before creating or updating the matching GitHub Release. - -The workflow can be manually dispatched with an existing tag to recover or replace a macOS asset. It never modifies earlier tags or release assets. The published DMG is accompanied by `SHA256SUMS-macos.txt`. +Keep `package.json`, `src-tauri/tauri.conf.json`, and `src-tauri/Cargo.toml` versions identical, then create and push the corresponding tag, for example `v1.0.0-beta.1`. The release workflow checks out that exact tag, builds on an Apple Silicon runner, lets Tauri sign and notarize the app, then separately submits and staples the finished signed DMG. The Windows job runs on the `switchify-signing` self-hosted runner with SimplySign authenticated. Both packages and updater signatures must pass verification before the matching GitHub Release and `update-feed/latest.json` are updated. -Updater feed generation and updater signing are intentionally separate work. Publishing a DMG does not make the in-app updater functional. +The workflow can be manually dispatched with an existing tag to recover or replace assets for that tag. It never modifies earlier tags or their assets. Checksums accompany both platform installers. ## Rotation and troubleshooting diff --git a/package.json b/package.json index 03e2fb8..70ccff9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", - "test": "vitest run", + "test": "vitest run && node --test scripts/create-update-feed.node-test.mjs", "lint": "tsc --noEmit", "macos:setup-signing": "./scripts/setup-macos-dev-signing.sh", "macos:run": "./scripts/run-macos-signed.sh", diff --git a/scripts/Build-WindowsUiAccess.ps1 b/scripts/Build-WindowsUiAccess.ps1 index e8a126f..5135eab 100644 --- a/scripts/Build-WindowsUiAccess.ps1 +++ b/scripts/Build-WindowsUiAccess.ps1 @@ -1,4 +1,7 @@ -param([switch]$SkipSign) +param( + [switch]$SkipSign, + [string]$TauriConfig = 'src-tauri/tauri.windows-uiaccess.conf.json' +) $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot @@ -29,7 +32,7 @@ try { & (Join-Path $PSScriptRoot 'Sign-Windows.ps1') $sidecar } - & $npm run tauri build -- --bundles nsis --config src-tauri/tauri.windows-uiaccess.conf.json + & $npm run tauri build -- --bundles nsis --config $TauriConfig if ($LASTEXITCODE -ne 0) { throw "Tauri package build failed with exit code $LASTEXITCODE." } if (-not $SkipSign) { diff --git a/scripts/create-update-feed.mjs b/scripts/create-update-feed.mjs new file mode 100644 index 0000000..6a2f8da --- /dev/null +++ b/scripts/create-update-feed.mjs @@ -0,0 +1,112 @@ +import { + existsSync, + lstatSync, + openSync, + closeSync, + readFileSync, + readSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +const regularFiles = (directory) => { + if (!existsSync(directory) || !lstatSync(directory).isDirectory()) { + throw new Error(`Expected release artifact directory ${directory}.`); + } + return readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => join(directory, entry.name)); +}; + +const pickSignedArtifact = (directory, predicate, label) => { + const files = regularFiles(directory); + const matches = files.filter((path) => predicate(basename(path)) && existsSync(`${path}.sig`)); + if (matches.length !== 1) { + throw new Error(`Expected one signed ${label} artifact, found ${matches.length}.`); + } + if (!lstatSync(`${matches[0]}.sig`).isFile()) { + throw new Error(`Updater signature is not a regular file for ${basename(matches[0])}.`); + } + return matches[0]; +}; + +const validateMacArtifact = (path) => { + const header = Buffer.alloc(2); + const file = openSync(path, "r"); + try { + if (readSync(file, header, 0, header.length, 0) !== header.length || header[0] !== 0x1f || header[1] !== 0x8b) { + throw new Error(`macOS updater artifact is not a gzip archive: ${basename(path)}.`); + } + } finally { + closeSync(file); + } +}; + +const validateWindowsArtifact = (path) => { + const dosHeader = Buffer.alloc(64); + const file = openSync(path, "r"); + try { + if (readSync(file, dosHeader, 0, dosHeader.length, 0) !== dosHeader.length || dosHeader.toString("ascii", 0, 2) !== "MZ") { + throw new Error(`Windows updater artifact is not a PE executable: ${basename(path)}.`); + } + const peOffset = dosHeader.readUInt32LE(0x3c); + const peHeader = Buffer.alloc(4); + if (peOffset < dosHeader.length || readSync(file, peHeader, 0, peHeader.length, peOffset) !== peHeader.length || !peHeader.equals(Buffer.from("PE\0\0"))) { + throw new Error(`Windows updater artifact has an invalid PE header: ${basename(path)}.`); + } + } finally { + closeSync(file); + } +}; + +const verifyWithExecutable = (verifier, artifact, signature) => { + execFileSync(resolve(verifier), [artifact, signature], { stdio: "pipe" }); +}; + +export const createUpdateFeed = ({ root, version, tag, output, verifier, verifySignature }) => { + if (!SEMVER.test(version)) throw new Error("Invalid semantic version."); + if (tag !== `v${version}`) throw new Error(`Release tag ${tag} does not match v${version}.`); + + const artifactRoot = resolve(root); + const mac = pickSignedArtifact(join(artifactRoot, "macos-release"), (name) => name.endsWith(".app.tar.gz"), "macOS"); + const windows = pickSignedArtifact(join(artifactRoot, "windows-release"), (name) => name.endsWith("-setup.exe"), "Windows NSIS"); + validateMacArtifact(mac); + validateWindowsArtifact(windows); + + const verify = verifySignature ?? ((artifact, signature) => verifyWithExecutable(verifier, artifact, signature)); + const platform = (path) => { + const signaturePath = `${path}.sig`; + const signature = readFileSync(signaturePath, "utf8").trim(); + if (!signature) throw new Error(`Updater signature is empty for ${basename(path)}.`); + verify(path, signaturePath); + return { + signature, + url: `https://github.com/switchifyapp/switchify-pc/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(basename(path))}`, + }; + }; + + const feed = { + version, + notes: `Switchify PC ${version}`, + pub_date: new Date().toISOString(), + platforms: { + "darwin-aarch64": platform(mac), + "windows-x86_64": platform(windows), + }, + }; + writeFileSync(resolve(output), `${JSON.stringify(feed, null, 2)}\n`); + return feed; +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [root, version, tag, output, verifier] = process.argv.slice(2); + if (!root || !version || !tag || !output || !verifier) { + throw new Error("Usage: node scripts/create-update-feed.mjs "); + } + createUpdateFeed({ root, version, tag, output, verifier }); +} diff --git a/scripts/create-update-feed.node-test.mjs b/scripts/create-update-feed.node-test.mjs new file mode 100644 index 0000000..87b31cf --- /dev/null +++ b/scripts/create-update-feed.node-test.mjs @@ -0,0 +1,77 @@ +import { afterEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { gzipSync } from "node:zlib"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createUpdateFeed } from "./create-update-feed.mjs"; + +const temporaryDirectories = []; +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +const fixtures = () => { + const root = mkdtempSync(join(tmpdir(), "switchify-feed-")); + temporaryDirectories.push(root); + const macDirectory = join(root, "macos-release"); + const windowsDirectory = join(root, "windows-release"); + mkdirSync(macDirectory); + mkdirSync(windowsDirectory); + const mac = join(macDirectory, "Switchify.PC.app.tar.gz"); + const windows = join(windowsDirectory, "Switchify.PC_1.0.0_x64-setup.exe"); + writeFileSync(mac, gzipSync("archive fixture")); + const pe = Buffer.alloc(128); + pe.write("MZ", 0, "ascii"); + pe.writeUInt32LE(64, 0x3c); + pe.write("PE\0\0", 64, "ascii"); + writeFileSync(windows, pe); + writeFileSync(`${mac}.sig`, "mac-signature"); + writeFileSync(`${windows}.sig`, "windows-signature"); + return { root, mac, windows, output: join(root, "latest.json") }; +}; + +const options = (fixture, verifySignature = () => {}) => ({ + ...fixture, + version: "1.0.0-beta.1", + tag: "v1.0.0-beta.1", + verifySignature, +}); + +describe("createUpdateFeed", () => { + it("publishes only structurally valid artifacts whose signatures verify", () => { + const fixture = fixtures(); + const verified = []; + const feed = createUpdateFeed(options(fixture, (artifact) => verified.push(artifact))); + assert.deepEqual(verified, [fixture.mac, fixture.windows]); + assert.equal(feed.platforms["darwin-aarch64"].signature, "mac-signature"); + assert.equal(feed.platforms["windows-x86_64"].signature, "windows-signature"); + }); + + it("refuses a non-empty signature that fails cryptographic verification", () => { + const fixture = fixtures(); + assert.throws( + () => createUpdateFeed(options(fixture, () => { throw new Error("invalid signature"); })), + /invalid signature/, + ); + assert.equal(existsSync(fixture.output), false); + }); + + it("refuses a wrong-format payload even if its signature verifier succeeds", () => { + const fixture = fixtures(); + writeFileSync(fixture.mac, "not an updater archive"); + assert.throws(() => createUpdateFeed(options(fixture)), /not a gzip archive/); + assert.equal(existsSync(fixture.output), false); + }); + + it("does not search outside the expected platform artifact directories", () => { + const fixture = fixtures(); + rmSync(fixture.mac); + rmSync(`${fixture.mac}.sig`); + const unexpectedDirectory = join(fixture.root, "renamed-download"); + mkdirSync(unexpectedDirectory); + writeFileSync(join(unexpectedDirectory, "Switchify.PC.app.tar.gz"), gzipSync("archive")); + writeFileSync(join(unexpectedDirectory, "Switchify.PC.app.tar.gz.sig"), "signature"); + assert.throws(() => createUpdateFeed(options(fixture)), /Expected one signed macOS artifact, found 0/); + }); +}); diff --git a/scripts/render-updater-config.mjs b/scripts/render-updater-config.mjs new file mode 100644 index 0000000..f446b3f --- /dev/null +++ b/scripts/render-updater-config.mjs @@ -0,0 +1,36 @@ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const output = process.argv[2]; +const windows = process.argv.includes("--windows"); +const endpoint = process.env.SWITCHIFY_UPDATER_ENDPOINT + ?? "https://raw.githubusercontent.com/switchifyapp/switchify-pc/update-feed/latest.json"; +const pubkey = process.env.SWITCHIFY_UPDATER_PUBLIC_KEY?.trim(); + +if (!output) throw new Error("Usage: node scripts/render-updater-config.mjs [--windows]"); +if (!pubkey) throw new Error("SWITCHIFY_UPDATER_PUBLIC_KEY is required."); +let endpointUrl; +try { + endpointUrl = new URL(endpoint); +} catch { + throw new Error("SWITCHIFY_UPDATER_ENDPOINT must be a valid URL."); +} +if (endpointUrl.protocol !== "https:") throw new Error("SWITCHIFY_UPDATER_ENDPOINT must use HTTPS."); + +const config = { + plugins: { updater: { endpoints: [endpoint], pubkey } }, + bundle: { createUpdaterArtifacts: true }, +}; + +if (windows) { + config.bundle.externalBin = ["binaries/switchify-pc-startup"]; + config.bundle.windows = { + signCommand: { + cmd: "powershell.exe", + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "../scripts/Sign-Windows.ps1", "%1"], + }, + nsis: { installMode: "perMachine", installerHooks: "windows/installer-hooks.nsh" }, + }; +} + +writeFileSync(resolve(output), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 48df830..e94e786 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod protocol; mod state; mod storage; mod telemetry; +mod updater; #[cfg(target_os = "windows")] mod windows_runtime; #[cfg(target_os = "windows")] @@ -31,6 +32,10 @@ use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_autostart::ManagerExt as AutostartManagerExt; use tauri_plugin_updater::UpdaterExt; use telemetry::TelemetryConsent; +use updater::{ + download_with_cancel, DownloadResult, Operation as UpdateOperation, RetryAction, + UpdateArtifact, UpdateManager, UpdateView, +}; #[tauri::command] fn get_app_state(model: State<'_, AppModel>) -> AppState { @@ -715,34 +720,264 @@ fn delete_switch_profile( Ok(list_switch_profiles(model)) } -#[tauri::command] -async fn check_for_updates(app: AppHandle, model: State<'_, AppModel>) -> Result { - if !updater_has_endpoints(app.config().plugins.0.get("updater")) { +fn publish_update(app: &AppHandle, model: &AppModel, update: UpdateView) -> AppState { + model + .shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .state + .updater = update; + state::emit_state(app, &model.shared); + model.snapshot() +} + +fn update_failure( + app: &AppHandle, + model: &AppModel, + version: Option, + retry: RetryAction, + context: &str, + error: &str, +) -> AppState { + let message = format!("{context}: {error}"); + state::set_activity(&model.shared, ActivityKind::Error, &message); + model.record_updater("failed", Some(error)); + publish_update(app, model, UpdateView::failed(version, message, retry)) +} + +async fn check_for_updates_inner(app: &AppHandle) -> AppState { + let model = app.state::(); + let manager = app.state::(); + if !updater_is_configured(app.config().plugins.0.get("updater")) { state::set_activity( &model.shared, ActivityKind::Info, "Updates are not configured for this build.", ); - model.record_updater("unavailable", Some("update endpoints are not configured")); + model.record_updater( + "unavailable", + Some("update endpoint or public key is missing"), + ); + return publish_update(app, &model, UpdateView::unconfigured()); + } + if manager.has_download() { + return model.snapshot(); + } + if !manager.begin(UpdateOperation::Check) { + return model.snapshot(); + } + publish_update(app, &model, UpdateView::checking()); + let result = match app.updater() { + Ok(updater) => updater.check().await.map_err(|error| error.to_string()), + Err(error) => Err(error.to_string()), + }; + manager.finish(UpdateOperation::Check); + match result { + Ok(Some(update)) => { + let version = update.version().to_owned(); + manager.replace_available(Some(update)); + state::set_activity( + &model.shared, + ActivityKind::Info, + format!("Switchify PC {version} is available."), + ); + model.record_updater("available", None); + publish_update(app, &model, UpdateView::available(version)) + } + Ok(None) => { + manager.replace_available(None); + state::set_activity( + &model.shared, + ActivityKind::Success, + "Switchify PC is up to date.", + ); + model.record_updater("current", None); + publish_update(app, &model, UpdateView::current()) + } + Err(error) => update_failure( + app, + &model, + None, + RetryAction::Check, + "Update check failed", + &error, + ), + } +} + +#[tauri::command] +async fn check_for_updates(app: AppHandle) -> Result { + Ok(check_for_updates_inner(&app).await) +} + +#[tauri::command] +async fn download_update(app: AppHandle) -> Result { + let model = app.state::(); + let manager = app.state::(); + if !manager.begin(UpdateOperation::Download) { return Ok(model.snapshot()); } - let updater = match app.updater() { - Ok(updater) => updater, - Err(error) => return Err(record_update_failure(&model, &error.to_string())), + let Some(update) = manager.available() else { + manager.finish(UpdateOperation::Download); + return Ok(update_failure( + &app, + &model, + None, + RetryAction::Check, + "Update download could not start", + "check for an update first", + )); }; - let update = match updater.check().await { - Ok(update) => update, - Err(error) => return Err(record_update_failure(&model, &error.to_string())), + let version = update.version().to_owned(); + let (cancel_sender, cancel_receiver) = tokio::sync::watch::channel(false); + manager.set_download_cancel(cancel_sender); + state::set_activity( + &model.shared, + ActivityKind::Info, + format!("Downloading Switchify PC {version}…"), + ); + publish_update(&app, &model, UpdateView::downloading(version.clone())); + + let progress_app = app.clone(); + let progress_shared = model.shared.clone(); + let result = download_with_cancel(&update, cancel_receiver, move |chunk, total| { + { + let mut data = progress_shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + data.state.updater.add_progress(chunk, total); + } + state::emit_state(&progress_app, &progress_shared); + }); + let result = result.await; + manager.finish(UpdateOperation::Download); + match result { + DownloadResult::Complete(Ok(bytes)) => { + let downloaded_bytes = bytes.len() as u64; + let total_bytes = model.snapshot().updater.total_bytes; + manager.store_download(bytes); + state::set_activity( + &model.shared, + ActivityKind::Success, + format!("Switchify PC {version} is ready to install."), + ); + model.record_updater("ready", None); + Ok(publish_update( + &app, + &model, + UpdateView::ready(version, downloaded_bytes, total_bytes), + )) + } + DownloadResult::Complete(Err(error)) => Ok(update_failure( + &app, + &model, + Some(version), + RetryAction::Download, + "Update download failed", + &error, + )), + DownloadResult::Cancelled => { + state::set_activity( + &model.shared, + ActivityKind::Info, + "Update download cancelled.", + ); + model.record_updater("cancelled", None); + Ok(publish_update(&app, &model, UpdateView::cancelled(version))) + } + } +} + +#[tauri::command] +fn cancel_update_download( + app: AppHandle, + model: State<'_, AppModel>, + manager: State<'_, UpdateManager>, +) -> AppState { + if manager.cancel_download() { + state::set_activity( + &model.shared, + ActivityKind::Info, + "Cancelling update download…", + ); + state::emit_state(&app, &model.shared); + } + model.snapshot() +} + +#[tauri::command] +async fn install_update(app: AppHandle) -> Result { + let model = app.state::(); + let manager = app.state::(); + if !manager.begin(UpdateOperation::Install) { + return Ok(model.snapshot()); + } + let Some(update) = manager.available() else { + manager.finish(UpdateOperation::Install); + return Ok(update_failure( + &app, + &model, + None, + RetryAction::Check, + "Update installation could not start", + "check for an update first", + )); }; - let message = update.map_or_else( - || "Switchify PC is up to date.".to_string(), - |update| format!("Switchify PC {} is available.", update.version), + let Some(bytes) = manager.take_download() else { + manager.finish(UpdateOperation::Install); + return Ok(update_failure( + &app, + &model, + Some(update.version().to_owned()), + RetryAction::Download, + "Update installation could not start", + "download the update first", + )); + }; + let version = update.version().to_owned(); + let downloaded_bytes = bytes.len() as u64; + let total_bytes = model.snapshot().updater.total_bytes; + let overlay = app.state::(); + let modifier_overlay = app.state::(); + if let Err(error) = disconnect_all_inner(&app, &model, &overlay, &modifier_overlay) { + manager.store_download(bytes); + manager.finish(UpdateOperation::Install); + return Ok(update_failure( + &app, + &model, + Some(version), + RetryAction::Install, + "Update installation failed", + &error, + )); + } + state::set_activity( + &model.shared, + ActivityKind::Info, + format!("Installing Switchify PC {version}…"), ); - state::set_activity(&model.shared, ActivityKind::Info, message); - model.record_updater("checked", None); - Ok(model.snapshot()) + publish_update( + &app, + &model, + UpdateView::applying(version.clone(), downloaded_bytes, total_bytes), + ); + if let Err(error) = UpdateArtifact::install(&update, &bytes) { + manager.store_download(bytes); + manager.finish(UpdateOperation::Install); + return Ok(update_failure( + &app, + &model, + Some(version), + RetryAction::Install, + "Update installation failed", + &error, + )); + } + model.record_updater("installed", None); + app.restart(); } +#[cfg(test)] fn record_update_failure(model: &AppModel, error: &str) -> String { state::set_activity( &model.shared, @@ -753,11 +988,26 @@ fn record_update_failure(model: &AppModel, error: &str) -> String { error.to_owned() } -fn updater_has_endpoints(config: Option<&serde_json::Value>) -> bool { - config +fn updater_is_configured(config: Option<&serde_json::Value>) -> bool { + let has_endpoints = config .and_then(|config| config.get("endpoints")) .and_then(serde_json::Value::as_array) - .is_some_and(|endpoints| !endpoints.is_empty()) + .is_some_and(|endpoints| !endpoints.is_empty()); + let has_public_key = config + .and_then(|config| config.get("pubkey")) + .and_then(serde_json::Value::as_str) + .is_some_and(|key| !key.trim().is_empty()); + has_endpoints && has_public_key +} + +fn start_update_scheduler(app: AppHandle) { + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + loop { + let _ = check_for_updates_inner(&app).await; + tokio::time::sleep(std::time::Duration::from_secs(6 * 60 * 60)).await; + } + }); } #[tauri::command] @@ -900,10 +1150,16 @@ pub fn run() { ) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(model) + .manage(UpdateManager::::default()) .manage(PendingProfileExit::default()) .manage(PendingNavigation::default()) .setup(move |app| { install_tray(app)?; + if updater_is_configured(app.config().plugins.0.get("updater")) { + let model = app.state::(); + publish_update(app.handle(), &model, UpdateView::idle()); + start_update_scheduler(app.handle().clone()); + } { let model = app.state::(); let state = model.snapshot(); @@ -998,6 +1254,9 @@ pub fn run() { cancel_profile_exit, take_navigation_request, check_for_updates, + download_update, + cancel_update_download, + install_update, export_diagnostics ]) .run(tauri::generate_context!()) @@ -1077,7 +1336,7 @@ fn platform_disconnect_all(app: &AppHandle, shared: &state::SharedModel) -> Resu #[cfg(test)] mod tests { use super::{ - has_start_hidden_argument, record_update_failure, updater_has_endpoints, validate_profile, + has_start_hidden_argument, record_update_failure, updater_is_configured, validate_profile, PendingNavigation, PendingProfileExit, ProfileExitAction, TraySnapshot, NAVIGATE_REQUESTED_EVENT, }; @@ -1106,12 +1365,16 @@ mod tests { #[test] fn update_checks_require_a_configured_endpoint() { - assert!(!updater_has_endpoints(None)); - assert!(!updater_has_endpoints(Some(&json!({ + assert!(!updater_is_configured(None)); + assert!(!updater_is_configured(Some(&json!({ "endpoints": [], "pubkey": "" })))); - assert!(updater_has_endpoints(Some(&json!({ + assert!(!updater_is_configured(Some(&json!({ + "endpoints": ["https://updates.example.com/latest.json"], + "pubkey": "" + })))); + assert!(updater_is_configured(Some(&json!({ "endpoints": ["https://updates.example.com/latest.json"], "pubkey": "test-key" })))); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 793472f..33a101d 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -9,6 +9,7 @@ use crate::diagnostics::{DiagnosticHistory, DiagnosticSummary}; use crate::protocol::{PendingPairingSummary, ProtocolEngine}; use crate::storage::{AppStorage, PersistedState}; use crate::telemetry::{TelemetryConsent, TelemetryService, TelemetryView}; +use crate::updater::UpdateView; pub const APP_STATE_EVENT: &str = "app-state-changed"; @@ -263,6 +264,7 @@ pub struct AppState { pub diagnostics: DiagnosticSummary, pub telemetry: TelemetryView, pub setup: SetupState, + pub updater: UpdateView, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -370,6 +372,7 @@ impl AppModel { completed: saved.setup_completed, auto_open_eligible: setup_auto_open_eligible, }, + updater: UpdateView::default(), }, })); let model = Self { diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs new file mode 100644 index 0000000..cf9538c --- /dev/null +++ b/src-tauri/src/updater.rs @@ -0,0 +1,486 @@ +use std::{future::Future, pin::Pin, sync::Mutex}; + +use serde::Serialize; +use tauri_plugin_updater::Update; +use tokio::sync::watch; + +type DownloadFuture<'a> = Pin, String>> + Send + 'a>>; + +pub trait UpdateArtifact: Clone + Send + Sync + 'static { + fn version(&self) -> &str; + fn download<'a>( + &'a self, + on_chunk: Box) + Send + 'a>, + ) -> DownloadFuture<'a>; + fn install(&self, bytes: &[u8]) -> Result<(), String>; +} + +impl UpdateArtifact for Update { + fn version(&self) -> &str { + &self.version + } + + fn download<'a>( + &'a self, + on_chunk: Box) + Send + 'a>, + ) -> DownloadFuture<'a> { + Box::pin(async move { + Update::download(self, on_chunk, || {}) + .await + .map_err(|error| error.to_string()) + }) + } + + fn install(&self, bytes: &[u8]) -> Result<(), String> { + Update::install(self, bytes).map_err(|error| error.to_string()) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum DownloadResult { + Complete(Result, String>), + Cancelled, +} + +pub async fn download_with_cancel( + update: &U, + mut cancel_receiver: watch::Receiver, + on_chunk: F, +) -> DownloadResult +where + U: UpdateArtifact, + F: FnMut(usize, Option) + Send, +{ + let mut download = update.download(Box::new(on_chunk)); + tokio::select! { + result = &mut download => DownloadResult::Complete(result), + changed = cancel_receiver.changed() => { + if changed.is_ok() && *cancel_receiver.borrow() { + DownloadResult::Cancelled + } else { + DownloadResult::Complete(download.await) + } + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum UpdateStatus { + Unconfigured, + Idle, + Checking, + Available, + Downloading, + ReadyToInstall, + Applying, + Current, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RetryAction { + Check, + Download, + Install, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UpdateView { + pub status: UpdateStatus, + pub version: Option, + pub downloaded_bytes: u64, + pub total_bytes: Option, + pub error: Option, + pub retry_action: Option, +} + +impl Default for UpdateView { + fn default() -> Self { + Self::unconfigured() + } +} + +impl UpdateView { + pub fn unconfigured() -> Self { + Self::new(UpdateStatus::Unconfigured) + } + + pub fn idle() -> Self { + Self::new(UpdateStatus::Idle) + } + + pub fn checking() -> Self { + Self::new(UpdateStatus::Checking) + } + + pub fn current() -> Self { + Self::new(UpdateStatus::Current) + } + + pub fn available(version: String) -> Self { + Self { + status: UpdateStatus::Available, + version: Some(version), + ..Self::new(UpdateStatus::Available) + } + } + + pub fn downloading(version: String) -> Self { + Self { + status: UpdateStatus::Downloading, + version: Some(version), + ..Self::new(UpdateStatus::Downloading) + } + } + + pub fn ready(version: String, downloaded_bytes: u64, total_bytes: Option) -> Self { + Self { + status: UpdateStatus::ReadyToInstall, + version: Some(version), + downloaded_bytes, + total_bytes, + error: None, + retry_action: None, + } + } + + pub fn applying(version: String, downloaded_bytes: u64, total_bytes: Option) -> Self { + Self { + status: UpdateStatus::Applying, + version: Some(version), + downloaded_bytes, + total_bytes, + error: None, + retry_action: None, + } + } + + pub fn failed(version: Option, error: String, retry_action: RetryAction) -> Self { + Self { + status: UpdateStatus::Failed, + version, + downloaded_bytes: 0, + total_bytes: None, + error: Some(error), + retry_action: Some(retry_action), + } + } + + pub fn cancelled(version: String) -> Self { + Self { + status: UpdateStatus::Cancelled, + version: Some(version), + downloaded_bytes: 0, + total_bytes: None, + error: None, + retry_action: Some(RetryAction::Download), + } + } + + pub fn add_progress(&mut self, chunk: usize, total: Option) { + self.downloaded_bytes = self.downloaded_bytes.saturating_add(chunk as u64); + if total.is_some() { + self.total_bytes = total; + } + } + + fn new(status: UpdateStatus) -> Self { + Self { + status, + version: None, + downloaded_bytes: 0, + total_bytes: None, + error: None, + retry_action: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Operation { + Check, + Download, + Install, +} + +#[derive(Default)] +struct RuntimeData { + active: Option, + available: Option, + downloaded: Option>, + cancel_download: Option>, +} + +pub struct UpdateManager(Mutex>); + +impl Default for UpdateManager { + fn default() -> Self { + Self(Mutex::new(RuntimeData { + active: None, + available: None, + downloaded: None, + cancel_download: None, + })) + } +} + +impl UpdateManager { + pub fn begin(&self, operation: Operation) -> bool { + let mut data = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if data.active.is_some() { + return false; + } + data.active = Some(operation); + true + } + + pub fn finish(&self, operation: Operation) { + let mut data = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if data.active == Some(operation) { + data.active = None; + } + if operation == Operation::Download { + data.cancel_download = None; + } + } + + pub fn replace_available(&self, update: Option) { + let mut data = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + data.available = update; + data.downloaded = None; + } + + pub fn available(&self) -> Option { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .available + .clone() + } + + pub fn store_download(&self, bytes: Vec) { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .downloaded = Some(bytes); + } + + pub fn take_download(&self) -> Option> { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .downloaded + .take() + } + + pub fn has_download(&self) -> bool { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .downloaded + .is_some() + } + + pub fn set_download_cancel(&self, sender: watch::Sender) { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .cancel_download = Some(sender); + } + + pub fn cancel_download(&self) -> bool { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .cancel_download + .as_ref() + .is_some_and(|sender| sender.send(true).is_ok()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use tokio::sync::Notify; + + use super::*; + + #[derive(Clone)] + struct FakeUpdate { + version: String, + chunks: Vec>, + download_error: Option, + install_error: Option, + installed: Arc>>>, + gate: Option>, + } + + impl FakeUpdate { + fn successful(chunks: Vec>) -> Self { + Self { + version: "2.0.0".into(), + chunks, + download_error: None, + install_error: None, + installed: Arc::new(Mutex::new(Vec::new())), + gate: None, + } + } + } + + impl UpdateArtifact for FakeUpdate { + fn version(&self) -> &str { + &self.version + } + + fn download<'a>( + &'a self, + mut on_chunk: Box) + Send + 'a>, + ) -> DownloadFuture<'a> { + Box::pin(async move { + if let Some(gate) = &self.gate { + gate.notified().await; + } + if let Some(error) = &self.download_error { + return Err(error.clone()); + } + let total = self.chunks.iter().map(Vec::len).sum::() as u64; + let mut bytes = Vec::new(); + for chunk in &self.chunks { + on_chunk(chunk.len(), Some(total)); + bytes.extend_from_slice(chunk); + } + Ok(bytes) + }) + } + + fn install(&self, bytes: &[u8]) -> Result<(), String> { + if let Some(error) = &self.install_error { + return Err(error.clone()); + } + self.installed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(bytes.to_vec()); + Ok(()) + } + } + + #[test] + fn transitions_expose_progress_retry_and_cancellation() { + let mut downloading = UpdateView::downloading("2.0.0".into()); + downloading.add_progress(25, Some(100)); + downloading.add_progress(30, None); + assert_eq!(downloading.downloaded_bytes, 55); + assert_eq!(downloading.total_bytes, Some(100)); + assert_eq!( + UpdateView::ready("2.0.0".into(), 100, Some(100)).status, + UpdateStatus::ReadyToInstall + ); + assert_eq!( + UpdateView::cancelled("2.0.0".into()).retry_action, + Some(RetryAction::Download) + ); + assert_eq!( + UpdateView::failed(None, "offline".into(), RetryAction::Check).retry_action, + Some(RetryAction::Check) + ); + } + + #[test] + fn operation_gate_deduplicates_concurrent_work() { + let manager = UpdateManager::::default(); + assert!(manager.begin(Operation::Check)); + assert!(!manager.begin(Operation::Check)); + assert!(!manager.begin(Operation::Install)); + manager.finish(Operation::Check); + assert!(manager.begin(Operation::Install)); + manager.store_download(vec![1, 2, 3]); + assert!(manager.has_download()); + } + + #[tokio::test] + async fn cancellation_signal_is_one_shot_and_non_blocking() { + let manager = UpdateManager::::default(); + let (sender, mut receiver) = watch::channel(false); + manager.set_download_cancel(sender); + assert!(manager.cancel_download()); + receiver.changed().await.unwrap(); + assert!(*receiver.borrow()); + manager.finish(Operation::Download); + assert!(!manager.cancel_download()); + } + + #[tokio::test] + async fn fake_artifact_download_reports_progress_and_installs_exact_bytes() { + let update = FakeUpdate::successful(vec![vec![1, 2], vec![3, 4, 5]]); + let (_cancel, receiver) = watch::channel(false); + let progress = Arc::new(Mutex::new(Vec::new())); + let progress_events = progress.clone(); + let result = download_with_cancel(&update, receiver, move |chunk, total| { + progress_events + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push((chunk, total)); + }) + .await; + assert_eq!(result, DownloadResult::Complete(Ok(vec![1, 2, 3, 4, 5]))); + assert_eq!( + *progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + vec![(2, Some(5)), (3, Some(5))] + ); + update.install(&[1, 2, 3]).unwrap(); + assert_eq!( + *update + .installed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + vec![vec![1, 2, 3]] + ); + } + + #[tokio::test] + async fn fake_artifact_exposes_download_and_install_failures() { + let mut update = FakeUpdate::successful(Vec::new()); + update.download_error = Some("network unavailable".into()); + update.install_error = Some("installer rejected".into()); + let (_cancel, receiver) = watch::channel(false); + assert_eq!( + download_with_cancel(&update, receiver, |_, _| {}).await, + DownloadResult::Complete(Err("network unavailable".into())) + ); + assert_eq!(update.install(&[1]), Err("installer rejected".into())); + } + + #[tokio::test] + async fn cancellation_drops_an_incomplete_fake_download() { + let gate = Arc::new(Notify::new()); + let mut update = FakeUpdate::successful(vec![vec![1]]); + update.gate = Some(gate); + let (cancel, receiver) = watch::channel(false); + let download = download_with_cancel(&update, receiver, |_, _| {}); + tokio::pin!(download); + tokio::select! { + result = &mut download => panic!("download finished before cancellation: {result:?}"), + () = tokio::task::yield_now() => {} + } + cancel.send(true).unwrap(); + assert_eq!(download.await, DownloadResult::Cancelled); + } +} diff --git a/src/App.test.tsx b/src/App.test.tsx index d375de7..99f01d6 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -18,6 +18,7 @@ describe("Switchify PC shell", () => { browserState.connectedDeviceName = null; browserState.diagnostics = { recentBluetooth: [], lastDisconnect: null, recentErrors: [] }; browserState.telemetry = { consent: "undecided", available: true }; + browserState.updater = { status: "unconfigured", version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; browserState.setup = { shown: true, completed: false, autoOpenEligible: false }; }); @@ -57,6 +58,52 @@ describe("Switchify PC shell", () => { checkForUpdates.mockRestore(); }); + it("shows update progress and exposes cancellation in Settings", async () => { + browserState.updater = { status: "downloading", version: "1.0.0-beta.2", downloadedBytes: 50, totalBytes: 200, error: null, retryAction: null }; + const cancel = vi.spyOn(api, "cancelUpdateDownload").mockResolvedValue(structuredClone(browserState)); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + + expect(screen.getByRole("status")).toHaveTextContent("Downloading Switchify PC 1.0.0-beta.2"); + expect(screen.getByRole("progressbar", { name: "Update download progress" })).toHaveAttribute("value", "50"); + expect(document.querySelector(".update-controls > span")).toHaveTextContent("25%"); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("offers the correct retry action after a failure", async () => { + browserState.updater = { status: "failed", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: "Download failed", retryAction: "download" }; + const download = vi.spyOn(api, "downloadUpdate").mockResolvedValue(structuredClone(browserState)); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByRole("alert")).toHaveTextContent("Download failed"); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(download).toHaveBeenCalledOnce(); + }); + + it("offers installation and restart when a download is ready", async () => { + browserState.updater = { status: "readyToInstall", version: "1.0.0-beta.2", downloadedBytes: 200, totalBytes: 200, error: null, retryAction: null }; + const install = vi.spyOn(api, "installUpdate").mockResolvedValue(structuredClone(browserState)); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + fireEvent.click(screen.getByRole("button", { name: "Install and restart" })); + expect(install).toHaveBeenCalledOnce(); + }); + + it("retries a cancelled download from the beginning", async () => { + browserState.updater = { status: "cancelled", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: "download" }; + const download = vi.spyOn(api, "downloadUpdate").mockResolvedValue(structuredClone(browserState)); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByRole("status")).toHaveTextContent("Download cancelled. You can retry when ready."); + fireEvent.click(screen.getByRole("button", { name: "Retry download" })); + expect(download).toHaveBeenCalledOnce(); + }); + it("routes tray navigation without discarding a dirty profile silently", async () => { let navigate: ((target: "home" | "settings" | "profiles") => void) | undefined; vi.spyOn(api, "onNavigateRequested").mockImplementation(async (handler) => { diff --git a/src/App.tsx b/src/App.tsx index 532df93..9ec932f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { ShieldCheck, SlidersHorizontal, Smartphone, Trash2, WifiOff, Wrench, X, } from "lucide-react"; import { api, type ProfileExitAction } from "./api"; -import type { AppSettings, AppState, PendingPairing, SwitchProfile } from "./types"; +import type { AppSettings, AppState, PendingPairing, SwitchProfile, UpdateState } from "./types"; type View = "home" | "devices" | "profiles" | "settings" | "support"; @@ -302,7 +302,45 @@ function applyLocalSettings(base: AppSettings, local: AppSettings, keys: Set void; chooseTelemetry: (enabled: boolean) => void; checkUpdates: () => void; busy: boolean }) { +type UpdateAction = "check" | "download" | "install"; + +function updateDescription(update: UpdateState) { + switch (update.status) { + case "unconfigured": return "Updates are unavailable in this build because its signed feed is not configured."; + case "idle": return "Automatic update checks are enabled."; + case "checking": return "Checking for updates…"; + case "available": return `Switchify PC ${update.version} is available.`; + case "downloading": return `Downloading Switchify PC ${update.version}…`; + case "readyToInstall": return `Switchify PC ${update.version} is ready to install.`; + case "applying": return `Installing Switchify PC ${update.version}…`; + case "current": return "Switchify PC is up to date."; + case "failed": return update.error ?? "The update operation failed."; + case "cancelled": return "Download cancelled. You can retry when ready."; + } +} + +function UpdateControls({ update, run, cancel }: { update: UpdateState; run: (action: UpdateAction) => void; cancel: () => void }) { + const percent = update.totalBytes && update.totalBytes > 0 + ? Math.min(100, Math.round(update.downloadedBytes * 100 / update.totalBytes)) + : null; + const action = update.status === "available" || update.status === "cancelled" ? "download" + : update.status === "readyToInstall" ? "install" + : update.status === "failed" ? update.retryAction + : update.status === "idle" || update.status === "current" || update.status === "unconfigured" ? "check" : null; + const label = update.status === "failed" ? "Retry" + : action === "download" ? (update.status === "cancelled" ? "Retry download" : "Download") + : action === "install" ? "Install and restart" : "Check for updates"; + return
+

{updateDescription(update)}

+ {update.status === "downloading" && <> + + {percent === null ? `${update.downloadedBytes.toLocaleString()} bytes` : `${percent}%`} + } +
{action && }{update.status === "downloading" && }{(update.status === "checking" || update.status === "applying") && }
+
; +} + +function SettingsView({ state, settings, onChange, chooseTelemetry, updateAction, cancelUpdate, busy }: { state: AppState; settings: AppSettings; onChange: (next: AppSettings) => void; chooseTelemetry: (enabled: boolean) => void; updateAction: (action: UpdateAction) => void; cancelUpdate: () => void; busy: boolean }) { const update = (key: K, value: AppSettings[K]) => onChange({ ...settings, [key]: value }); return

Settings

Startup, pointer, privacy, and updates

update("startWithSystem", value)} /> @@ -345,7 +383,7 @@ function SettingsView({ state, settings, onChange, chooseTelemetry, checkUpdates } update("shareDiagnostics", value)} />{state.telemetry.consent === "undecided" &&
}

{state.telemetry.available ? state.telemetry.consent === "undecided" ? "No choice recorded yet. Nothing is sent unless you choose Share diagnostics." : state.telemetry.consent === "enabled" ? "Consent recorded. You can turn this off at any time to delete queued reports." : "Opted out. No diagnostic reports are stored or sent." : "Diagnostic reporting is unavailable in this build."} Privacy policy

- +
; } @@ -594,6 +632,22 @@ export function App() { finally { setCheckingUpdates(false); } }; + const runUpdate = async (action: UpdateAction) => { + setError(null); + if (action === "check") setCheckingUpdates(true); + try { + const operation = action === "check" ? api.checkForUpdates : action === "download" ? api.downloadUpdate : api.installUpdate; + syncState(await operation()); + } catch (reason) { setError(String(reason)); } + finally { if (action === "check") setCheckingUpdates(false); } + }; + + const cancelUpdate = async () => { + setError(null); + try { syncState(await api.cancelUpdateDownload()); } + catch (reason) { setError(String(reason)); } + }; + const openSetup = () => { setSetupOpen(true); void perform(api.markSetupShown); @@ -709,7 +763,7 @@ export function App() { {view === "home" && void perform(api.disconnectAll)} onAccessibility={() => void perform(() => api.checkAccessibility(true))} onSetup={openSetup} />} {view === "devices" && void perform(() => api.forgetDevice(id))} />} {view === "profiles" && { profileEditorDirty.current = dirty; }} nativeExitRequest={profileExitRequest} onConfirmNativeExit={confirmProfileExit} onCancelNativeExit={cancelProfileExit} />} - {view === "settings" && void perform(() => api.setTelemetryConsent(enabled))} checkUpdates={() => void checkForUpdates()} busy={busy} />} + {view === "settings" && void perform(() => api.setTelemetryConsent(enabled))} updateAction={(action) => void runUpdate(action)} cancelUpdate={() => void cancelUpdate()} busy={busy} />} {view === "support" && void perform(operation)} openSetup={openSetup} />} {setupOpen && perform(() => api.checkAccessibility(true))} reject={(requestId) => perform(() => api.rejectPairing(requestId))} approve={(requestId) => perform(() => api.approvePairing(requestId))} />} diff --git a/src/api.test.ts b/src/api.test.ts index da853f2..49b29dd 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -50,6 +50,20 @@ describe("runtime state events", () => { expect(invoke).toHaveBeenCalledWith("take_navigation_request"); }); + it("invokes each updater lifecycle command", async () => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, value: {} }); + + await api.checkForUpdates(); + await api.downloadUpdate(); + await api.cancelUpdateDownload(); + await api.installUpdate(); + + expect(invoke).toHaveBeenCalledWith("check_for_updates", undefined); + expect(invoke).toHaveBeenCalledWith("download_update", undefined); + expect(invoke).toHaveBeenCalledWith("cancel_update_download", undefined); + expect(invoke).toHaveBeenCalledWith("install_update", undefined); + }); + it("delivers tray navigation queued before the listener was ready", async () => { Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, diff --git a/src/api.ts b/src/api.ts index e60d64b..9c30ab3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -29,6 +29,7 @@ export const browserState: AppState = { diagnostics: { recentBluetooth: [], lastDisconnect: null, recentErrors: [] }, telemetry: { consent: "undecided", available: true }, setup: { shown: false, completed: false, autoOpenEligible: true }, + updater: { status: "unconfigured", version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }, }; const emptyBindings = () => Array.from({ length: 8 }, (_, index) => ({ @@ -102,6 +103,9 @@ export const api = { ? invoke("cancel_profile_exit") : Promise.resolve(), checkForUpdates: () => call("check_for_updates"), + downloadUpdate: () => call("download_update"), + cancelUpdateDownload: () => call("cancel_update_download"), + installUpdate: () => call("install_update"), exportDiagnostics: () => call("export_diagnostics"), onState: async (handler: (state: AppState) => void): Promise => { if (!("__TAURI_INTERNALS__" in window)) return () => undefined; diff --git a/src/styles.css b/src/styles.css index 3b96922..40d7141 100644 --- a/src/styles.css +++ b/src/styles.css @@ -105,6 +105,10 @@ button:disabled { cursor: default; opacity: 0.55; } .setting-group h2 { font-size: 15px; } .setting-group header p { margin-top: 5px; color: var(--muted); font-size: 12px; line-height: 1.5; } .setting-controls { display: grid; align-content: start; gap: 17px; } +.update-controls { display: grid; gap: 10px; color: var(--muted); font-size: 12px; } +.update-controls > div { display: flex; flex-wrap: wrap; gap: 8px; } +.update-controls progress { width: 100%; accent-color: var(--brand); } +.update-controls > span { font-variant-numeric: tabular-nums; } .toggle-row, .range-row { display: grid; align-items: center; gap: 12px; font-size: 13px; } .toggle-row { grid-template-columns: 1fr auto; cursor: pointer; } .toggle-row input { position: absolute; width: 1px; height: 1px; opacity: 0; } diff --git a/src/types.ts b/src/types.ts index be27585..b6abfdc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,15 @@ export type DiagnosticSummary = { recentErrors: DiagnosticEvent[]; }; export type TelemetryState = { consent: "undecided" | "enabled" | "disabled"; available: boolean }; +export type UpdateStatus = "unconfigured" | "idle" | "checking" | "available" | "downloading" | "readyToInstall" | "applying" | "current" | "failed" | "cancelled"; +export type UpdateState = { + status: UpdateStatus; + version: string | null; + downloadedBytes: number; + totalBytes: number | null; + error: string | null; + retryAction: "check" | "download" | "install" | null; +}; export type AppSettings = { startWithSystem: boolean; @@ -47,6 +56,7 @@ export type AppState = { diagnostics: DiagnosticSummary; telemetry: TelemetryState; setup: { shown: boolean; completed: boolean; autoOpenEligible: boolean }; + updater: UpdateState; }; export type SwitchBinding = { diff --git a/tools/updater-signature-verifier/Cargo.lock b/tools/updater-signature-verifier/Cargo.lock new file mode 100644 index 0000000..f052afd --- /dev/null +++ b/tools/updater-signature-verifier/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "switchify-updater-signature-verifier" +version = "0.1.0" +dependencies = [ + "base64", + "minisign-verify", +] diff --git a/tools/updater-signature-verifier/Cargo.toml b/tools/updater-signature-verifier/Cargo.toml new file mode 100644 index 0000000..070fc43 --- /dev/null +++ b/tools/updater-signature-verifier/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "switchify-updater-signature-verifier" +version = "0.1.0" +edition = "2021" +rust-version = "1.97.1" +publish = false + +[dependencies] +base64 = "0.22" +minisign-verify = "0.2.5" diff --git a/tools/updater-signature-verifier/src/main.rs b/tools/updater-signature-verifier/src/main.rs new file mode 100644 index 0000000..98ffb56 --- /dev/null +++ b/tools/updater-signature-verifier/src/main.rs @@ -0,0 +1,92 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use minisign_verify::{PublicKey, Signature}; +use std::{env, fs, fs::File, io::Read, path::PathBuf}; + +fn main() { + if let Err(error) = verify() { + eprintln!("Updater signature verification failed: {error}"); + std::process::exit(1); + } +} + +fn verify() -> Result<(), String> { + let mut arguments = env::args_os().skip(1); + let artifact = PathBuf::from(arguments.next().ok_or("missing artifact path")?); + let signature_path = PathBuf::from(arguments.next().ok_or("missing signature path")?); + if arguments.next().is_some() { + return Err("expected exactly an artifact and signature path".into()); + } + + let public_key_text = env::var("SWITCHIFY_UPDATER_PUBLIC_KEY") + .map_err(|_| "SWITCHIFY_UPDATER_PUBLIC_KEY is not configured")?; + let public_key = decode_public_key(&public_key_text)?; + let signature_text = fs::read_to_string(&signature_path) + .map_err(|error| format!("cannot read signature file: {error}"))?; + let signature = decode_signature(&signature_text)?; + let mut verifier = public_key + .verify_stream(&signature) + .map_err(|error| format!("unsupported updater signature: {error}"))?; + let mut file = + File::open(&artifact).map_err(|error| format!("cannot open artifact: {error}"))?; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = file + .read(&mut buffer) + .map_err(|error| format!("cannot read artifact: {error}"))?; + if count == 0 { + break; + } + verifier.update(&buffer[..count]); + } + verifier + .finalize() + .map_err(|error| format!("signature does not match artifact: {error}")) +} + +fn decode_public_key(value: &str) -> Result { + PublicKey::decode(value.trim()) + .or_else(|_| PublicKey::from_base64(value.trim())) + .or_else(|_| { + let decoded = STANDARD + .decode(value.trim()) + .map_err(|_| minisign_verify::Error::InvalidEncoding)?; + let text = std::str::from_utf8(&decoded) + .map_err(|_| minisign_verify::Error::InvalidEncoding)?; + PublicKey::decode(text.trim()) + }) + .map_err(|error| format!("invalid updater public key: {error}")) +} + +fn decode_signature(value: &str) -> Result { + Signature::decode(value.trim()) + .or_else(|_| { + let decoded = STANDARD + .decode(value.trim()) + .map_err(|_| minisign_verify::Error::InvalidEncoding)?; + let text = std::str::from_utf8(&decoded) + .map_err(|_| minisign_verify::Error::InvalidEncoding)?; + Signature::decode(text.trim()) + }) + .map_err(|error| format!("invalid updater signature: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PUBLIC_KEY: &str = "untrusted comment: minisign public key E7620F1842B4E81F\nRWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3"; + const SIGNATURE: &str = "untrusted comment: signature from minisign secret key\nRUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=\ntrusted comment: timestamp:1556193335\tfile:test\ny/rUw2y8/hOUYjZU71eHp/Wo1KZ40fGy2VJEDl34XMJM+TX48Ss/17u3IvIfbVR1FkZZSNCisQbuQY+bHwhEBg=="; + + #[test] + fn decodes_tauri_wrapped_key_and_signature() { + let public_key = decode_public_key(&STANDARD.encode(PUBLIC_KEY)).unwrap(); + let signature = decode_signature(&STANDARD.encode(SIGNATURE)).unwrap(); + public_key.verify(b"test", &signature, false).unwrap(); + } + + #[test] + fn rejects_invalid_wrapped_values() { + assert!(decode_public_key(&STANDARD.encode("not a public key")).is_err()); + assert!(decode_signature(&STANDARD.encode("not a signature")).is_err()); + } +}