From 663005c8ea0867b95c150ad7b8d949c0d7dd0ab7 Mon Sep 17 00:00:00 2001 From: atavism Date: Tue, 11 Aug 2026 06:36:27 -0700 Subject: [PATCH 1/8] fix native desktop update signing --- .github/workflows/build-macos.yml | 39 ++++++++ .github/workflows/build-windows.yml | 73 ++++++++++++++ .github/workflows/release.yml | 42 ++++---- .github/workflows/verify-update-service.yml | 11 +++ lib/core/updater/updater.dart | 6 ++ .../updater/winsparkle_build_version.dart | 34 +++++++ scripts/ci/generate_update_metadata.py | 78 ++++++++++----- scripts/ci/generate_update_metadata_test.py | 98 ++++++++++++------- scripts/ci/verify_update_service.py | 26 ++++- scripts/ci/verify_update_service_test.py | 12 ++- windows/dsa_pub.pem | 36 +++++++ windows/runner/Runner.rc | 9 ++ 12 files changed, 379 insertions(+), 85 deletions(-) create mode 100644 lib/core/updater/winsparkle_build_version.dart create mode 100644 windows/dsa_pub.pem diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 730cb290ba..eaa9c757bc 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -17,6 +17,8 @@ on: required: true MACOS_PROVISION_TUNNEL_BASE64: required: true + SPARKLE_ED_PRIVATE_KEY: + required: true inputs: version: required: true @@ -256,6 +258,35 @@ jobs: ditto -c -k --keepParent "$APP_PATH" "$RUNNER_TEMP/Lantern.app.zip" + - name: Sign macOS update + if: ${{ inputs.build_type != 'nightly' }} + shell: bash + env: + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + DMG_PATH: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.dmg + run: | + set -euo pipefail + if [[ -z "${SPARKLE_ED_PRIVATE_KEY:-}" ]]; then + echo "SPARKLE_ED_PRIVATE_KEY is required for macOS update signing" >&2 + exit 1 + fi + if [[ ! -f "$DMG_PATH" ]]; then + echo "macOS installer not found: $DMG_PATH" >&2 + exit 1 + fi + + signature_path="$RUNNER_TEMP/$(basename "$DMG_PATH").sparkle-signature" + printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \ + | macos/Pods/Sparkle/bin/sign_update --ed-key-file - -p "$DMG_PATH" \ + > "$signature_path" + signature="$(tr -d '\r\n' < "$signature_path")" + if [[ -z "$signature" ]]; then + echo "Sparkle produced an empty macOS update signature" >&2 + exit 1 + fi + printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \ + | macos/Pods/Sparkle/bin/sign_update --verify --ed-key-file - "$DMG_PATH" "$signature" + - name: Upload macOS app uses: actions/upload-artifact@v4 with: @@ -270,6 +301,14 @@ jobs: path: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.dmg retention-days: 2 + - name: Upload macOS update signature + if: ${{ inputs.build_type != 'nightly' }} + uses: actions/upload-artifact@v4 + with: + name: lantern-installer-dmg-signature + path: ${{ runner.temp }}/${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.dmg.sparkle-signature + retention-days: 2 + - name: Run macOS connect smoke if: ${{ inputs.run_connect_smoke }} timeout-minutes: 30 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 79d541e0db..469e0a9886 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -9,6 +9,8 @@ on: required: false SIGNPATH_API_TOKEN: required: false + WINSPARKLE_DSA_PRIVATE_KEY: + required: true inputs: version: required: true @@ -351,9 +353,80 @@ jobs: -ApiToken $env:SIGNPATH_API_TOKEN ` -Description "Installer - GitHub Actions build ${{ inputs.version }}" + - name: Sign Windows update + if: ${{ inputs.build_type != 'nightly' }} + shell: pwsh + env: + WINSPARKLE_DSA_PRIVATE_KEY: ${{ secrets.WINSPARKLE_DSA_PRIVATE_KEY }} + FULL_INSTALLER_NAME: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }} + run: | + if ([string]::IsNullOrWhiteSpace($env:WINSPARKLE_DSA_PRIVATE_KEY)) { + throw "WINSPARKLE_DSA_PRIVATE_KEY is required for Windows update signing" + } + + $installerPath = "$env:FULL_INSTALLER_NAME.exe" + if (-not (Test-Path $installerPath)) { + throw "Windows installer not found: $installerPath" + } + + $signer = "windows\flutter\ephemeral\.plugin_symlinks\auto_updater_windows\windows\WinSparkle-0.8.1\bin\sign_update.bat" + if (-not (Test-Path $signer)) { + throw "WinSparkle signer not found: $signer" + } + + $privateKeyPath = Join-Path $env:RUNNER_TEMP "winsparkle-dsa-private-key.pem" + $signaturePath = Join-Path $env:RUNNER_TEMP "$env:FULL_INSTALLER_NAME.exe.sparkle-signature" + $signatureBinPath = Join-Path $env:RUNNER_TEMP "winsparkle-signature.bin" + $digestPath = Join-Path $env:RUNNER_TEMP "winsparkle-installer.sha1" + try { + [System.IO.File]::WriteAllText( + $privateKeyPath, + $env:WINSPARKLE_DSA_PRIVATE_KEY, + [System.Text.Encoding]::ASCII + ) + + $signatureOutput = & $signer $installerPath $privateKeyPath + if ($LASTEXITCODE -ne 0) { + throw "WinSparkle signing failed with exit code $LASTEXITCODE" + } + $signature = ($signatureOutput -join "").Trim() + if ([string]::IsNullOrWhiteSpace($signature)) { + throw "WinSparkle produced an empty Windows update signature" + } + [System.IO.File]::WriteAllText( + $signaturePath, + $signature, + [System.Text.Encoding]::ASCII + ) + + & openssl enc -base64 -d -A -in $signaturePath -out $signatureBinPath + if ($LASTEXITCODE -ne 0) { + throw "Unable to decode the WinSparkle signature" + } + & openssl dgst -sha1 -binary -out $digestPath $installerPath + if ($LASTEXITCODE -ne 0) { + throw "Unable to hash the Windows installer for verification" + } + & openssl dgst -sha1 -verify "windows\dsa_pub.pem" -signature $signatureBinPath $digestPath + if ($LASTEXITCODE -ne 0) { + throw "Windows update signature verification failed" + } + } + finally { + Remove-Item -Force -ErrorAction SilentlyContinue $privateKeyPath, $signatureBinPath, $digestPath + } + - name: Upload Windows installer uses: actions/upload-artifact@v4 with: name: lantern-installer-exe path: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.exe retention-days: 2 + + - name: Upload Windows update signature + if: ${{ inputs.build_type != 'nightly' }} + uses: actions/upload-artifact@v4 + with: + name: lantern-installer-exe-signature + path: ${{ runner.temp }}/${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.exe.sparkle-signature + retention-days: 2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b4539a89b..3220335165 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,6 +96,7 @@ jobs: publish_mobile_stores: ${{ steps.meta.outputs.publish_mobile_stores }} smoke_enable_ip_check: ${{ steps.meta.outputs.smoke_enable_ip_check }} stealth_leakage_mode: ${{ steps.meta.outputs.stealth_leakage_mode }} + sparkle_version: ${{ steps.meta.outputs.sparkle_version }} steps: - name: Checkout repo uses: actions/checkout@v4 @@ -306,6 +307,7 @@ jobs: echo "publish_mobile_stores=$PUBLISH_MOBILE_STORES" >> $GITHUB_OUTPUT echo "smoke_enable_ip_check=$SMOKE_ENABLE_IP_CHECK" >> $GITHUB_OUTPUT echo "stealth_leakage_mode=$STEALTH_LEAKAGE_MODE" >> $GITHUB_OUTPUT + echo "sparkle_version=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT - name: Update pubspec.yaml version shell: bash @@ -890,25 +892,31 @@ jobs: with: ref: ${{ github.sha }} - - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 - with: - channel: stable - flutter-version-file: .github/flutter-version.yaml - - name: Install Python dependencies run: python3 -m pip install -r scripts/requirements.txt - name: Test update metadata generator run: python3 -m unittest scripts/ci/generate_update_metadata_test.py - - name: Publish update metadata sidecars - # DISABLED: `auto_updater:sign_update` exits 255 on the dmg, which failed - # this job and made release-finalize delete the draft release on every - # tagged run (v9.1.19-beta, v9.1.20-beta got tags with no release). + - name: Download macOS update signature if: | - false && - needs.set-metadata.outputs.build_type != 'nightly' + needs.set-metadata.outputs.platform == 'all' || + contains(needs.set-metadata.outputs.platform, 'macos') + uses: actions/download-artifact@v4 + with: + name: lantern-installer-dmg-signature + path: update-signatures + + - name: Download Windows update signature + if: | + needs.set-metadata.outputs.platform == 'all' || + contains(needs.set-metadata.outputs.platform, 'windows') + uses: actions/download-artifact@v4 + with: + name: lantern-installer-exe-signature + path: update-signatures + + - name: Publish update metadata sidecars env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -941,8 +949,10 @@ jobs: python3 scripts/ci/generate_update_metadata.py \ --build-type "$BUILD_TYPE" \ --version "$VERSION" \ + --sparkle-version "${{ needs.set-metadata.outputs.sparkle_version }}" \ --bucket "$BUCKET" \ --output-dir update-metadata \ + --sparkle-signature-dir update-signatures \ "${artifacts[@]}" for metadata in update-metadata/*.update.json; do @@ -954,6 +964,7 @@ jobs: - name: Publish legacy appcast.xml if: | + false && needs.set-metadata.outputs.build_type != 'nightly' && ( needs.set-metadata.outputs.platform == 'all' || @@ -989,10 +1000,7 @@ jobs: verify-update-service: needs: [set-metadata, publish-update-metadata, release-finalize] - # DISABLED with the sidecar step above: with nothing published, this polls - # for its full 2700s timeout and then fails. if: | - false && !cancelled() && needs.set-metadata.outputs.build_type == 'beta' && needs.publish-update-metadata.result == 'success' && @@ -1000,6 +1008,7 @@ jobs: uses: ./.github/workflows/verify-update-service.yml with: version: ${{ needs.set-metadata.outputs.version }} + sparkle_version: ${{ needs.set-metadata.outputs.sparkle_version }} channel: beta platform: ${{ needs.set-metadata.outputs.platform }} update_url: "https://update.getlantern.org/update/lantern" @@ -1035,7 +1044,6 @@ jobs: env.BUILD_TYPE != 'nightly' && needs.upload-s3.result == 'success' && needs.upload-release-artifacts.result == 'success' && - needs.publish-update-metadata.result == 'success' && (needs.upload-google-play.result == 'success' || needs.upload-google-play.result == 'skipped') && (needs.upload-testflight.result == 'success' || needs.upload-testflight.result == 'skipped') env: @@ -1051,7 +1059,6 @@ jobs: (env.CLEANUP_ON_FAILURE == 'true' && !(needs.upload-s3.result == 'success' && needs.upload-release-artifacts.result == 'success' && - needs.publish-update-metadata.result == 'success' && (needs.upload-google-play.result == 'success' || needs.upload-google-play.result == 'skipped') && (needs.upload-testflight.result == 'success' || needs.upload-testflight.result == 'skipped'))) env: @@ -1094,7 +1101,6 @@ jobs: env.CLEANUP_ON_FAILURE != 'true' && !(needs.upload-s3.result == 'success' && needs.upload-release-artifacts.result == 'success' && - needs.publish-update-metadata.result == 'success' && (needs.upload-google-play.result == 'success' || needs.upload-google-play.result == 'skipped') && (needs.upload-testflight.result == 'success' || needs.upload-testflight.result == 'skipped')) run: | diff --git a/.github/workflows/verify-update-service.yml b/.github/workflows/verify-update-service.yml index 42f5227c46..09b885ee6d 100644 --- a/.github/workflows/verify-update-service.yml +++ b/.github/workflows/verify-update-service.yml @@ -7,6 +7,11 @@ on: description: "Release version to verify, with or without leading v" required: true type: string + sparkle_version: + description: "Desktop bundle build number to verify" + required: false + type: string + default: "" channel: description: "Release channel to verify" required: false @@ -39,6 +44,10 @@ on: version: required: true type: string + sparkle_version: + required: false + type: string + default: "" channel: required: false type: string @@ -86,6 +95,7 @@ jobs: CHANNEL: ${{ inputs.channel }} PLATFORM: ${{ inputs.platform }} VERSION: ${{ inputs.version }} + SPARKLE_VERSION: ${{ inputs.sparkle_version }} TIMEOUT_SECONDS: ${{ inputs.timeout_seconds }} INTERVAL_SECONDS: ${{ inputs.interval_seconds }} run: | @@ -94,5 +104,6 @@ jobs: --channel "$CHANNEL" \ --platform "$PLATFORM" \ --version "$VERSION" \ + --sparkle-version "$SPARKLE_VERSION" \ --timeout-seconds "$TIMEOUT_SECONDS" \ --interval-seconds "$INTERVAL_SECONDS" diff --git a/lib/core/updater/updater.dart b/lib/core/updater/updater.dart index d65d4782a5..302c226c64 100644 --- a/lib/core/updater/updater.dart +++ b/lib/core/updater/updater.dart @@ -8,7 +8,9 @@ import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/feature_flags.dart'; import 'package:lantern/core/services/injection_container.dart'; import 'package:lantern/core/updater/android_sideload_updater.dart'; +import 'package:lantern/core/updater/winsparkle_build_version.dart'; import 'package:lantern/lantern/lantern_service.dart'; +import 'package:package_info_plus/package_info_plus.dart'; class Updater { Updater({AndroidSideloadUpdater? androidSideloadUpdater}) @@ -62,6 +64,10 @@ class Updater { final buildType = AppBuildInfo.buildType; final feedUrl = AppUrls.appcastFor(buildType); final updater = AutoUpdater.instance; + if (Platform.isWindows) { + final packageInfo = await PackageInfo.fromPlatform(); + setWinSparkleBuildVersion(packageInfo.buildNumber); + } await updater.setFeedURL(feedUrl); await updater.setScheduledCheckInterval(3600); diff --git a/lib/core/updater/winsparkle_build_version.dart b/lib/core/updater/winsparkle_build_version.dart new file mode 100644 index 0000000000..358bd2eb34 --- /dev/null +++ b/lib/core/updater/winsparkle_build_version.dart @@ -0,0 +1,34 @@ +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +typedef _SetAppBuildVersionNative = Void Function(Pointer buildVersion); +typedef _SetAppBuildVersionDart = void Function(Pointer buildVersion); + +/// Tells WinSparkle which internal build number to compare with the appcast. +/// +/// The plugin otherwise compares Lantern's display version (for example, +/// `9.1.20`) while Sparkle on macOS compares the numeric bundle build. Release +/// CI uses that shared build number for both desktop platforms. +void setWinSparkleBuildVersion(String buildVersion) { + final normalized = buildVersion.trim(); + if (normalized.isEmpty) { + throw ArgumentError.value( + buildVersion, + 'buildVersion', + 'must not be empty', + ); + } + + final winSparkle = DynamicLibrary.open('WinSparkle.dll'); + final setBuildVersion = winSparkle + .lookupFunction<_SetAppBuildVersionNative, _SetAppBuildVersionDart>( + 'win_sparkle_set_app_build_version', + ); + final nativeBuildVersion = normalized.toNativeUtf16(); + try { + setBuildVersion(nativeBuildVersion); + } finally { + calloc.free(nativeBuildVersion); + } +} diff --git a/scripts/ci/generate_update_metadata.py b/scripts/ci/generate_update_metadata.py index c9043f0bc4..f7c391090a 100755 --- a/scripts/ci/generate_update_metadata.py +++ b/scripts/ci/generate_update_metadata.py @@ -4,17 +4,14 @@ from __future__ import annotations import argparse +import base64 +import binascii import hashlib import json -import re -import subprocess from pathlib import Path from typing import Optional, Tuple -SPARKLE_SIGNATURE_RE = re.compile(r'sparkle:edSignature="([^"]+)"') - - def release_channel(build_type: str) -> str: normalized = build_type.strip().lower() if normalized in ("production", "prod", "stable", ""): @@ -50,22 +47,34 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() -def sparkle_signature(path: Path) -> str: - # Lantern CI owns Sparkle signing. lantern-cloud only reads this value back - # from the sidecar when it renders the channel appcast. - proc = subprocess.run( - ["dart", "run", "auto_updater:sign_update", str(path)], - check=True, - capture_output=True, - text=True, - ) - match = SPARKLE_SIGNATURE_RE.search(proc.stdout.strip()) - if not match: - raise RuntimeError(f"could not parse Sparkle signature for {path.name}: {proc.stdout}") - return match.group(1) - - -def sidecar_for(path: Path, build_type: str, version: str, bucket: str) -> Optional[dict[str, object]]: +def updater_signature(path: Path, signature_dir: Path, kind: str) -> str: + # Signing happens on the native platform build runners. This Linux job only + # validates and packages their public signatures into release sidecars. + signature_path = signature_dir / f"{path.name}.sparkle-signature" + try: + signature = signature_path.read_text(encoding="ascii").strip() + except FileNotFoundError as err: + raise RuntimeError(f"missing {kind} signature for {path.name}") from err + + try: + decoded = base64.b64decode(signature, validate=True) + except (binascii.Error, ValueError) as err: + raise RuntimeError(f"invalid {kind} signature for {path.name}") from err + if kind == "EdDSA" and len(decoded) != 64: + raise RuntimeError(f"invalid EdDSA signature length for {path.name}") + if kind == "DSA" and (not decoded or decoded[0] != 0x30): + raise RuntimeError(f"invalid DSA signature for {path.name}") + return signature + + +def sidecar_for( + path: Path, + build_type: str, + version: str, + bucket: str, + signature_dir: Optional[Path] = None, + sparkle_version: Optional[str] = None, +) -> Optional[dict[str, object]]: info = artifact_info(path.name) if info is None: return None @@ -88,8 +97,20 @@ def sidecar_for(path: Path, build_type: str, version: str, bucket: str) -> Optio "size": path.stat().st_size, "sha256": sha256_file(path), } - if platform in ("macos", "windows"): - metadata["sparkle_ed_signature"] = sparkle_signature(path) + if platform == "macos": + if not sparkle_version: + raise RuntimeError(f"missing Sparkle version for {path.name}") + if signature_dir is None: + raise RuntimeError(f"missing signature directory for {path.name}") + metadata["sparkle_version"] = sparkle_version + metadata["sparkle_ed_signature"] = updater_signature(path, signature_dir, "EdDSA") + elif platform == "windows": + if not sparkle_version: + raise RuntimeError(f"missing Sparkle version for {path.name}") + if signature_dir is None: + raise RuntimeError(f"missing signature directory for {path.name}") + metadata["sparkle_version"] = sparkle_version + metadata["sparkle_dsa_signature"] = updater_signature(path, signature_dir, "DSA") return metadata @@ -97,8 +118,10 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--build-type", required=True) parser.add_argument("--version", required=True, help="Release version without leading v") + parser.add_argument("--sparkle-version", required=True, help="Desktop bundle build number") parser.add_argument("--bucket", required=True) parser.add_argument("--output-dir", required=True, type=Path) + parser.add_argument("--sparkle-signature-dir", required=True, type=Path) parser.add_argument("artifacts", nargs="+", type=Path) args = parser.parse_args() @@ -107,7 +130,14 @@ def main() -> None: for artifact in args.artifacts: if not artifact.is_file(): continue - metadata = sidecar_for(artifact, args.build_type, args.version, args.bucket) + metadata = sidecar_for( + artifact, + args.build_type, + args.version, + args.bucket, + args.sparkle_signature_dir, + args.sparkle_version, + ) if metadata is None: continue output = args.output_dir / f"{artifact.name}.update.json" diff --git a/scripts/ci/generate_update_metadata_test.py b/scripts/ci/generate_update_metadata_test.py index 0a09be3a37..4e209c0edc 100644 --- a/scripts/ci/generate_update_metadata_test.py +++ b/scripts/ci/generate_update_metadata_test.py @@ -2,12 +2,12 @@ from __future__ import annotations +import base64 import hashlib import pathlib -import subprocess import sys import tempfile -from unittest import TestCase, main, mock +from unittest import TestCase, main sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) @@ -130,50 +130,76 @@ def test_sidecar_for_adds_sparkle_signature_for_desktop(self) -> None: with tempfile.TemporaryDirectory() as tmp: artifact = pathlib.Path(tmp) / "lantern-installer-beta.dmg" artifact.write_bytes(b"dmg bytes") + signature = base64.b64encode(b"s" * 64).decode("ascii") + (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( + signature, + encoding="ascii", + ) - completed = subprocess.CompletedProcess( - args=["dart"], - returncode=0, - stdout='sparkle:edSignature="sparkle-signature"\n', - stderr="", + metadata = generate_update_metadata.sidecar_for( + artifact, + "beta", + "9.2.0-beta", + "lantern.io", + pathlib.Path(tmp), + "920", ) - with mock.patch( - "generate_update_metadata.subprocess.run", - return_value=completed, - ) as run: - metadata = generate_update_metadata.sidecar_for( - artifact, - "beta", - "9.2.0-beta", - "lantern.io", - ) self.assertEqual(metadata["platform"], "macos") - self.assertEqual(metadata["sparkle_ed_signature"], "sparkle-signature") - run.assert_called_once_with( - ["dart", "run", "auto_updater:sign_update", str(artifact)], - check=True, - capture_output=True, - text=True, - ) + self.assertEqual(metadata["sparkle_version"], "920") + self.assertEqual(metadata["sparkle_ed_signature"], signature) - def test_sparkle_signature_rejects_unexpected_output(self) -> None: + def test_sidecar_for_adds_dsa_signature_for_windows(self) -> None: with tempfile.TemporaryDirectory() as tmp: artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" artifact.write_bytes(b"exe bytes") + signature = "MAMCAQE=" + (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( + signature, + encoding="ascii", + ) - completed = subprocess.CompletedProcess( - args=["dart"], - returncode=0, - stdout="no signature here", - stderr="", + metadata = generate_update_metadata.sidecar_for( + artifact, + "beta", + "9.2.0-beta", + "lantern.io", + pathlib.Path(tmp), + "920", ) - with mock.patch( - "generate_update_metadata.subprocess.run", - return_value=completed, - ): - with self.assertRaises(RuntimeError): - generate_update_metadata.sparkle_signature(artifact) + + self.assertEqual(metadata["platform"], "windows") + self.assertEqual(metadata["sparkle_version"], "920") + self.assertEqual(metadata["sparkle_dsa_signature"], signature) + self.assertNotIn("sparkle_ed_signature", metadata) + + def test_updater_signature_rejects_invalid_base64(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" + artifact.write_bytes(b"exe bytes") + (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( + "not a signature", + encoding="ascii", + ) + + with self.assertRaisesRegex(RuntimeError, "invalid DSA signature"): + generate_update_metadata.updater_signature( + artifact, + pathlib.Path(tmp), + "DSA", + ) + + def test_updater_signature_requires_native_runner_output(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + artifact = pathlib.Path(tmp) / "lantern-installer-beta.dmg" + artifact.write_bytes(b"dmg bytes") + + with self.assertRaisesRegex(RuntimeError, "missing EdDSA signature"): + generate_update_metadata.updater_signature( + artifact, + pathlib.Path(tmp), + "EdDSA", + ) if __name__ == "__main__": diff --git a/scripts/ci/verify_update_service.py b/scripts/ci/verify_update_service.py index 8d0fbc4ea6..16c6481d4c 100644 --- a/scripts/ci/verify_update_service.py +++ b/scripts/ci/verify_update_service.py @@ -38,6 +38,7 @@ class Config: timeout_seconds: int interval_seconds: int platforms: frozenset[str] + sparkle_version: str = "" class VerificationError(Exception): @@ -169,7 +170,8 @@ def parse_appcast(xml_text: str) -> tuple[str, list[dict[str, str]]]: enclosures.append( { "url": enclosure.attrib.get("url", ""), - "signature": enclosure.attrib.get(f"{{{SPARKLE_NS}}}edSignature", ""), + "ed_signature": enclosure.attrib.get(f"{{{SPARKLE_NS}}}edSignature", ""), + "dsa_signature": enclosure.attrib.get(f"{{{SPARKLE_NS}}}dsaSignature", ""), "os": enclosure.attrib.get(f"{{{SPARKLE_NS}}}os", ""), } ) @@ -201,7 +203,11 @@ def verify_beta_appcast( for os_name, suffix in required_platforms.items(): enclosure = by_os.get(os_name) require(enclosure is not None, f"beta appcast missing {os_name} enclosure") - require(enclosure["signature"], f"beta appcast {os_name} enclosure missing signature") + signature_key = "ed_signature" if os_name == "macos" else "dsa_signature" + require( + enclosure[signature_key], + f"beta appcast {os_name} enclosure missing {signature_key}", + ) require( enclosure["url"].endswith(suffix), f"beta appcast {os_name} URL does not end with {suffix}: {enclosure['url']}", @@ -233,8 +239,18 @@ def run_checks_once(config: Config) -> None: verify_stable_excludes_beta(config.update_url, expected_version, platform) if config.platforms & set(APPCAST_PLATFORMS): - verify_beta_appcast(config.update_url, expected_version, config.platforms) - verify_stable_appcast_excludes_beta(config.update_url, expected_version) + expected_sparkle_version = normalize_version( + config.sparkle_version or config.version + ) + verify_beta_appcast( + config.update_url, + expected_sparkle_version, + config.platforms, + ) + verify_stable_appcast_excludes_beta( + config.update_url, + expected_sparkle_version, + ) def poll_until_verified(config: Config) -> None: @@ -270,6 +286,7 @@ def main() -> None: help="'all' or comma-separated release platforms", ) parser.add_argument("--version", required=True, help="Release version, with or without leading v") + parser.add_argument("--sparkle-version", default="", help="Desktop bundle build number") parser.add_argument("--timeout-seconds", type=int, default=2700) parser.add_argument("--interval-seconds", type=int, default=60) args = parser.parse_args() @@ -282,6 +299,7 @@ def main() -> None: timeout_seconds=args.timeout_seconds, interval_seconds=args.interval_seconds, platforms=normalize_platforms(args.platform), + sparkle_version=args.sparkle_version, ) ) diff --git a/scripts/ci/verify_update_service_test.py b/scripts/ci/verify_update_service_test.py index df4b12c04f..5a2afbce74 100644 --- a/scripts/ci/verify_update_service_test.py +++ b/scripts/ci/verify_update_service_test.py @@ -17,6 +17,7 @@ class UpdateServiceHandler(BaseHTTPRequestHandler): beta_version = "9.2.0-beta" + beta_appcast_version = "9.2.0-beta" stable_version = "9.1.0" stable_appcast_status = 200 beta_enclosures = [ @@ -58,7 +59,7 @@ def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API. if self.path.endswith("channel=beta"): self.write_xml( self.appcast_xml( - self.beta_version, + self.beta_appcast_version, self.beta_enclosures, ) ) @@ -99,7 +100,9 @@ def write_xml(self, data: str) -> None: @staticmethod def appcast_xml(version: str, enclosures: list[tuple[str, str, str]]) -> str: enclosure_xml = "\n".join( - f'' for os_name, signature, url in enclosures ) @@ -118,6 +121,7 @@ def appcast_xml(version: str, enclosures: list[tuple[str, str, str]]) -> str: class VerifyUpdateServiceTest(unittest.TestCase): def setUp(self) -> None: UpdateServiceHandler.stable_appcast_status = 200 + UpdateServiceHandler.beta_appcast_version = UpdateServiceHandler.beta_version UpdateServiceHandler.beta_enclosures = [ ("macos", "macos-signature", "https://example.com/lantern-installer-beta.dmg"), ("windows", "windows-signature", "https://example.com/lantern-installer-beta.exe"), @@ -135,6 +139,7 @@ def tearDown(self) -> None: self.server.server_close() def test_run_checks_once_accepts_valid_beta_release(self) -> None: + UpdateServiceHandler.beta_appcast_version = "920" verify_update_service.run_checks_once( verify_update_service.Config( update_url=self.update_url, @@ -143,6 +148,7 @@ def test_run_checks_once_accepts_valid_beta_release(self) -> None: timeout_seconds=1, interval_seconds=1, platforms=verify_update_service.normalize_platforms("all"), + sparkle_version="920", ) ) @@ -239,7 +245,7 @@ def test_parse_appcast_preserves_empty_signature(self) -> None: ) version, enclosures = verify_update_service.parse_appcast(xml_text) self.assertEqual(version, "9.2.0-beta") - self.assertEqual(enclosures[0]["signature"], "") + self.assertEqual(enclosures[0]["ed_signature"], "") def test_parse_appcast_rejects_internal_entities(self) -> None: xml_text = """ diff --git a/windows/dsa_pub.pem b/windows/dsa_pub.pem new file mode 100644 index 0000000000..6433d0aa43 --- /dev/null +++ b/windows/dsa_pub.pem @@ -0,0 +1,36 @@ +-----BEGIN PUBLIC KEY----- +MIIGRzCCBDkGByqGSM44BAEwggQsAoICAQDgBDHQyYKtkPu+L7TxWVja/pWktmyf +pKI+g00miLmBYJ2H85c8Egk7rGIRJUrWfCd7EtpgBnpeissd4qhTPgLxyt0lrVuP +FMIzK6DL65DtosGPsbXsl32THePayorKOtkDB7rHFBYlLxS4DwSev+qAzj+YHY48 +pK09YwmTPdvGNU87UgfOyqINBGxEmpdxW/fSDg1Wpaytuw2xGUEO5RyhqW57Z3Nk +m58E/fG+GlakUM6TotL7k1c//6UVNP2Z7vBmvUcfTZYn416gsGXsMSH3y8jBME8f +K0WNGqcTYbJVs1DEC4Do3ppoLBM+eEjfa7WHo36EDCuAqFCcPiayUBIY1bBBJd6O +tkv3Rjq/oopqo/WjQCxiFvdORWgPkPmbm+A9zaUnAqKdz4dEe+9OWt+iX8XiqPNZ +QVJLapSmVrRKnxVim8j89Hv5a2hIqSmwoZO2XftAJB1P1vYTwSn5IJ/NUuAKO+4a +cW9GmELHvnpPAZxhRR/2q1v00nDnZ8sqhbWuTcxNXeg67IAIApR3ThSybLH7+i33 +4VtV3eGW6aCGDZNPh+avWV/kIkrqK87YAqQS/PHhy8XKglnqNQh26SntZRfD21tX +l3S+3mRlvpoVfWCLfY8kdhLU8HhynZN59GBUII4Sc69cVgoCSaiHp1i2MuhW6F7G +UgXyteffVPhdEQIhAMnoXjnZaYxGO9WnfBOYyklhwlfR2160sRilgEOsR+LVAoIC +AErwijtlyqalJlWlEfmpBzdlTRGzO3K7wEdEBq60QqzX1wqjHHG+Uen1Q8z4dTU+ +tFrBKvwnDFe8oVOxjkRzadCGGOrhf+xmbi1TRJQIFyy1A2lm4r3gjtsh9FaTUXo9 +pL+c3X8GFQ90ZAoAISarHOJvJ6wO/G5tRa/YdK9pQxfD36Cc0hJAe23hUwdUbMwr +JJpxrUr006zWzV3Q3FFJVNs6AL9i15lQvzTFxgXBeDNJiWMMtJWS6GUIEiP9ktR1 +57yBu4SE8e8CjgPUOL8ZNk+hcKEJaQ0TZzZ/mtn16gFhYJp+2zWZIROAazBXajrw +gKTxsg4gIf48prHYk6VrPWL2ilQY3FXAGYGRso7xPHDEogyL4xN7OZuVXxi8sZck +netJvbF8VlKoo7n2Mw3tjOpIRHNCNC+kpwUCOk3w7VF3cpEj2cnK29qdyjz5yM+E +LKr6/O0bIJ17ShzAr0KzO9IHjas/PW1RhKds06MZo37lhbDu03AJLGVTJafT8Ana +x1vnDRm2NPc2iy5elrYtvcL3Qy5pD38GPL5qW/L+aa4Oqg2nySWpCU7vvOaxHxIf ++UyfWJARZb5D0FVzGdqFK939gxMeQwcK6nqvXJPPNCYAF7hlZHzlXMVeEQVqjpfK +tDfzxc3uqJ+I3c8qCPm4OwrCljXRu9rVtylAXPusDAn/A4ICBgACggIBAIOLDJLZ +Rjmq7xkWiJ0fmuWMFduD55KRhbT3WWGvzNSooytlfxf1FNrciLQ7RWuMDhUTpmGE +n01WqN1wqFUwP0qkUPYRtKWrrELdfsefsaDgdSKInR/TnH9hx9l4yzUvC/D6EOQ3 +K1Kiet2CzFQHmViuXCIpacqK/Qi//hSB7JFMacYd5Vrjn8pWtC2Mu507XqUm+5TG +O11i5H3V2gcUzoar4TAl2aGOxvJbTRkQHxCXP3YmFwAJjvNNLUcyUFlG7yaldGQn +jp6K4qIwX7mjQ3hcR07ncUbcps2YhTkM3Y7XTceYsM81encVoXXB5K14bHlR2Acj +WF+Ha2fAobmK/Hy4U/DtYNcyDFncZl/o/rPgtSVhuIGc0ApBeZGuNyTpuvnAGQay +ozxprfrAde4ExnMiJ0MfRfAQSjrSZr5s+iwNalrayfhH4dDUNTUwyIISzJ1QDvNJ +nJ0jao+hFy384m/33cUIrk0ueiec/RgXKs8tG1tTxK/sF3VNR9jU955ZewcibAKP +tnHkmQ+uz7kQE6hii+1ODVodTpA5H/mljXYiqFVi9puyWVmCHtAHHEJx5SbrJxu0 +IMc9hd0y6Q6bgCoIZgBJTxxBcq7oDuudVpRuqKyhB17fmOTySnzuZgEzrzV181hS +r8RRVl/u0zUP503Tw3UvtoFOHB24o4xrO6hg +-----END PUBLIC KEY----- diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 082987df17..61317d0de8 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -55,6 +55,15 @@ END IDI_APP_ICON ICON "resources\\app_icon.ico" +///////////////////////////////////////////////////////////////////////////// +// +// WinSparkle +// + +// Verify update signatures with the public half of the CI signing key. +DSAPub DSAPEM "..\\dsa_pub.pem" + + ///////////////////////////////////////////////////////////////////////////// // // Version From ecfc437249ba3c4ec6428a2010c3f4b24403881d Mon Sep 17 00:00:00 2001 From: atavism Date: Tue, 11 Aug 2026 06:58:24 -0700 Subject: [PATCH 2/8] code review updates --- .github/scripts/sign_macos_update.sh | 43 +++++++ .github/scripts/sign_windows_update.ps1 | 107 +++++++++++++++++ .github/workflows/build-macos.yml | 24 +--- .github/workflows/build-windows.yml | 58 +-------- .github/workflows/release.yml | 48 ++------ scripts/generate_appcast.py | 153 ------------------------ scripts/requirements.txt | 1 - 7 files changed, 167 insertions(+), 267 deletions(-) create mode 100755 .github/scripts/sign_macos_update.sh create mode 100644 .github/scripts/sign_windows_update.ps1 delete mode 100644 scripts/generate_appcast.py diff --git a/.github/scripts/sign_macos_update.sh b/.github/scripts/sign_macos_update.sh new file mode 100755 index 0000000000..dccc260b07 --- /dev/null +++ b/.github/scripts/sign_macos_update.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +installer_path="${1:-}" +signature_path="${2:-}" +signer_path="${3:-macos/Pods/Sparkle/bin/sign_update}" + +if [[ -z "$installer_path" || -z "$signature_path" ]]; then + printf 'Usage: %s [sign-update-path]\n' "$0" >&2 + exit 2 +fi +if [[ -z "${SPARKLE_ED_PRIVATE_KEY:-}" ]]; then + printf 'SPARKLE_ED_PRIVATE_KEY is required for macOS update signing\n' >&2 + exit 1 +fi +if [[ ! -f "$installer_path" ]]; then + printf 'macOS installer not found: %s\n' "$installer_path" >&2 + exit 1 +fi +if [[ ! -x "$signer_path" ]]; then + printf 'Sparkle signer not found or not executable: %s\n' "$signer_path" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$signature_path")" +temporary_signature="$(mktemp "${signature_path}.tmp.XXXXXX")" +trap 'rm -f "$temporary_signature"' EXIT + +signature="$({ + printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \ + | "$signer_path" --ed-key-file - -p "$installer_path" +} | tr -d '\r\n')" +if [[ -z "$signature" ]]; then + printf 'Sparkle produced an empty macOS update signature\n' >&2 + exit 1 +fi + +printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \ + | "$signer_path" --verify --ed-key-file - "$installer_path" "$signature" + +printf '%s\n' "$signature" >"$temporary_signature" +mv "$temporary_signature" "$signature_path" +trap - EXIT diff --git a/.github/scripts/sign_windows_update.ps1 b/.github/scripts/sign_windows_update.ps1 new file mode 100644 index 0000000000..434d67a29b --- /dev/null +++ b/.github/scripts/sign_windows_update.ps1 @@ -0,0 +1,107 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$InstallerPath, + + [Parameter(Mandatory = $true)] + [string]$SignaturePath, + + [string]$PrivateKey = $env:WINSPARKLE_DSA_PRIVATE_KEY, + + [string]$PublicKeyPath = "windows\dsa_pub.pem", + + [string]$SignerPath = "", + + [string]$TemporaryDirectory = $env:RUNNER_TEMP +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Assert-NativeCommandSucceeded { + param([Parameter(Mandatory = $true)][string]$Message) + + if ($LASTEXITCODE -ne 0) { + throw "$Message (exit code $LASTEXITCODE)" + } +} + +if ([string]::IsNullOrWhiteSpace($PrivateKey)) { + throw "WINSPARKLE_DSA_PRIVATE_KEY is required for Windows update signing" +} +if (-not (Test-Path -LiteralPath $InstallerPath -PathType Leaf)) { + throw "Windows installer not found: $InstallerPath" +} +if (-not (Test-Path -LiteralPath $PublicKeyPath -PathType Leaf)) { + throw "WinSparkle public key not found: $PublicKeyPath" +} +if ([string]::IsNullOrWhiteSpace($SignerPath)) { + $signerRoot = "windows\flutter\ephemeral\.plugin_symlinks\auto_updater_windows\windows" + if (-not (Test-Path -LiteralPath $signerRoot -PathType Container)) { + throw "WinSparkle plugin directory not found: $signerRoot" + } + $signerCandidates = @( + Get-ChildItem -LiteralPath $signerRoot -Filter "sign_update.bat" -File -Recurse + ) + if ($signerCandidates.Count -ne 1) { + throw "Expected one WinSparkle signer under $signerRoot, found $($signerCandidates.Count)" + } + $SignerPath = $signerCandidates[0].FullName +} +if (-not (Test-Path -LiteralPath $SignerPath -PathType Leaf)) { + throw "WinSparkle signer not found: $SignerPath" +} +if ([string]::IsNullOrWhiteSpace($TemporaryDirectory)) { + $TemporaryDirectory = [System.IO.Path]::GetTempPath() +} + +$null = Get-Command openssl -ErrorAction Stop +$null = New-Item -ItemType Directory -Force -Path $TemporaryDirectory +$signatureDirectory = Split-Path -Parent ([System.IO.Path]::GetFullPath($SignaturePath)) +$null = New-Item -ItemType Directory -Force -Path $signatureDirectory + +$temporaryPrefix = Join-Path $TemporaryDirectory "winsparkle-$([guid]::NewGuid().ToString('N'))" +$privateKeyPath = "$temporaryPrefix-private.pem" +$signatureTextPath = "$temporaryPrefix-signature.txt" +$signatureBinaryPath = "$temporaryPrefix-signature.bin" +$digestPath = "$temporaryPrefix-installer.sha1" + +try { + [System.IO.File]::WriteAllText( + $privateKeyPath, + $PrivateKey, + [System.Text.Encoding]::ASCII + ) + + $signatureOutput = & $SignerPath $InstallerPath $privateKeyPath + Assert-NativeCommandSucceeded "WinSparkle signing failed" + + $signature = ($signatureOutput -join "").Trim() + if ([string]::IsNullOrWhiteSpace($signature)) { + throw "WinSparkle produced an empty Windows update signature" + } + [System.IO.File]::WriteAllText( + $signatureTextPath, + $signature, + [System.Text.Encoding]::ASCII + ) + + & openssl enc -base64 -d -A -in $signatureTextPath -out $signatureBinaryPath + Assert-NativeCommandSucceeded "Unable to decode the WinSparkle signature" + + & openssl dgst -sha1 -binary -out $digestPath $InstallerPath + Assert-NativeCommandSucceeded "Unable to hash the Windows installer" + + & openssl dgst -sha1 -verify $PublicKeyPath -signature $signatureBinaryPath $digestPath + Assert-NativeCommandSucceeded "Windows update signature verification failed" + + [System.IO.File]::WriteAllText( + $SignaturePath, + "$signature`n", + [System.Text.Encoding]::ASCII + ) +} +finally { + Remove-Item -Force -ErrorAction SilentlyContinue ` + $privateKeyPath, $signatureTextPath, $signatureBinaryPath, $digestPath +} diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index eaa9c757bc..27e4a791a5 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -265,27 +265,9 @@ jobs: SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} DMG_PATH: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.dmg run: | - set -euo pipefail - if [[ -z "${SPARKLE_ED_PRIVATE_KEY:-}" ]]; then - echo "SPARKLE_ED_PRIVATE_KEY is required for macOS update signing" >&2 - exit 1 - fi - if [[ ! -f "$DMG_PATH" ]]; then - echo "macOS installer not found: $DMG_PATH" >&2 - exit 1 - fi - - signature_path="$RUNNER_TEMP/$(basename "$DMG_PATH").sparkle-signature" - printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \ - | macos/Pods/Sparkle/bin/sign_update --ed-key-file - -p "$DMG_PATH" \ - > "$signature_path" - signature="$(tr -d '\r\n' < "$signature_path")" - if [[ -z "$signature" ]]; then - echo "Sparkle produced an empty macOS update signature" >&2 - exit 1 - fi - printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \ - | macos/Pods/Sparkle/bin/sign_update --verify --ed-key-file - "$DMG_PATH" "$signature" + bash ./.github/scripts/sign_macos_update.sh \ + "$DMG_PATH" \ + "$RUNNER_TEMP/$(basename "$DMG_PATH").sparkle-signature" - name: Upload macOS app uses: actions/upload-artifact@v4 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 469e0a9886..388c452f6a 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -360,61 +360,9 @@ jobs: WINSPARKLE_DSA_PRIVATE_KEY: ${{ secrets.WINSPARKLE_DSA_PRIVATE_KEY }} FULL_INSTALLER_NAME: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }} run: | - if ([string]::IsNullOrWhiteSpace($env:WINSPARKLE_DSA_PRIVATE_KEY)) { - throw "WINSPARKLE_DSA_PRIVATE_KEY is required for Windows update signing" - } - - $installerPath = "$env:FULL_INSTALLER_NAME.exe" - if (-not (Test-Path $installerPath)) { - throw "Windows installer not found: $installerPath" - } - - $signer = "windows\flutter\ephemeral\.plugin_symlinks\auto_updater_windows\windows\WinSparkle-0.8.1\bin\sign_update.bat" - if (-not (Test-Path $signer)) { - throw "WinSparkle signer not found: $signer" - } - - $privateKeyPath = Join-Path $env:RUNNER_TEMP "winsparkle-dsa-private-key.pem" - $signaturePath = Join-Path $env:RUNNER_TEMP "$env:FULL_INSTALLER_NAME.exe.sparkle-signature" - $signatureBinPath = Join-Path $env:RUNNER_TEMP "winsparkle-signature.bin" - $digestPath = Join-Path $env:RUNNER_TEMP "winsparkle-installer.sha1" - try { - [System.IO.File]::WriteAllText( - $privateKeyPath, - $env:WINSPARKLE_DSA_PRIVATE_KEY, - [System.Text.Encoding]::ASCII - ) - - $signatureOutput = & $signer $installerPath $privateKeyPath - if ($LASTEXITCODE -ne 0) { - throw "WinSparkle signing failed with exit code $LASTEXITCODE" - } - $signature = ($signatureOutput -join "").Trim() - if ([string]::IsNullOrWhiteSpace($signature)) { - throw "WinSparkle produced an empty Windows update signature" - } - [System.IO.File]::WriteAllText( - $signaturePath, - $signature, - [System.Text.Encoding]::ASCII - ) - - & openssl enc -base64 -d -A -in $signaturePath -out $signatureBinPath - if ($LASTEXITCODE -ne 0) { - throw "Unable to decode the WinSparkle signature" - } - & openssl dgst -sha1 -binary -out $digestPath $installerPath - if ($LASTEXITCODE -ne 0) { - throw "Unable to hash the Windows installer for verification" - } - & openssl dgst -sha1 -verify "windows\dsa_pub.pem" -signature $signatureBinPath $digestPath - if ($LASTEXITCODE -ne 0) { - throw "Windows update signature verification failed" - } - } - finally { - Remove-Item -Force -ErrorAction SilentlyContinue $privateKeyPath, $signatureBinPath, $digestPath - } + & ".\.github\scripts\sign_windows_update.ps1" ` + -InstallerPath "$env:FULL_INSTALLER_NAME.exe" ` + -SignaturePath "$env:RUNNER_TEMP\$env:FULL_INSTALLER_NAME.exe.sparkle-signature" - name: Upload Windows installer uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3220335165..ce01499695 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -962,42 +962,6 @@ jobs: gh release upload "$RELEASE_TAG" "$metadata" --clobber done - - name: Publish legacy appcast.xml - if: | - false && - needs.set-metadata.outputs.build_type != 'nightly' && - ( - needs.set-metadata.outputs.platform == 'all' || - contains(needs.set-metadata.outputs.platform, 'macos') || - contains(needs.set-metadata.outputs.platform, 'windows') || - contains(needs.set-metadata.outputs.platform, 'linux') - ) - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - GITHUB_TOKEN: ${{ secrets.CI_PRIVATE_REPOS_GH_TOKEN }} - GH_TOKEN: ${{ github.token }} - BUILD_TYPE: ${{ needs.set-metadata.outputs.build_type }} - RELEASE_TAG: ${{ needs.set-metadata.outputs.release_tag }} - BUCKET: ${{ vars.S3_RELEASES_BUCKET }} - run: | - # Strip 'v' prefix for S3 paths - VERSION="${RELEASE_TAG#v}" - - python3 scripts/generate_appcast.py - - # Keep this legacy feed for released clients that still point at S3. - # New clients use lantern-cloud's channel-aware appcast endpoint. - aws s3 cp appcast.xml "s3://${BUCKET}/releases/${BUILD_TYPE}/${VERSION}/appcast.xml" --acl public-read - aws s3 cp appcast.xml "s3://${BUCKET}/releases/${BUILD_TYPE}/latest/appcast.xml" --acl public-read - if [[ "$BUILD_TYPE" == "production" ]]; then - aws s3 cp appcast.xml "s3://${BUCKET}/releases/appcast.xml" --acl public-read - fi - - # Upload appcast.xml to GitHub release, but - # not git because we may be in detached HEAD - gh release upload "$RELEASE_TAG" appcast.xml --clobber - verify-update-service: needs: [set-metadata, publish-update-metadata, release-finalize] if: | @@ -1049,9 +1013,19 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - echo "All steps succeeded - publishing draft release" + echo "Required release artifacts succeeded - publishing draft release" gh release edit "$RELEASE_TAG" --draft=false + - name: Report update metadata failure + if: | + env.IS_TEST_RUN != 'true' && + env.BUILD_TYPE != 'nightly' && + needs.publish-update-metadata.result == 'failure' + run: | + echo "::warning title=Auto-update metadata was not published::The release remains valid, but desktop auto-update metadata needs follow-up." + echo "### Auto-update metadata publishing failed" >> "$GITHUB_STEP_SUMMARY" + echo "The release was preserved, but desktop auto-update metadata needs follow-up." >> "$GITHUB_STEP_SUMMARY" + - name: Delete draft release if: | env.IS_TEST_RUN == 'true' || diff --git a/scripts/generate_appcast.py b/scripts/generate_appcast.py deleted file mode 100644 index 89ecda5310..0000000000 --- a/scripts/generate_appcast.py +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env python3 -""" -generate_appcast.py - -This script is used to fetch GitHub releases and emits a Sparkle-compatible appcast.xml. -It downloads each platform asset via the asset ID endpoint, parses the associated signatur - -Usage: - export GITHUB_TOKEN= - export BUILD_TYPE= (optional, defaults to production) - python3 scripts/generate_appcast.py -""" -import os -import sys -import subprocess -import tempfile -import re -import requests -import xml.etree.ElementTree as ET - -# -------- Configuration -------- -REPO = "getlantern/lantern" -OUT_PATH = "appcast.xml" -PLATFORMS = { - "macos": ".dmg", - "windows": ".exe", - "linux": ".AppImage", -} -# -------------------------------- - - -def indent(elem, level=0): - """Recursively add indentation to XML for pretty-printing.""" - i = "\n" + level * " " - if len(elem): - if not elem.text or not elem.text.strip(): - elem.text = i + " " - for child in elem: - indent(child, level + 1) - if not child.tail or not child.tail.strip(): - child.tail = i - else: - if level and (not elem.tail or not elem.tail.strip()): - elem.tail = i - - -def main(): - token = os.environ.get('GITHUB_TOKEN') - if not token: - sys.exit("Error: GITHUB_TOKEN variable is not set.") - - build_type = os.environ.get('BUILD_TYPE', 'production') - - api_url = f"https://api.github.com/repos/{REPO}/releases" - api_headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github.v3+json" - } - - resp = requests.get(api_url, headers=api_headers) - resp.raise_for_status() - releases = resp.json() - - ET.register_namespace('sparkle', 'http://www.andymatuschak.org/xml-namespaces/sparkle') - rss = ET.Element('rss', { - 'version': '2.0', - 'xmlns:sparkle': 'http://www.andymatuschak.org/xml-namespaces/sparkle' - }) - channel = ET.SubElement(rss, 'channel') - ET.SubElement(channel, 'title').text = 'Lantern' - ET.SubElement(channel, 'description').text = 'Latest updates for Lantern' - ET.SubElement(channel, 'language').text = 'en' - - signature_re = re.compile(r'sparkle:edSignature="([^"]+)"') - - for release in releases: - # Always skip drafts - if release.get('draft'): - continue - - # For production appcast, skip prereleases - # For beta appcast, include prereleases (beta releases are marked as prerelease) - if release.get('prerelease') and build_type != 'beta': - continue - - name = release.get('name') or release.get('tag_name') - tag = release.get('tag_name', '').lstrip('v') - short_version = tag.split('-')[0] - pub_date = release.get('published_at') - assets = release.get('assets', []) or [] - - item = ET.SubElement(channel, 'item') - ET.SubElement(item, 'title').text = name - ET.SubElement(item, 'sparkle:version').text = tag - ET.SubElement(item, 'sparkle:shortVersionString').text = short_version - ET.SubElement(item, 'pubDate').text = pub_date - - # one per platform asset - for os_name, ext in PLATFORMS.items(): - asset = next((a for a in assets if a.get('name', '').endswith(ext)), None) - if not asset: - continue - - asset_id = asset['id'] - download_url = f"https://api.github.com/repos/{REPO}/releases/assets/{asset_id}" - headers = { - 'Authorization': f"Bearer {token}", - 'Accept': 'application/octet-stream', - 'X-GitHub-Api-Version': '2022-11-28' - } - - # Download to temp file - tmpf = tempfile.NamedTemporaryFile(delete=False) - try: - with requests.get(download_url, headers=headers, stream=True) as r: - r.raise_for_status() - for chunk in r.iter_content(chunk_size=8192): - tmpf.write(chunk) - tmpf.close() - - proc = subprocess.run( - ['dart', 'run', 'auto_updater:sign_update', tmpf.name], - check=True, capture_output=True - ) - out = proc.stdout.decode().strip() - m = signature_re.search(out) - if not m: - raise RuntimeError(f"Failed to parse signature from: {out}") - sig_value = m.group(1) - - size = asset.get('size') or os.path.getsize(tmpf.name) - - # Add enclosure - ET.SubElement(item, 'enclosure', { - 'url': f"https://github.com/{REPO}/releases/download/v{tag}/{asset['name']}", - 'sparkle:edSignature': sig_value, - 'sparkle:os': os_name, - 'length': str(size), - 'type': 'application/octet-stream' - }) - - finally: - os.unlink(tmpf.name) - - indent(rss) - tree = ET.ElementTree(rss) - os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True) - tree.write(OUT_PATH, encoding='utf-8', xml_declaration=True) - print(f"Generated {OUT_PATH}") - - -if __name__ == '__main__': - main() diff --git a/scripts/requirements.txt b/scripts/requirements.txt index df7eaf0c87..36969f2c4b 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -1,2 +1 @@ -requests defusedxml From 4ee679bcbc467bf36ac6827f755811b49b13456a Mon Sep 17 00:00:00 2001 From: atavism Date: Tue, 11 Aug 2026 07:47:04 -0700 Subject: [PATCH 3/8] code review updates --- .github/workflows/build-macos.yml | 2 +- .github/workflows/build-windows.yml | 2 +- lib/core/updater/updater.dart | 8 ++- scripts/ci/generate_update_metadata.py | 54 ++++++++++++++++++++- scripts/ci/generate_update_metadata_test.py | 26 +++++++++- scripts/ci/verify_update_service.py | 14 ++++-- scripts/ci/verify_update_service_test.py | 18 +++++++ 7 files changed, 115 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 27e4a791a5..4fb3b2ef09 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -18,7 +18,7 @@ on: MACOS_PROVISION_TUNNEL_BASE64: required: true SPARKLE_ED_PRIVATE_KEY: - required: true + required: false inputs: version: required: true diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 388c452f6a..a9040c990a 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -10,7 +10,7 @@ on: SIGNPATH_API_TOKEN: required: false WINSPARKLE_DSA_PRIVATE_KEY: - required: true + required: false inputs: version: required: true diff --git a/lib/core/updater/updater.dart b/lib/core/updater/updater.dart index 302c226c64..3e638f8a99 100644 --- a/lib/core/updater/updater.dart +++ b/lib/core/updater/updater.dart @@ -65,8 +65,12 @@ class Updater { final feedUrl = AppUrls.appcastFor(buildType); final updater = AutoUpdater.instance; if (Platform.isWindows) { - final packageInfo = await PackageInfo.fromPlatform(); - setWinSparkleBuildVersion(packageInfo.buildNumber); + try { + final packageInfo = await PackageInfo.fromPlatform(); + setWinSparkleBuildVersion(packageInfo.buildNumber); + } catch (e, st) { + appLogger.warning('Failed to set WinSparkle build version', e, st); + } } await updater.setFeedURL(feedUrl); await updater.setScheduledCheckInterval(3600); diff --git a/scripts/ci/generate_update_metadata.py b/scripts/ci/generate_update_metadata.py index f7c391090a..2de28dd01d 100755 --- a/scripts/ci/generate_update_metadata.py +++ b/scripts/ci/generate_update_metadata.py @@ -47,6 +47,58 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() +def _der_value(data: bytes, offset: int, expected_tag: int) -> tuple[bytes, int]: + """Read one definite-length DER value and return its contents and end offset.""" + if offset >= len(data) or data[offset] != expected_tag: + raise ValueError("unexpected DER tag") + offset += 1 + if offset >= len(data): + raise ValueError("missing DER length") + + first_length_byte = data[offset] + offset += 1 + if first_length_byte < 0x80: + length = first_length_byte + else: + length_byte_count = first_length_byte & 0x7F + if ( + length_byte_count == 0 + or offset + length_byte_count > len(data) + or data[offset] == 0 + ): + raise ValueError("invalid DER length") + length = int.from_bytes(data[offset : offset + length_byte_count], "big") + if length < 0x80: + raise ValueError("non-minimal DER length") + offset += length_byte_count + + end = offset + length + if end > len(data): + raise ValueError("truncated DER value") + return data[offset:end], end + + +def _is_valid_dsa_signature(signature: bytes) -> bool: + """Return whether a signature is one DER sequence of two positive integers.""" + try: + sequence, end = _der_value(signature, 0, 0x30) + if end != len(signature): + return False + r_value, offset = _der_value(sequence, 0, 0x02) + s_value, offset = _der_value(sequence, offset, 0x02) + if offset != len(sequence): + return False + except ValueError: + return False + + for value in (r_value, s_value): + if not value or not any(value) or value[0] & 0x80: + return False + if len(value) > 1 and value[0] == 0 and not value[1] & 0x80: + return False + return True + + def updater_signature(path: Path, signature_dir: Path, kind: str) -> str: # Signing happens on the native platform build runners. This Linux job only # validates and packages their public signatures into release sidecars. @@ -62,7 +114,7 @@ def updater_signature(path: Path, signature_dir: Path, kind: str) -> str: raise RuntimeError(f"invalid {kind} signature for {path.name}") from err if kind == "EdDSA" and len(decoded) != 64: raise RuntimeError(f"invalid EdDSA signature length for {path.name}") - if kind == "DSA" and (not decoded or decoded[0] != 0x30): + if kind == "DSA" and not _is_valid_dsa_signature(decoded): raise RuntimeError(f"invalid DSA signature for {path.name}") return signature diff --git a/scripts/ci/generate_update_metadata_test.py b/scripts/ci/generate_update_metadata_test.py index 4e209c0edc..f26a57bd00 100644 --- a/scripts/ci/generate_update_metadata_test.py +++ b/scripts/ci/generate_update_metadata_test.py @@ -153,7 +153,9 @@ def test_sidecar_for_adds_dsa_signature_for_windows(self) -> None: with tempfile.TemporaryDirectory() as tmp: artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" artifact.write_bytes(b"exe bytes") - signature = "MAMCAQE=" + signature = base64.b64encode( + bytes.fromhex("3006020101020101") + ).decode("ascii") (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( signature, encoding="ascii", @@ -173,6 +175,28 @@ def test_sidecar_for_adds_dsa_signature_for_windows(self) -> None: self.assertEqual(metadata["sparkle_dsa_signature"], signature) self.assertNotIn("sparkle_ed_signature", metadata) + def test_updater_signature_rejects_malformed_dsa_der(self) -> None: + malformed_signatures = { + "missing s integer": bytes.fromhex("3003020101"), + "trailing byte": bytes.fromhex("300602010102010100"), + } + for name, malformed in malformed_signatures.items(): + with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp: + artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" + artifact.write_bytes(b"exe bytes") + signature = base64.b64encode(malformed).decode("ascii") + (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( + signature, + encoding="ascii", + ) + + with self.assertRaisesRegex(RuntimeError, "invalid DSA signature"): + generate_update_metadata.updater_signature( + artifact, + pathlib.Path(tmp), + "DSA", + ) + def test_updater_signature_rejects_invalid_base64(self) -> None: with tempfile.TemporaryDirectory() as tmp: artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" diff --git a/scripts/ci/verify_update_service.py b/scripts/ci/verify_update_service.py index 16c6481d4c..68a96391e8 100644 --- a/scripts/ci/verify_update_service.py +++ b/scripts/ci/verify_update_service.py @@ -239,9 +239,11 @@ def run_checks_once(config: Config) -> None: verify_stable_excludes_beta(config.update_url, expected_version, platform) if config.platforms & set(APPCAST_PLATFORMS): - expected_sparkle_version = normalize_version( - config.sparkle_version or config.version + require( + bool(config.sparkle_version.strip()), + "--sparkle-version is required when verifying macOS or Windows appcasts", ) + expected_sparkle_version = normalize_version(config.sparkle_version) verify_beta_appcast( config.update_url, expected_sparkle_version, @@ -291,6 +293,12 @@ def main() -> None: parser.add_argument("--interval-seconds", type=int, default=60) args = parser.parse_args() + platforms = normalize_platforms(args.platform) + if platforms & set(APPCAST_PLATFORMS) and not args.sparkle_version.strip(): + parser.error( + "--sparkle-version is required when verifying macOS or Windows appcasts" + ) + poll_until_verified( Config( update_url=args.update_url, @@ -298,7 +306,7 @@ def main() -> None: version=args.version, timeout_seconds=args.timeout_seconds, interval_seconds=args.interval_seconds, - platforms=normalize_platforms(args.platform), + platforms=platforms, sparkle_version=args.sparkle_version, ) ) diff --git a/scripts/ci/verify_update_service_test.py b/scripts/ci/verify_update_service_test.py index 5a2afbce74..0b56ef9a23 100644 --- a/scripts/ci/verify_update_service_test.py +++ b/scripts/ci/verify_update_service_test.py @@ -163,6 +163,7 @@ def test_run_checks_once_accepts_missing_stable_appcast(self) -> None: timeout_seconds=1, interval_seconds=1, platforms=verify_update_service.normalize_platforms("all"), + sparkle_version="9.2.0-beta", ) ) @@ -179,9 +180,26 @@ def test_run_checks_once_accepts_single_platform_appcast_release(self) -> None: timeout_seconds=1, interval_seconds=1, platforms=verify_update_service.normalize_platforms("macos"), + sparkle_version="9.2.0-beta", ) ) + def test_run_checks_once_requires_sparkle_version_for_desktop(self) -> None: + with self.assertRaisesRegex( + verify_update_service.VerificationError, + "--sparkle-version is required", + ): + verify_update_service.run_checks_once( + verify_update_service.Config( + update_url=self.update_url, + channel="beta", + version="v9.2.0-beta", + timeout_seconds=1, + interval_seconds=1, + platforms=verify_update_service.normalize_platforms("windows"), + ) + ) + def test_run_checks_once_skips_appcast_for_android_only_release(self) -> None: UpdateServiceHandler.stable_appcast_status = 404 From 2aed67ec376e82becf4da0911e864139834b81d7 Mon Sep 17 00:00:00 2001 From: atavism Date: Tue, 11 Aug 2026 10:18:27 -0700 Subject: [PATCH 4/8] migrate windows updates to eddsa --- .github/scripts/sign_windows_update.ps1 | 82 +++++++++++------ .github/workflows/build-windows.yml | 4 +- pubspec.lock | 13 +-- pubspec.yaml | 5 ++ scripts/ci/generate_update_metadata.py | 73 ++-------------- scripts/ci/generate_update_metadata_test.py | 97 ++++++++------------- scripts/ci/verify_update_service.py | 6 +- scripts/ci/verify_update_service_test.py | 2 +- windows/dsa_pub.pem | 36 -------- windows/runner/Runner.rc | 4 +- 10 files changed, 112 insertions(+), 210 deletions(-) delete mode 100644 windows/dsa_pub.pem diff --git a/.github/scripts/sign_windows_update.ps1 b/.github/scripts/sign_windows_update.ps1 index 434d67a29b..a7ac2d79d6 100644 --- a/.github/scripts/sign_windows_update.ps1 +++ b/.github/scripts/sign_windows_update.ps1 @@ -6,9 +6,11 @@ param( [Parameter(Mandatory = $true)] [string]$SignaturePath, - [string]$PrivateKey = $env:WINSPARKLE_DSA_PRIVATE_KEY, + [string]$PrivateKey = $env:SPARKLE_ED_PRIVATE_KEY, - [string]$PublicKeyPath = "windows\dsa_pub.pem", + [string]$ResourcePath = "windows\runner\Runner.rc", + + [string]$MacOSInfoPlistPath = "macos\Runner\Info.plist", [string]$SignerPath = "", @@ -27,21 +29,47 @@ function Assert-NativeCommandSucceeded { } if ([string]::IsNullOrWhiteSpace($PrivateKey)) { - throw "WINSPARKLE_DSA_PRIVATE_KEY is required for Windows update signing" + throw "SPARKLE_ED_PRIVATE_KEY is required for Windows update signing" } if (-not (Test-Path -LiteralPath $InstallerPath -PathType Leaf)) { throw "Windows installer not found: $InstallerPath" } -if (-not (Test-Path -LiteralPath $PublicKeyPath -PathType Leaf)) { - throw "WinSparkle public key not found: $PublicKeyPath" +if (-not (Test-Path -LiteralPath $ResourcePath -PathType Leaf)) { + throw "Windows resource file not found: $ResourcePath" +} +if (-not (Test-Path -LiteralPath $MacOSInfoPlistPath -PathType Leaf)) { + throw "macOS Info.plist not found: $MacOSInfoPlistPath" +} + +$resourceContents = Get-Content -LiteralPath $ResourcePath -Raw +$resourceMatch = [regex]::Match( + $resourceContents, + 'EdDSAPub\s+EDDSA\s+\{"([A-Za-z0-9+/=]+)"\}' +) +if (-not $resourceMatch.Success) { + throw "EdDSAPub resource not found in $ResourcePath" +} +$publicKey = $resourceMatch.Groups[1].Value + +$infoPlistContents = Get-Content -LiteralPath $MacOSInfoPlistPath -Raw +$infoPlistMatch = [regex]::Match( + $infoPlistContents, + '\s*SUPublicEDKey\s*\s*\s*([^<\s]+)\s*' +) +if (-not $infoPlistMatch.Success) { + throw "SUPublicEDKey not found in $MacOSInfoPlistPath" +} +if ($infoPlistMatch.Groups[1].Value -ne $publicKey) { + throw "Windows and macOS update public keys do not match" } + if ([string]::IsNullOrWhiteSpace($SignerPath)) { $signerRoot = "windows\flutter\ephemeral\.plugin_symlinks\auto_updater_windows\windows" if (-not (Test-Path -LiteralPath $signerRoot -PathType Container)) { throw "WinSparkle plugin directory not found: $signerRoot" } $signerCandidates = @( - Get-ChildItem -LiteralPath $signerRoot -Filter "sign_update.bat" -File -Recurse + Get-ChildItem -LiteralPath $signerRoot -Filter "winsparkle-tool.exe" -File -Recurse ) if ($signerCandidates.Count -ne 1) { throw "Expected one WinSparkle signer under $signerRoot, found $($signerCandidates.Count)" @@ -55,53 +83,49 @@ if ([string]::IsNullOrWhiteSpace($TemporaryDirectory)) { $TemporaryDirectory = [System.IO.Path]::GetTempPath() } -$null = Get-Command openssl -ErrorAction Stop $null = New-Item -ItemType Directory -Force -Path $TemporaryDirectory -$signatureDirectory = Split-Path -Parent ([System.IO.Path]::GetFullPath($SignaturePath)) +$signaturePath = [System.IO.Path]::GetFullPath($SignaturePath) +$signatureDirectory = Split-Path -Parent $signaturePath $null = New-Item -ItemType Directory -Force -Path $signatureDirectory $temporaryPrefix = Join-Path $TemporaryDirectory "winsparkle-$([guid]::NewGuid().ToString('N'))" -$privateKeyPath = "$temporaryPrefix-private.pem" -$signatureTextPath = "$temporaryPrefix-signature.txt" -$signatureBinaryPath = "$temporaryPrefix-signature.bin" -$digestPath = "$temporaryPrefix-installer.sha1" +$privateKeyPath = "$temporaryPrefix-private.key" +$temporarySignaturePath = "$temporaryPrefix-signature.txt" try { [System.IO.File]::WriteAllText( $privateKeyPath, - $PrivateKey, + $PrivateKey.Trim(), [System.Text.Encoding]::ASCII ) - $signatureOutput = & $SignerPath $InstallerPath $privateKeyPath + $signatureOutput = & $SignerPath sign --private-key-file $privateKeyPath $InstallerPath Assert-NativeCommandSucceeded "WinSparkle signing failed" $signature = ($signatureOutput -join "").Trim() if ([string]::IsNullOrWhiteSpace($signature)) { throw "WinSparkle produced an empty Windows update signature" } - [System.IO.File]::WriteAllText( - $signatureTextPath, - $signature, - [System.Text.Encoding]::ASCII - ) - - & openssl enc -base64 -d -A -in $signatureTextPath -out $signatureBinaryPath - Assert-NativeCommandSucceeded "Unable to decode the WinSparkle signature" - - & openssl dgst -sha1 -binary -out $digestPath $InstallerPath - Assert-NativeCommandSucceeded "Unable to hash the Windows installer" + try { + $decodedSignature = [Convert]::FromBase64String($signature) + } + catch { + throw "WinSparkle produced an invalid base64 signature" + } + if ($decodedSignature.Length -ne 64) { + throw "WinSparkle produced an invalid EdDSA signature length: $($decodedSignature.Length)" + } - & openssl dgst -sha1 -verify $PublicKeyPath -signature $signatureBinaryPath $digestPath + & $SignerPath verify --public-key $publicKey --signature $signature $InstallerPath Assert-NativeCommandSucceeded "Windows update signature verification failed" [System.IO.File]::WriteAllText( - $SignaturePath, + $temporarySignaturePath, "$signature`n", [System.Text.Encoding]::ASCII ) + Move-Item -LiteralPath $temporarySignaturePath -Destination $signaturePath -Force } finally { - Remove-Item -Force -ErrorAction SilentlyContinue ` - $privateKeyPath, $signatureTextPath, $signatureBinaryPath, $digestPath + Remove-Item -Force -ErrorAction SilentlyContinue $privateKeyPath, $temporarySignaturePath } diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index a9040c990a..9e52addd5c 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -9,7 +9,7 @@ on: required: false SIGNPATH_API_TOKEN: required: false - WINSPARKLE_DSA_PRIVATE_KEY: + SPARKLE_ED_PRIVATE_KEY: required: false inputs: version: @@ -357,7 +357,7 @@ jobs: if: ${{ inputs.build_type != 'nightly' }} shell: pwsh env: - WINSPARKLE_DSA_PRIVATE_KEY: ${{ secrets.WINSPARKLE_DSA_PRIVATE_KEY }} + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} FULL_INSTALLER_NAME: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }} run: | & ".\.github\scripts\sign_windows_update.ps1" ` diff --git a/pubspec.lock b/pubspec.lock index 452cc1efe5..fe3b0b49ea 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -170,13 +170,14 @@ packages: source: hosted version: "1.0.0" auto_updater_windows: - dependency: transitive + dependency: "direct overridden" description: - name: auto_updater_windows - sha256: "2bba20a71eee072f49b7267fedd5c4f1406c4b1b1e5b83932c634dbab75b80c9" - url: "https://pub.dev" - source: hosted - version: "1.0.0" + path: "packages/auto_updater_windows" + ref: "7d8e67bcd78a8d57516775ea5cf18f4b83786afa" + resolved-ref: "7d8e67bcd78a8d57516775ea5cf18f4b83786afa" + url: "https://github.com/getlantern/auto_updater.git" + source: git + version: "1.0.1" back_button_interceptor: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3614557f33..49341222e5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,11 @@ environment: flutter: ">=3.41.0 <4.0.0" dependency_overrides: + auto_updater_windows: + git: + url: https://github.com/getlantern/auto_updater.git + path: packages/auto_updater_windows + ref: 7d8e67bcd78a8d57516775ea5cf18f4b83786afa # 12.6.x bumps the native Stripe SDKs (iOS ~>25.9), keep in step with flutter_stripe 12.4.0 stripe_android: 12.4.0 stripe_ios: 12.4.0 diff --git a/scripts/ci/generate_update_metadata.py b/scripts/ci/generate_update_metadata.py index 2de28dd01d..a8c3aab937 100755 --- a/scripts/ci/generate_update_metadata.py +++ b/scripts/ci/generate_update_metadata.py @@ -47,75 +47,21 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() -def _der_value(data: bytes, offset: int, expected_tag: int) -> tuple[bytes, int]: - """Read one definite-length DER value and return its contents and end offset.""" - if offset >= len(data) or data[offset] != expected_tag: - raise ValueError("unexpected DER tag") - offset += 1 - if offset >= len(data): - raise ValueError("missing DER length") - - first_length_byte = data[offset] - offset += 1 - if first_length_byte < 0x80: - length = first_length_byte - else: - length_byte_count = first_length_byte & 0x7F - if ( - length_byte_count == 0 - or offset + length_byte_count > len(data) - or data[offset] == 0 - ): - raise ValueError("invalid DER length") - length = int.from_bytes(data[offset : offset + length_byte_count], "big") - if length < 0x80: - raise ValueError("non-minimal DER length") - offset += length_byte_count - - end = offset + length - if end > len(data): - raise ValueError("truncated DER value") - return data[offset:end], end - - -def _is_valid_dsa_signature(signature: bytes) -> bool: - """Return whether a signature is one DER sequence of two positive integers.""" - try: - sequence, end = _der_value(signature, 0, 0x30) - if end != len(signature): - return False - r_value, offset = _der_value(sequence, 0, 0x02) - s_value, offset = _der_value(sequence, offset, 0x02) - if offset != len(sequence): - return False - except ValueError: - return False - - for value in (r_value, s_value): - if not value or not any(value) or value[0] & 0x80: - return False - if len(value) > 1 and value[0] == 0 and not value[1] & 0x80: - return False - return True - - -def updater_signature(path: Path, signature_dir: Path, kind: str) -> str: +def updater_signature(path: Path, signature_dir: Path) -> str: # Signing happens on the native platform build runners. This Linux job only # validates and packages their public signatures into release sidecars. signature_path = signature_dir / f"{path.name}.sparkle-signature" try: signature = signature_path.read_text(encoding="ascii").strip() except FileNotFoundError as err: - raise RuntimeError(f"missing {kind} signature for {path.name}") from err + raise RuntimeError(f"missing EdDSA signature for {path.name}") from err try: decoded = base64.b64decode(signature, validate=True) except (binascii.Error, ValueError) as err: - raise RuntimeError(f"invalid {kind} signature for {path.name}") from err - if kind == "EdDSA" and len(decoded) != 64: + raise RuntimeError(f"invalid EdDSA signature for {path.name}") from err + if len(decoded) != 64: raise RuntimeError(f"invalid EdDSA signature length for {path.name}") - if kind == "DSA" and not _is_valid_dsa_signature(decoded): - raise RuntimeError(f"invalid DSA signature for {path.name}") return signature @@ -149,20 +95,13 @@ def sidecar_for( "size": path.stat().st_size, "sha256": sha256_file(path), } - if platform == "macos": - if not sparkle_version: - raise RuntimeError(f"missing Sparkle version for {path.name}") - if signature_dir is None: - raise RuntimeError(f"missing signature directory for {path.name}") - metadata["sparkle_version"] = sparkle_version - metadata["sparkle_ed_signature"] = updater_signature(path, signature_dir, "EdDSA") - elif platform == "windows": + if platform in ("macos", "windows"): if not sparkle_version: raise RuntimeError(f"missing Sparkle version for {path.name}") if signature_dir is None: raise RuntimeError(f"missing signature directory for {path.name}") metadata["sparkle_version"] = sparkle_version - metadata["sparkle_dsa_signature"] = updater_signature(path, signature_dir, "DSA") + metadata["sparkle_ed_signature"] = updater_signature(path, signature_dir) return metadata diff --git a/scripts/ci/generate_update_metadata_test.py b/scripts/ci/generate_update_metadata_test.py index f26a57bd00..585bf4f906 100644 --- a/scripts/ci/generate_update_metadata_test.py +++ b/scripts/ci/generate_update_metadata_test.py @@ -127,90 +127,62 @@ def test_sidecar_for_skips_unknown_artifacts(self) -> None: self.assertIsNone(metadata) def test_sidecar_for_adds_sparkle_signature_for_desktop(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - artifact = pathlib.Path(tmp) / "lantern-installer-beta.dmg" - artifact.write_bytes(b"dmg bytes") - signature = base64.b64encode(b"s" * 64).decode("ascii") - (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( - signature, - encoding="ascii", - ) + cases = { + "macos": "lantern-installer-beta.dmg", + "windows": "lantern-installer-beta.exe", + } + for platform, filename in cases.items(): + with self.subTest(platform=platform), tempfile.TemporaryDirectory() as tmp: + artifact = pathlib.Path(tmp) / filename + artifact.write_bytes(b"installer bytes") + signature = base64.b64encode(b"s" * 64).decode("ascii") + (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( + signature, + encoding="ascii", + ) - metadata = generate_update_metadata.sidecar_for( - artifact, - "beta", - "9.2.0-beta", - "lantern.io", - pathlib.Path(tmp), - "920", - ) + metadata = generate_update_metadata.sidecar_for( + artifact, + "beta", + "9.2.0-beta", + "lantern.io", + pathlib.Path(tmp), + "920", + ) - self.assertEqual(metadata["platform"], "macos") - self.assertEqual(metadata["sparkle_version"], "920") - self.assertEqual(metadata["sparkle_ed_signature"], signature) + self.assertEqual(metadata["platform"], platform) + self.assertEqual(metadata["sparkle_version"], "920") + self.assertEqual(metadata["sparkle_ed_signature"], signature) - def test_sidecar_for_adds_dsa_signature_for_windows(self) -> None: + def test_updater_signature_rejects_invalid_base64(self) -> None: with tempfile.TemporaryDirectory() as tmp: artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" artifact.write_bytes(b"exe bytes") - signature = base64.b64encode( - bytes.fromhex("3006020101020101") - ).decode("ascii") (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( - signature, + "not a signature", encoding="ascii", ) - metadata = generate_update_metadata.sidecar_for( - artifact, - "beta", - "9.2.0-beta", - "lantern.io", - pathlib.Path(tmp), - "920", - ) - - self.assertEqual(metadata["platform"], "windows") - self.assertEqual(metadata["sparkle_version"], "920") - self.assertEqual(metadata["sparkle_dsa_signature"], signature) - self.assertNotIn("sparkle_ed_signature", metadata) - - def test_updater_signature_rejects_malformed_dsa_der(self) -> None: - malformed_signatures = { - "missing s integer": bytes.fromhex("3003020101"), - "trailing byte": bytes.fromhex("300602010102010100"), - } - for name, malformed in malformed_signatures.items(): - with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp: - artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" - artifact.write_bytes(b"exe bytes") - signature = base64.b64encode(malformed).decode("ascii") - (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( - signature, - encoding="ascii", + with self.assertRaisesRegex(RuntimeError, "invalid EdDSA signature"): + generate_update_metadata.updater_signature( + artifact, + pathlib.Path(tmp), ) - with self.assertRaisesRegex(RuntimeError, "invalid DSA signature"): - generate_update_metadata.updater_signature( - artifact, - pathlib.Path(tmp), - "DSA", - ) - - def test_updater_signature_rejects_invalid_base64(self) -> None: + def test_updater_signature_rejects_wrong_eddsa_length(self) -> None: with tempfile.TemporaryDirectory() as tmp: artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" artifact.write_bytes(b"exe bytes") + signature = base64.b64encode(b"short signature").decode("ascii") (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( - "not a signature", + signature, encoding="ascii", ) - with self.assertRaisesRegex(RuntimeError, "invalid DSA signature"): + with self.assertRaisesRegex(RuntimeError, "invalid EdDSA signature length"): generate_update_metadata.updater_signature( artifact, pathlib.Path(tmp), - "DSA", ) def test_updater_signature_requires_native_runner_output(self) -> None: @@ -222,7 +194,6 @@ def test_updater_signature_requires_native_runner_output(self) -> None: generate_update_metadata.updater_signature( artifact, pathlib.Path(tmp), - "EdDSA", ) diff --git a/scripts/ci/verify_update_service.py b/scripts/ci/verify_update_service.py index 68a96391e8..417ec2f348 100644 --- a/scripts/ci/verify_update_service.py +++ b/scripts/ci/verify_update_service.py @@ -171,7 +171,6 @@ def parse_appcast(xml_text: str) -> tuple[str, list[dict[str, str]]]: { "url": enclosure.attrib.get("url", ""), "ed_signature": enclosure.attrib.get(f"{{{SPARKLE_NS}}}edSignature", ""), - "dsa_signature": enclosure.attrib.get(f"{{{SPARKLE_NS}}}dsaSignature", ""), "os": enclosure.attrib.get(f"{{{SPARKLE_NS}}}os", ""), } ) @@ -203,10 +202,9 @@ def verify_beta_appcast( for os_name, suffix in required_platforms.items(): enclosure = by_os.get(os_name) require(enclosure is not None, f"beta appcast missing {os_name} enclosure") - signature_key = "ed_signature" if os_name == "macos" else "dsa_signature" require( - enclosure[signature_key], - f"beta appcast {os_name} enclosure missing {signature_key}", + enclosure["ed_signature"], + f"beta appcast {os_name} enclosure missing EdDSA signature", ) require( enclosure["url"].endswith(suffix), diff --git a/scripts/ci/verify_update_service_test.py b/scripts/ci/verify_update_service_test.py index 0b56ef9a23..87285da338 100644 --- a/scripts/ci/verify_update_service_test.py +++ b/scripts/ci/verify_update_service_test.py @@ -101,7 +101,7 @@ def write_xml(self, data: str) -> None: def appcast_xml(version: str, enclosures: list[tuple[str, str, str]]) -> str: enclosure_xml = "\n".join( f'' for os_name, signature, url in enclosures diff --git a/windows/dsa_pub.pem b/windows/dsa_pub.pem deleted file mode 100644 index 6433d0aa43..0000000000 --- a/windows/dsa_pub.pem +++ /dev/null @@ -1,36 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIIGRzCCBDkGByqGSM44BAEwggQsAoICAQDgBDHQyYKtkPu+L7TxWVja/pWktmyf -pKI+g00miLmBYJ2H85c8Egk7rGIRJUrWfCd7EtpgBnpeissd4qhTPgLxyt0lrVuP -FMIzK6DL65DtosGPsbXsl32THePayorKOtkDB7rHFBYlLxS4DwSev+qAzj+YHY48 -pK09YwmTPdvGNU87UgfOyqINBGxEmpdxW/fSDg1Wpaytuw2xGUEO5RyhqW57Z3Nk -m58E/fG+GlakUM6TotL7k1c//6UVNP2Z7vBmvUcfTZYn416gsGXsMSH3y8jBME8f -K0WNGqcTYbJVs1DEC4Do3ppoLBM+eEjfa7WHo36EDCuAqFCcPiayUBIY1bBBJd6O -tkv3Rjq/oopqo/WjQCxiFvdORWgPkPmbm+A9zaUnAqKdz4dEe+9OWt+iX8XiqPNZ -QVJLapSmVrRKnxVim8j89Hv5a2hIqSmwoZO2XftAJB1P1vYTwSn5IJ/NUuAKO+4a -cW9GmELHvnpPAZxhRR/2q1v00nDnZ8sqhbWuTcxNXeg67IAIApR3ThSybLH7+i33 -4VtV3eGW6aCGDZNPh+avWV/kIkrqK87YAqQS/PHhy8XKglnqNQh26SntZRfD21tX -l3S+3mRlvpoVfWCLfY8kdhLU8HhynZN59GBUII4Sc69cVgoCSaiHp1i2MuhW6F7G -UgXyteffVPhdEQIhAMnoXjnZaYxGO9WnfBOYyklhwlfR2160sRilgEOsR+LVAoIC -AErwijtlyqalJlWlEfmpBzdlTRGzO3K7wEdEBq60QqzX1wqjHHG+Uen1Q8z4dTU+ -tFrBKvwnDFe8oVOxjkRzadCGGOrhf+xmbi1TRJQIFyy1A2lm4r3gjtsh9FaTUXo9 -pL+c3X8GFQ90ZAoAISarHOJvJ6wO/G5tRa/YdK9pQxfD36Cc0hJAe23hUwdUbMwr -JJpxrUr006zWzV3Q3FFJVNs6AL9i15lQvzTFxgXBeDNJiWMMtJWS6GUIEiP9ktR1 -57yBu4SE8e8CjgPUOL8ZNk+hcKEJaQ0TZzZ/mtn16gFhYJp+2zWZIROAazBXajrw -gKTxsg4gIf48prHYk6VrPWL2ilQY3FXAGYGRso7xPHDEogyL4xN7OZuVXxi8sZck -netJvbF8VlKoo7n2Mw3tjOpIRHNCNC+kpwUCOk3w7VF3cpEj2cnK29qdyjz5yM+E -LKr6/O0bIJ17ShzAr0KzO9IHjas/PW1RhKds06MZo37lhbDu03AJLGVTJafT8Ana -x1vnDRm2NPc2iy5elrYtvcL3Qy5pD38GPL5qW/L+aa4Oqg2nySWpCU7vvOaxHxIf -+UyfWJARZb5D0FVzGdqFK939gxMeQwcK6nqvXJPPNCYAF7hlZHzlXMVeEQVqjpfK -tDfzxc3uqJ+I3c8qCPm4OwrCljXRu9rVtylAXPusDAn/A4ICBgACggIBAIOLDJLZ -Rjmq7xkWiJ0fmuWMFduD55KRhbT3WWGvzNSooytlfxf1FNrciLQ7RWuMDhUTpmGE -n01WqN1wqFUwP0qkUPYRtKWrrELdfsefsaDgdSKInR/TnH9hx9l4yzUvC/D6EOQ3 -K1Kiet2CzFQHmViuXCIpacqK/Qi//hSB7JFMacYd5Vrjn8pWtC2Mu507XqUm+5TG -O11i5H3V2gcUzoar4TAl2aGOxvJbTRkQHxCXP3YmFwAJjvNNLUcyUFlG7yaldGQn -jp6K4qIwX7mjQ3hcR07ncUbcps2YhTkM3Y7XTceYsM81encVoXXB5K14bHlR2Acj -WF+Ha2fAobmK/Hy4U/DtYNcyDFncZl/o/rPgtSVhuIGc0ApBeZGuNyTpuvnAGQay -ozxprfrAde4ExnMiJ0MfRfAQSjrSZr5s+iwNalrayfhH4dDUNTUwyIISzJ1QDvNJ -nJ0jao+hFy384m/33cUIrk0ueiec/RgXKs8tG1tTxK/sF3VNR9jU955ZewcibAKP -tnHkmQ+uz7kQE6hii+1ODVodTpA5H/mljXYiqFVi9puyWVmCHtAHHEJx5SbrJxu0 -IMc9hd0y6Q6bgCoIZgBJTxxBcq7oDuudVpRuqKyhB17fmOTySnzuZgEzrzV181hS -r8RRVl/u0zUP503Tw3UvtoFOHB24o4xrO6hg ------END PUBLIC KEY----- diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 61317d0de8..728d673213 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -60,8 +60,8 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" // WinSparkle // -// Verify update signatures with the public half of the CI signing key. -DSAPub DSAPEM "..\\dsa_pub.pem" +// Keep this in sync with SUPublicEDKey in macos/Runner/Info.plist. +EdDSAPub EDDSA {"J9Pe9z0DTNLMygl0zxG0BWBON2HbTebIO1et3fARypo="} ///////////////////////////////////////////////////////////////////////////// From 596cb2059ae2871730111af905521e533445faec Mon Sep 17 00:00:00 2001 From: atavism Date: Tue, 11 Aug 2026 11:15:27 -0700 Subject: [PATCH 5/8] code review updates --- lib/core/updater/updater.dart | 105 ++++++++++++++++++++++++---- test/core/updater/updater_test.dart | 57 +++++++++++++++ 2 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 test/core/updater/updater_test.dart diff --git a/lib/core/updater/updater.dart b/lib/core/updater/updater.dart index 3e638f8a99..d7e15530d9 100644 --- a/lib/core/updater/updater.dart +++ b/lib/core/updater/updater.dart @@ -11,25 +11,38 @@ import 'package:lantern/core/updater/android_sideload_updater.dart'; import 'package:lantern/core/updater/winsparkle_build_version.dart'; import 'package:lantern/lantern/lantern_service.dart'; import 'package:package_info_plus/package_info_plus.dart'; - -class Updater { - Updater({AndroidSideloadUpdater? androidSideloadUpdater}) - : _androidSideloadUpdater = - androidSideloadUpdater ?? AndroidSideloadUpdater(); +import 'package:tray_manager/tray_manager.dart'; +import 'package:window_manager/window_manager.dart'; + +class Updater with UpdaterListener { + Updater({ + AndroidSideloadUpdater? androidSideloadUpdater, + AutoUpdater? autoUpdater, + bool? isWindows, + Future Function()? quitForUpdate, + }) : _androidSideloadUpdater = + androidSideloadUpdater ?? AndroidSideloadUpdater(), + _autoUpdater = autoUpdater ?? AutoUpdater.instance, + _isWindowsPlatform = isWindows ?? (!kIsWeb && Platform.isWindows), + _quitForUpdate = quitForUpdate; final AndroidSideloadUpdater _androidSideloadUpdater; + final AutoUpdater _autoUpdater; + final bool _isWindowsPlatform; + final Future Function()? _quitForUpdate; - bool _initialized = false; + Future? _initialization; + bool _listenerRegistered = false; + bool _quittingForUpdate = false; bool get _isAndroidPlatform => !kIsWeb && Platform.isAndroid; bool get _isSupportedPlatform => !kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isAndroid); - Future init() async { - if (_initialized) return; - _initialized = true; + Future init() => _initialization ??= _initialize(); + Future _initialize() async { if (kDebugMode || !_isSupportedPlatform) return; final flags = await _featureFlags(); @@ -43,6 +56,7 @@ class Updater { Future canCheckForUpdates() async { if (!_isSupportedPlatform) return false; try { + await init(); final flags = await _featureFlags(); if (_isAndroidPlatform) { return _androidSideloadUpdater.isEnabled(flags, logDisabled: false); @@ -63,7 +77,10 @@ class Updater { try { final buildType = AppBuildInfo.buildType; final feedUrl = AppUrls.appcastFor(buildType); - final updater = AutoUpdater.instance; + if (!_listenerRegistered) { + _autoUpdater.addListener(this); + _listenerRegistered = true; + } if (Platform.isWindows) { try { final packageInfo = await PackageInfo.fromPlatform(); @@ -72,15 +89,15 @@ class Updater { appLogger.warning('Failed to set WinSparkle build version', e, st); } } - await updater.setFeedURL(feedUrl); - await updater.setScheduledCheckInterval(3600); + await _autoUpdater.setFeedURL(feedUrl); + await _autoUpdater.setScheduledCheckInterval(3600); // Background check after startup (avoid modal immediately on launch) const firstPromptDelay = Duration(seconds: 45); unawaited( Future.delayed(firstPromptDelay, () async { try { - await updater.checkForUpdates(inBackground: true); + await _autoUpdater.checkForUpdates(inBackground: true); } catch (e, st) { appLogger.error('Failed to check for auto-updates', e, st); } @@ -97,6 +114,7 @@ class Updater { Future checkNow() async { if (!_isSupportedPlatform) return; + await init(); final flags = await _featureFlags(); if (_isAndroidPlatform) { @@ -113,9 +131,68 @@ class Updater { ); return; } - await AutoUpdater.instance.checkForUpdates(); + await _autoUpdater.checkForUpdates(); + } + + @override + void onUpdaterBeforeQuitForUpdate(AppcastItem? appcastItem) { + if (!_isWindowsPlatform || _quittingForUpdate) return; + _quittingForUpdate = true; + appLogger.info('WinSparkle is ready to install; shutting down Lantern'); + unawaited(_shutdownForWindowsUpdate()); + } + + Future _shutdownForWindowsUpdate() async { + try { + await (_quitForUpdate ?? _quitDesktopForUpdate)(); + } catch (e, st) { + _quittingForUpdate = false; + appLogger.error('Failed to shut down for Windows update', e, st); + } + } + + Future _quitDesktopForUpdate() async { + // WinSparkle has already launched the installer when it sends this event. + // Tear down Lantern's desktop UI so the installer can replace the binary. + try { + await windowManager.setPreventClose(false); + } catch (e, st) { + appLogger.warning('Failed to release the Lantern window', e, st); + } + try { + await trayManager.destroy(); + } catch (e, st) { + appLogger.warning('Failed to close the Lantern tray icon', e, st); + } + try { + await windowManager.destroy(); + } catch (e, st) { + appLogger.warning('Failed to close the Lantern window', e, st); + } + exit(0); + } + + @override + void onUpdaterCheckingForUpdate(Appcast? appcast) {} + + @override + void onUpdaterError(UpdaterError? error) { + appLogger.warning('Desktop update check failed: $error'); + } + + @override + void onUpdaterUpdateAvailable(AppcastItem? appcastItem) { + appLogger.info('Desktop update available'); } + @override + void onUpdaterUpdateDownloaded(AppcastItem? appcastItem) { + appLogger.info('Desktop update downloaded'); + } + + @override + void onUpdaterUpdateNotAvailable(UpdaterError? error) {} + Future> _featureFlags() async { final flagResult = await sl().featureFlag(); return flagResult.fold((_) => {}, (jsonStr) { diff --git a/test/core/updater/updater_test.dart b/test/core/updater/updater_test.dart new file mode 100644 index 0000000000..a7679caf56 --- /dev/null +++ b/test/core/updater/updater_test.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:lantern/core/updater/updater.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('WinSparkle shutdown', () { + test('quits when WinSparkle is ready to install', () async { + final quitStarted = Completer(); + final updater = Updater( + isWindows: true, + quitForUpdate: () async => quitStarted.complete(), + ); + + updater.onUpdaterBeforeQuitForUpdate(null); + + await quitStarted.future; + }); + + test('ignores duplicate shutdown requests', () async { + final quitStarted = Completer(); + final allowQuitToFinish = Completer(); + var quitCalls = 0; + final updater = Updater( + isWindows: true, + quitForUpdate: () async { + quitCalls++; + quitStarted.complete(); + await allowQuitToFinish.future; + }, + ); + + updater.onUpdaterBeforeQuitForUpdate(null); + await quitStarted.future; + updater.onUpdaterBeforeQuitForUpdate(null); + allowQuitToFinish.complete(); + await Future.delayed(Duration.zero); + + expect(quitCalls, 1); + }); + + test('leaves shutdown to Sparkle on other platforms', () async { + var quitCalls = 0; + final updater = Updater( + isWindows: false, + quitForUpdate: () async => quitCalls++, + ); + + updater.onUpdaterBeforeQuitForUpdate(null); + await Future.delayed(Duration.zero); + + expect(quitCalls, 0); + }); + }); +} From 6df977640cfff93b574a9dba86490973c90e806c Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 13 Aug 2026 09:16:00 -0700 Subject: [PATCH 6/8] Add macOS E2E test for auto-updates (#8968) * add macOS auto-update E2E smoke * fix appcast fetch from GitHub Actions * code review updates * add cross-platform auto-update smoke * make auto-update fixtures dispatchable * simplify auto-update smoke * fix Windows Flutter setup * code review updates * code review updates * code review updates --- .github/scripts/macos_auto_update_smoke.sh | 289 ++++++++++++++ .../scripts/macos_sparkle_handoff.applescript | 144 +++++++ .github/scripts/windows_auto_update_smoke.ps1 | 351 ++++++++++++++++++ .github/workflows/android-compile-check.yml | 2 +- .github/workflows/app-smoke-tests.yml | 39 +- .github/workflows/build-android.yml | 2 +- .../workflows/build-auto-update-fixtures.yml | 179 +++++++++ .github/workflows/build-ios.yml | 2 +- .github/workflows/build-linux.yml | 2 +- .github/workflows/build-macos.yml | 49 ++- .github/workflows/build-windows.yml | 96 ++++- .github/workflows/firebase-test-lab.yml | 2 +- .github/workflows/flutter-test.yml | 2 +- .github/workflows/macos-auto-update-smoke.yml | 185 +++++++++ .../workflows/windows-auto-update-smoke.yml | 200 ++++++++++ Makefile | 59 ++- .../auto_update/auto_update_robot.dart | 99 +++++ .../desktop_auto_update_smoke_test.dart | 41 ++ integration_test/utils/app_robot.dart | 70 +++- lib/core/common/app_build_info.dart | 5 + lib/core/common/app_urls.dart | 10 +- lib/features/home/home.dart | 1 + lib/features/setting/setting.dart | 2 + scripts/ci/generate_update_metadata.py | 12 +- scripts/ci/generate_update_metadata_test.py | 27 ++ scripts/ci/resolve_desktop_update_target.py | 234 ++++++++++++ .../ci/resolve_desktop_update_target_test.py | 141 +++++++ scripts/ci/verify_fixture_update_artifacts.py | 140 +++++++ test/core/common/app_urls_test.dart | 7 + test_driver/integration_test.dart | 3 + 30 files changed, 2346 insertions(+), 49 deletions(-) create mode 100644 .github/scripts/macos_auto_update_smoke.sh create mode 100644 .github/scripts/macos_sparkle_handoff.applescript create mode 100644 .github/scripts/windows_auto_update_smoke.ps1 create mode 100644 .github/workflows/build-auto-update-fixtures.yml create mode 100644 .github/workflows/macos-auto-update-smoke.yml create mode 100644 .github/workflows/windows-auto-update-smoke.yml create mode 100644 integration_test/auto_update/auto_update_robot.dart create mode 100644 integration_test/auto_update/desktop_auto_update_smoke_test.dart create mode 100644 scripts/ci/resolve_desktop_update_target.py create mode 100644 scripts/ci/resolve_desktop_update_target_test.py create mode 100644 scripts/ci/verify_fixture_update_artifacts.py create mode 100644 test_driver/integration_test.dart diff --git a/.github/scripts/macos_auto_update_smoke.sh b/.github/scripts/macos_auto_update_smoke.sh new file mode 100644 index 0000000000..565a5ff459 --- /dev/null +++ b/.github/scripts/macos_auto_update_smoke.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly APP_PATH="/Applications/Lantern.app" +readonly APP_EXECUTABLE="$APP_PATH/Contents/MacOS/Lantern" +readonly DATA_PATH="/Users/Shared/Lantern" +readonly HANDOFF_PATH="$DATA_PATH/E2E/auto-update-handoff.json" +readonly DEFAULTS_DOMAIN="org.getlantern.lantern" +readonly ROBOT_SOURCE=".github/scripts/macos_sparkle_handoff.applescript" +readonly ROBOT_SCRIPT="${RUNNER_TEMP:?RUNNER_TEMP is required}/macos-sparkle-handoff.scpt" +readonly FIXTURE_DMG="${FIXTURE_DMG:?FIXTURE_DMG is required}" +readonly TARGET_JSON="${TARGET_JSON:?TARGET_JSON is required}" +readonly APPCAST_XML="${APPCAST_XML:?APPCAST_XML is required}" +readonly ARTIFACT_DIR="${ARTIFACT_DIR:-smoke-artifacts/macos-auto-update}" +readonly UI_TIMEOUT_SECONDS="${UI_TIMEOUT_SECONDS:-120}" +readonly UPDATE_TIMEOUT_SECONDS="${UPDATE_TIMEOUT_SECONDS:-600}" + +[[ "$UI_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || { + printf 'UI_TIMEOUT_SECONDS must be a positive integer.\n' >&2 + exit 2 +} +[[ "$UPDATE_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || { + printf 'UPDATE_TIMEOUT_SECONDS must be a positive integer.\n' >&2 + exit 2 +} + +MOUNT_PATH="" +ORIGINAL_PID="" +RELAUNCHED_PID="" +RESULT="failure" + +log_e2e() { + printf '[E2E] %s\n' "$*" >&2 +} + +guard_ci_paths() { + [[ "${CI:-}" == "true" && "${GITHUB_ACTIONS:-}" == "true" ]] || { + printf 'This smoke only runs in GitHub Actions CI.\n' >&2 + exit 2 + } + [[ "${RUNNER_ENVIRONMENT:-}" == "self-hosted" ]] || { + printf 'This smoke requires the dedicated self-hosted macOS runner.\n' >&2 + exit 2 + } + [[ "${LANTERN_AUTO_UPDATE_SMOKE:-}" == "true" ]] || { + printf 'The explicit auto-update cleanup guard is missing.\n' >&2 + exit 2 + } + [[ "$APP_PATH" == "/Applications/Lantern.app" && "$DATA_PATH" == "/Users/Shared/Lantern" ]] || { + printf 'Refusing to use unexpected Lantern paths.\n' >&2 + exit 2 + } +} + +lantern_pids() { + pgrep -f "^${APP_EXECUTABLE}([[:space:]]|$)" 2>/dev/null || true +} + +quit_lantern() { + osascript -e 'tell application id "org.getlantern.lantern" to quit' >/dev/null 2>&1 || true + local pid + while IFS= read -r pid; do + [[ -n "$pid" ]] && kill -TERM "$pid" 2>/dev/null || true + done < <(lantern_pids) +} + +wait_for_exit() { + local pid="$1" + local deadline=$((SECONDS + $2)) + while kill -0 "$pid" 2>/dev/null; do + ((SECONDS < deadline)) || return 1 + sleep 1 + done +} + +wait_for_new_pid() { + local excluded_pid="$1" + local deadline=$((SECONDS + $2)) + while ((SECONDS < deadline)); do + local pid + while IFS= read -r pid; do + if [[ -n "$pid" && "$pid" != "$excluded_pid" ]] && kill -0 "$pid" 2>/dev/null; then + printf '%s\n' "$pid" + return 0 + fi + done < <(lantern_pids) + sleep 1 + done + return 1 +} + +capture_screenshot() { + local name="$1" + screencapture -x "$ARTIFACT_DIR/$name.png" \ + 2>"$ARTIFACT_DIR/$name-screenshot-error.txt" || true +} + +capture_processes() { + local name="$1" + { date -u; ps -axo pid=,ppid=,etime=,state=,command=; } \ + >"$ARTIFACT_DIR/processes-$name.txt" 2>&1 || true +} + +bundle_value() { + /usr/libexec/PlistBuddy -c "Print :$1" "$APP_PATH/Contents/Info.plist" +} + +capture_versions() { + local name="$1" + { + printf 'CFBundleVersion=%s\n' "$(bundle_value CFBundleVersion)" + printf 'CFBundleShortVersionString=%s\n' "$(bundle_value CFBundleShortVersionString)" + codesign -dvvv "$APP_PATH" + } >"$ARTIFACT_DIR/versions-$name.txt" 2>&1 || true +} + +verify_bundle() { + local name="$1" + local expected_build="$2" + local expected_display="$3" + [[ "$(bundle_value CFBundleVersion)" == "$expected_build" ]] || { + printf '%s bundle build did not match %s.\n' "$name" "$expected_build" >&2 + return 1 + } + [[ "$(bundle_value CFBundleShortVersionString)" == "$expected_display" ]] || { + printf '%s display version did not match %s.\n' "$name" "$expected_display" >&2 + return 1 + } + { + codesign --verify --deep --strict --verbose=4 "$APP_PATH" + spctl --assess --type execute --verbose=4 "$APP_PATH" + } >"$ARTIFACT_DIR/signature-$name.txt" 2>&1 +} + +detach_dmg() { + if [[ -n "$MOUNT_PATH" && -d "$MOUNT_PATH" ]]; then + hdiutil detach "$MOUNT_PATH" -quiet >/dev/null 2>&1 || true + rmdir "$MOUNT_PATH" 2>/dev/null || true + MOUNT_PATH="" + fi +} + +cleanup() { + guard_ci_paths + quit_lantern + local tracked_pid + for tracked_pid in "$ORIGINAL_PID" "$RELAUNCHED_PID"; do + if [[ -n "$tracked_pid" ]] && kill -0 "$tracked_pid" 2>/dev/null; then + kill -TERM "$tracked_pid" 2>/dev/null || true + wait_for_exit "$tracked_pid" 10 || kill -KILL "$tracked_pid" 2>/dev/null || true + fi + done + local pid + while IFS= read -r pid; do + if [[ -n "$pid" ]] && ! wait_for_exit "$pid" 10; then + kill -KILL "$pid" 2>/dev/null || true + fi + done < <(lantern_pids) + rm -rf -- "$APP_PATH" + rm -rf -- "$DATA_PATH" + defaults delete "$DEFAULTS_DOMAIN" >/dev/null 2>&1 || true +} + +capture_diagnostics() { + mkdir -p "$ARTIFACT_DIR" + { + printf 'result=%s\noriginal_pid=%s\nrelaunched_pid=%s\n' \ + "$RESULT" "$ORIGINAL_PID" "$RELAUNCHED_PID" + } >"$ARTIFACT_DIR/result.txt" + capture_processes final + [[ -d "$APP_PATH" ]] && capture_versions final + if [[ -d "$DATA_PATH/Logs" ]]; then + mkdir -p "$ARTIFACT_DIR/lantern-logs" + cp -R "$DATA_PATH/Logs/." "$ARTIFACT_DIR/lantern-logs/" 2>/dev/null || true + fi + if [[ -d "$HOME/Library/Logs/Sparkle" ]]; then + mkdir -p "$ARTIFACT_DIR/sparkle-logs" + cp -R "$HOME/Library/Logs/Sparkle/." "$ARTIFACT_DIR/sparkle-logs/" 2>/dev/null || true + fi + log show --last 90m --style syslog \ + --predicate 'process == "Lantern" OR process == "Updater" OR process == "Installer" OR process == "Downloader" OR eventMessage CONTAINS[c] "Sparkle" OR subsystem == "org.getlantern.lantern"' \ + >"$ARTIFACT_DIR/unified-lantern-sparkle.log" 2>&1 || true +} + +on_exit() { + local status=$? + set +e + capture_diagnostics + capture_screenshot final + detach_dmg + cleanup + exit "$status" +} +trap on_exit EXIT + +install_fixture() { + MOUNT_PATH="$(mktemp -d)" + hdiutil attach "$FIXTURE_DMG" -nobrowse -readonly -mountpoint "$MOUNT_PATH" >/dev/null + local source_app + source_app="$(find "$MOUNT_PATH" -maxdepth 3 -type d -name Lantern.app -print -quit)" + [[ -n "$source_app" ]] || return 1 + ditto "$source_app" "$APP_PATH" + detach_dmg +} + +guard_ci_paths +mkdir -p "$ARTIFACT_DIR" +cp "$APPCAST_XML" "$ARTIFACT_DIR/appcast.xml" +cp "$TARGET_JSON" "$ARTIFACT_DIR/resolved-target.json" +osacompile -o "$ROBOT_SCRIPT" "$ROBOT_SOURCE" + +TARGET_BUILD="$(jq -er '.target_build | tostring' "$TARGET_JSON")" +FIXTURE_BUILD="$(jq -er '.fixture_build | tostring' "$TARGET_JSON")" +DISPLAY_VERSION="$(jq -er .display_version "$TARGET_JSON")" +readonly TARGET_BUILD FIXTURE_BUILD DISPLAY_VERSION + +cleanup +mkdir -p "$DATA_PATH/E2E" +{ + xcrun stapler validate "$FIXTURE_DMG" + spctl --assess --type open --context context:primary-signature --verbose=4 "$FIXTURE_DMG" +} >"$ARTIFACT_DIR/fixture-notarization.txt" 2>&1 +install_fixture +verify_bundle fixture "$FIXTURE_BUILD" "$DISPLAY_VERSION" +capture_versions fixture + +log_e2e "running the Flutter auto-update robot against the installed fixture" +set +e +flutter drive \ + --profile \ + --use-application-binary="$APP_PATH" \ + --keep-app-running \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/auto_update/desktop_auto_update_smoke_test.dart \ + --device-id=macos \ + >"$ARTIFACT_DIR/flutter-drive.log" 2>&1 +drive_status=$? +set -e +cat "$ARTIFACT_DIR/flutter-drive.log" +[[ "$drive_status" -eq 0 ]] || exit "$drive_status" + +[[ -f "$HANDOFF_PATH" ]] || { + printf 'Flutter test completed without creating its native handoff.\n' >&2 + exit 1 +} +cp "$HANDOFF_PATH" "$ARTIFACT_DIR/auto-update-handoff.json" +if [[ -f "$DATA_PATH/Logs/screenshots/auto-update-before.png" ]]; then + cp "$DATA_PATH/Logs/screenshots/auto-update-before.png" "$ARTIFACT_DIR/before.png" +fi +ORIGINAL_PID="$(jq -er '.pid | tostring' "$HANDOFF_PATH")" +[[ "$(jq -er .build_number "$HANDOFF_PATH")" == "$FIXTURE_BUILD" ]] || { + printf 'Flutter test ran against the wrong fixture build.\n' >&2 + exit 1 +} +[[ "$(jq -er .display_version "$HANDOFF_PATH")" == "$DISPLAY_VERSION" ]] || { + printf 'Flutter test ran against the wrong fixture display version.\n' >&2 + exit 1 +} +kill -0 "$ORIGINAL_PID" +original_command="$(ps -p "$ORIGINAL_PID" -o command=)" +[[ "$original_command" == "$APP_EXECUTABLE"* ]] || { + printf 'Flutter handoff PID %s is not the installed Lantern app: %s\n' \ + "$ORIGINAL_PID" "$original_command" >&2 + exit 1 +} +capture_processes prompt + +log_e2e "waiting for the native Sparkle prompt from process $ORIGINAL_PID" +osascript "$ROBOT_SCRIPT" wait-prompt "$ORIGINAL_PID" "$UI_TIMEOUT_SECONDS" \ + | tee "$ARTIFACT_DIR/sparkle-prompt.txt" +capture_screenshot prompt +osascript "$ROBOT_SCRIPT" install-until-exit "$ORIGINAL_PID" "$UPDATE_TIMEOUT_SECONDS" \ + 2>&1 | tee "$ARTIFACT_DIR/sparkle-install.txt" + +log_e2e "waiting for Sparkle to replace and relaunch Lantern" +if kill -0 "$ORIGINAL_PID" 2>/dev/null; then + printf 'Original Lantern process is still running after Sparkle completed.\n' >&2 + exit 1 +fi +RELAUNCHED_PID="$(wait_for_new_pid "$ORIGINAL_PID" "$UI_TIMEOUT_SECONDS")" +osascript "$ROBOT_SCRIPT" wait-main "$RELAUNCHED_PID" "$UI_TIMEOUT_SECONDS" \ + | tee "$ARTIFACT_DIR/main-window-after.txt" +verify_bundle updated "$TARGET_BUILD" "$DISPLAY_VERSION" +capture_versions updated +capture_processes after +capture_screenshot after + +RESULT="success" +log_e2e "auto-update smoke passed: build $FIXTURE_BUILD -> $TARGET_BUILD" diff --git a/.github/scripts/macos_sparkle_handoff.applescript b/.github/scripts/macos_sparkle_handoff.applescript new file mode 100644 index 0000000000..44109e167c --- /dev/null +++ b/.github/scripts/macos_sparkle_handoff.applescript @@ -0,0 +1,144 @@ +-- The Flutter integration test owns Lantern's UI. This helper only crosses +-- the native Sparkle boundary and confirms a window exists after relaunch. + +property installButtonNames : {"Install Update", "Install and Relaunch"} +property pollInterval : 0.5 + +on findButton(elementRef) + tell application "System Events" + try + if role of elementRef is "AXButton" then + set buttonName to name of elementRef as text + if installButtonNames contains buttonName and enabled of elementRef then return elementRef + end if + end try + try + set children to UI elements of elementRef + on error + set children to {} + end try + end tell + repeat with childRef in children + set matchRef to my findButton(childRef) + if matchRef is not missing value then return matchRef + end repeat + return missing value +end findButton + +on processForPID(targetPID) + tell application "System Events" + repeat with processRef in application processes + try + if (unix id of processRef as integer) is targetPID then return processRef + end try + end repeat + end tell + return missing value +end processForPID + +on findInstallButton(processRef) + tell application "System Events" + try + set processWindows to windows of processRef + on error + set processWindows to {} + end try + end tell + repeat with windowRef in processWindows + set matchRef to my findButton(windowRef) + if matchRef is not missing value then return matchRef + end repeat + return missing value +end findInstallButton + +on waitForInstallButton(targetPID, timeoutSeconds) + set deadline to (current date) + timeoutSeconds + set checkedAccessibility to false + repeat while (current date) is less than deadline + set processRef to my processForPID(targetPID) + if processRef is not missing value then + if not checkedAccessibility then + tell application "System Events" + try + count of UI elements of processRef + on error errorMessage number errorNumber + error "macOS Accessibility is unavailable (" & errorNumber & "): " & errorMessage + end try + end tell + set checkedAccessibility to true + end if + set buttonRef to my findInstallButton(processRef) + if buttonRef is not missing value then return buttonRef + end if + delay pollInterval + end repeat + error "Sparkle install prompt did not appear before timeout" +end waitForInstallButton + +on waitForMainWindow(targetPID, timeoutSeconds) + set deadline to (current date) + timeoutSeconds + repeat while (current date) is less than deadline + set processRef to my processForPID(targetPID) + if processRef is not missing value then + tell application "System Events" + try + repeat with windowRef in windows of processRef + if visible of windowRef and subrole of windowRef is "AXStandardWindow" then + set frontmost of processRef to true + return "main window ready" + end if + end repeat + end try + end tell + end if + delay pollInterval + end repeat + error "Lantern did not expose a main window before timeout" +end waitForMainWindow + +on installUntilExit(targetPID, timeoutSeconds) + set deadline to (current date) + timeoutSeconds + repeat while (current date) is less than deadline + set processRef to my processForPID(targetPID) + if processRef is missing value then return "original process exited" + set buttonRef to my findInstallButton(processRef) + if buttonRef is not missing value then + tell application "System Events" + set buttonName to name of buttonRef as text + perform action "AXPress" of buttonRef + end tell + log "[E2E] pressed Sparkle " & buttonName + end if + delay pollInterval + end repeat + error "Lantern did not exit after accepting the Sparkle update" +end installUntilExit + +on positiveInteger(valueText, fieldName) + try + set parsedValue to valueText as integer + on error + error fieldName & " must be an integer" + end try + if parsedValue < 1 then error fieldName & " must be positive" + return parsedValue +end positiveInteger + +on run argv + if (count of argv) is not 3 then error "action, PID, and timeout are required" + set actionName to item 1 of argv + set targetPID to my positiveInteger(item 2 of argv, "PID") + set timeoutSeconds to my positiveInteger(item 3 of argv, "timeout") + + if actionName is "wait-prompt" then + set buttonRef to my waitForInstallButton(targetPID, timeoutSeconds) + tell application "System Events" to return name of buttonRef as text + end if + if actionName is "install-until-exit" then + return my installUntilExit(targetPID, timeoutSeconds) + end if + if actionName is "wait-main" then + return my waitForMainWindow(targetPID, timeoutSeconds) + end if + error "unknown action: " & actionName +end run diff --git a/.github/scripts/windows_auto_update_smoke.ps1 b/.github/scripts/windows_auto_update_smoke.ps1 new file mode 100644 index 0000000000..b7885e2e08 --- /dev/null +++ b/.github/scripts/windows_auto_update_smoke.ps1 @@ -0,0 +1,351 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$AppDirectory = 'C:\Program Files\Lantern' +$AppExecutable = Join-Path $AppDirectory 'lantern.exe' +$DataDirectory = 'C:\ProgramData\Lantern' +$HandoffPath = Join-Path $DataDirectory 'E2E\auto-update-handoff.json' +$FixtureDirectory = $env:FIXTURE_APP_DIR +$TargetJson = $env:TARGET_JSON +$AppcastXml = $env:APPCAST_XML +$ArtifactDirectory = $env:ARTIFACT_DIR +$UiTimeout = if ($env:UI_TIMEOUT_SECONDS) { [int]$env:UI_TIMEOUT_SECONDS } else { 120 } +$UpdateTimeout = if ($env:UPDATE_TIMEOUT_SECONDS) { [int]$env:UPDATE_TIMEOUT_SECONDS } else { 600 } +$script:OriginalPid = 0 +$script:RelaunchedPid = 0 +$script:Result = 'failure' + +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes + +function Write-E2E([string]$Message) { + Write-Host "[E2E] $Message" +} + +function Assert-SmokeGuard { + if ($env:CI -ne 'true' -or $env:GITHUB_ACTIONS -ne 'true' -or + $env:LANTERN_AUTO_UPDATE_SMOKE -ne 'true') { + throw 'This destructive smoke requires its explicit GitHub Actions guard.' + } + if ($AppDirectory -ne 'C:\Program Files\Lantern' -or + $DataDirectory -ne 'C:\ProgramData\Lantern') { + throw 'Refusing to use unexpected Lantern paths.' + } + foreach ($path in @($TargetJson, $AppcastXml)) { + if ([string]::IsNullOrWhiteSpace($path) -or + -not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Required smoke input was not found: $path" + } + } + if ([string]::IsNullOrWhiteSpace($FixtureDirectory) -or + -not (Test-Path -LiteralPath $FixtureDirectory -PathType Container) -or + -not (Test-Path -LiteralPath (Join-Path $FixtureDirectory 'lantern.exe') -PathType Leaf)) { + throw "Signed fixture app was not found: $FixtureDirectory" + } + if ([string]::IsNullOrWhiteSpace($ArtifactDirectory) -or + $UiTimeout -lt 1 -or $UpdateTimeout -lt 1) { + throw 'Artifact directory and positive timeouts are required.' + } +} + +function Get-LanternProcesses { + @(Get-Process -Name lantern -ErrorAction SilentlyContinue | Where-Object { + try { $_.Path -eq $AppExecutable } catch { $false } + }) +} + +function Invoke-Checked { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [string[]]$Arguments = @(), + [Parameter(Mandatory = $true)][string]$Description, + [int]$TimeoutSeconds = 180 + ) + Write-E2E "${Description}: $FilePath $($Arguments -join ' ')" + $process = Start-Process $FilePath -ArgumentList $Arguments -PassThru + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw "$Description timed out after $TimeoutSeconds seconds" + } + if ($process.ExitCode -ne 0) { + throw "$Description failed with exit code $($process.ExitCode)" + } +} + +function Reset-Lantern { + Assert-SmokeGuard + Get-LanternProcesses | Stop-Process -Force -ErrorAction SilentlyContinue + $uninstaller = Get-ChildItem $AppDirectory -Filter 'unins*.exe' -File -ErrorAction SilentlyContinue | + Sort-Object Name | Select-Object -First 1 + if ($uninstaller) { + Invoke-Checked $uninstaller.FullName @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-') ` + 'Uninstalling existing Lantern' + } else { + $service = Join-Path $AppDirectory 'lanternd.exe' + if (Test-Path -LiteralPath $service) { + & $service uninstall 2>&1 | Out-Null + } + } + Remove-Item -LiteralPath $AppDirectory, $DataDirectory -Recurse -Force -ErrorAction SilentlyContinue +} + +function Install-Fixture { + Reset-Lantern + New-Item -ItemType Directory -Path $AppDirectory -Force | Out-Null + Copy-Item -Path (Join-Path $FixtureDirectory '*') -Destination $AppDirectory -Recurse -Force + Invoke-Checked (Join-Path $AppDirectory 'lanternd.exe') @('install') 'Registering fixture service' +} + +function Save-Screenshot([string]$Name) { + try { + $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) + $bitmap.Save((Join-Path $ArtifactDirectory "$Name.png")) + } finally { + $graphics.Dispose() + $bitmap.Dispose() + } + } catch { + $_ | Out-String | Set-Content (Join-Path $ArtifactDirectory "$Name-screenshot-error.txt") + } +} + +function Assert-AppVersion { + param( + [string]$Name, + [int]$ExpectedBuild, + [string]$ExpectedDisplay + ) + $info = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($AppExecutable) + $display = ($info.ProductVersion -split '\+', 2)[0] + $signature = Get-AuthenticodeSignature -LiteralPath $AppExecutable + $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } + @( + "display_version=$display" + "raw_product_version=$($info.ProductVersion)" + "build_number=$($info.FilePrivatePart)" + "authenticode_status=$($signature.Status)" + "authenticode_subject=$subject" + ) | Set-Content (Join-Path $ArtifactDirectory "versions-$Name.txt") + if ($display -ne $ExpectedDisplay -or $info.FilePrivatePart -ne $ExpectedBuild) { + throw "$Name version $display+$($info.FilePrivatePart) did not match $ExpectedDisplay+$ExpectedBuild" + } + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + throw "$Name Lantern executable Authenticode status is $($signature.Status)" + } +} + +function Get-Window([string[]]$NamePrefixes) { + $condition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window + ) + foreach ($window in [System.Windows.Automation.AutomationElement]::RootElement.FindAll( + [System.Windows.Automation.TreeScope]::Children, $condition)) { + $name = $window.Current.Name + foreach ($prefix in $NamePrefixes) { + if ($name -eq $prefix -or $name.StartsWith("$prefix ")) { return $window } + } + } + return $null +} + +function Get-Button($Window, [string[]]$Names) { + if ($null -eq $Window) { return $null } + $condition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Button + ) + foreach ($button in $Window.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)) { + if ($button.Current.IsEnabled -and $Names -contains $button.Current.Name.Replace('&', '')) { + return $button + } + } + return $null +} + +function Press-Button($Button) { + $pattern = $null + if (-not $Button.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$pattern)) { + throw "Button '$($Button.Current.Name)' cannot be invoked" + } + Write-E2E "pressing $($Button.Current.Name)" + ([System.Windows.Automation.InvokePattern]$pattern).Invoke() +} + +function Save-UiTree([string]$Name) { + $lines = foreach ($windowName in @('Software Update', 'Setup - Lantern', 'Lantern Setup')) { + $window = Get-Window @($windowName) + if ($window) { + "WINDOW name='$($window.Current.Name)' pid=$($window.Current.ProcessId)" + $condition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Button + ) + foreach ($button in $window.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)) { + " BUTTON name='$($button.Current.Name)' enabled=$($button.Current.IsEnabled)" + } + } + } + $lines | Set-Content (Join-Path $ArtifactDirectory "ui-$Name.txt") +} + +function Wait-ForUpdatePrompt { + $deadline = [DateTime]::UtcNow.AddSeconds($UiTimeout) + while ([DateTime]::UtcNow -lt $deadline) { + $button = Get-Button (Get-Window @('Software Update')) @('Install update', 'Get update') + if ($button) { return $button } + Start-Sleep -Milliseconds 250 + } + Save-UiTree 'prompt-timeout' + throw 'WinSparkle update prompt did not appear before timeout' +} + +function Get-DownloadedInstaller([string]$ExpectedName, [long]$ExpectedLength) { + @( + Get-ChildItem $env:TEMP -Filter 'Update-*' -Directory -ErrorAction SilentlyContinue | + ForEach-Object { + Get-ChildItem $_.FullName -Filter $ExpectedName -File -ErrorAction SilentlyContinue + } | + Where-Object Length -eq $ExpectedLength | + Sort-Object LastWriteTimeUtc -Descending + ) | Select-Object -First 1 +} + +function Install-Update( + [int]$OriginalPid, + [string]$ExpectedInstallerName, + [long]$ExpectedInstallerLength, + [int]$ExpectedBuild +) { + $deadline = [DateTime]::UtcNow.AddSeconds($UpdateTimeout) + $launchRequested = $false + $originalExited = $false + while ([DateTime]::UtcNow -lt $deadline) { + $installer = Get-Window @('Setup - Lantern', 'Lantern Setup') + $button = Get-Button $installer @('Next >', 'Next', 'Install', 'Finish', 'Yes') + if ($button) { + Press-Button $button + Start-Sleep -Milliseconds 500 + } elseif (-not $launchRequested) { + $download = Get-DownloadedInstaller $ExpectedInstallerName $ExpectedInstallerLength + if ($download) { + $button = Get-Button (Get-Window @('Software Update')) @('Install update', 'Run installer') + if ($button) { + Write-E2E "download complete; launching $($download.Name)" + Press-Button $button + $launchRequested = $true + Start-Sleep -Milliseconds 500 + } + } + } + if (-not $originalExited -and -not (Get-Process -Id $OriginalPid -ErrorAction SilentlyContinue)) { + $originalExited = $true + Write-E2E "original Lantern process $OriginalPid exited" + } + if ($originalExited -and (Test-Path -LiteralPath $AppExecutable)) { + try { + $installedBuild = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($AppExecutable).FilePrivatePart + if ($installedBuild -eq $ExpectedBuild) { + $relaunchCandidates = @(Get-LanternProcesses | Where-Object Id -ne $OriginalPid) + foreach ($relaunched in $relaunchCandidates) { + $relaunched.Refresh() + if ($relaunched.MainWindowHandle -ne 0) { return $relaunched } + } + } + } catch { + # The installer may be replacing the executable while this poll runs. + } + } + Start-Sleep -Milliseconds 250 + } + Save-UiTree 'install-timeout' + throw 'WinSparkle did not install and relaunch Lantern before timeout' +} + +function Save-Diagnostics { + @( + "result=$script:Result" + "original_pid=$script:OriginalPid" + "relaunched_pid=$script:RelaunchedPid" + ) | Set-Content (Join-Path $ArtifactDirectory 'result.txt') + Get-CimInstance Win32_Process | Where-Object Name -Match '^(lantern|lanternd|lantern-installer.*|Update|unins\d*)\.exe$' | + Select-Object ProcessId, ParentProcessId, Name, ExecutablePath, CommandLine | + Format-List | Out-String -Width 4096 | + Set-Content (Join-Path $ArtifactDirectory 'processes-final.txt') + Save-UiTree 'final' + $logs = Join-Path $DataDirectory 'Logs' + if (Test-Path -LiteralPath $logs) { + Copy-Item $logs (Join-Path $ArtifactDirectory 'lantern-logs') -Recurse -Force + } + Get-ChildItem $env:TEMP -Filter 'Update-*' -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Get-ChildItem $_.FullName -Recurse -ErrorAction SilentlyContinue } | + Select-Object FullName, Length, LastWriteTime | + Format-Table -AutoSize | Out-String -Width 4096 | + Set-Content (Join-Path $ArtifactDirectory 'winsparkle-files.txt') +} + +Assert-SmokeGuard +New-Item -ItemType Directory -Path $ArtifactDirectory -Force | Out-Null +Copy-Item $AppcastXml (Join-Path $ArtifactDirectory 'appcast.xml') +Copy-Item $TargetJson (Join-Path $ArtifactDirectory 'resolved-target.json') +$target = Get-Content $TargetJson -Raw | ConvertFrom-Json +$targetBuild = [int]$target.target_build +$fixtureBuild = [int]$target.fixture_build +$displayVersion = [string]$target.display_version +$targetUri = [Uri][string]$target.artifact_url +$targetInstallerName = [IO.Path]::GetFileName($targetUri.AbsolutePath) +$targetInstallerLength = [long]$target.artifact_length + +try { + Install-Fixture + Assert-AppVersion 'fixture' $fixtureBuild $displayVersion + Save-Screenshot 'before' + + Write-E2E 'running the Flutter auto-update robot against the installed fixture' + & flutter drive --profile "--use-application-binary=$AppExecutable" --keep-app-running ` + --driver=test_driver/integration_test.dart ` + --target=integration_test/auto_update/desktop_auto_update_smoke_test.dart ` + --device-id=windows *>&1 | + Tee-Object -FilePath (Join-Path $ArtifactDirectory 'flutter-drive.log') + if ($LASTEXITCODE -ne 0) { throw "Flutter auto-update robot failed with exit code $LASTEXITCODE" } + + if (-not (Test-Path -LiteralPath $HandoffPath -PathType Leaf)) { + throw 'Flutter test completed without creating its native handoff.' + } + $handoff = Get-Content $HandoffPath -Raw | ConvertFrom-Json + Copy-Item $HandoffPath (Join-Path $ArtifactDirectory 'auto-update-handoff.json') + $script:OriginalPid = [int]$handoff.pid + if ([string]$handoff.build_number -ne [string]$fixtureBuild -or + [string]$handoff.display_version -ne $displayVersion) { + throw 'Flutter test ran against the wrong fixture version.' + } + $original = Get-Process -Id $script:OriginalPid -ErrorAction Stop + if ($original.Path -ne $AppExecutable) { + throw "Flutter handoff PID $script:OriginalPid is not the installed Lantern app" + } + + $installButton = Wait-ForUpdatePrompt + Save-UiTree 'prompt' + Save-Screenshot 'prompt' + Press-Button $installButton + $relaunched = Install-Update ` + $script:OriginalPid ` + $targetInstallerName ` + $targetInstallerLength ` + $targetBuild + $script:RelaunchedPid = $relaunched.Id + Assert-AppVersion 'updated' $targetBuild $displayVersion + Save-Screenshot 'after' + $script:Result = 'success' + Write-E2E "auto-update smoke passed: build $fixtureBuild -> $targetBuild" +} finally { + try { Save-Diagnostics } catch { Write-Warning "Unable to save all diagnostics: $_" } + try { Save-Screenshot 'final' } catch { Write-Warning "Unable to save final screenshot: $_" } + try { Reset-Lantern } catch { Write-Warning "Unable to clean up Lantern fixture: $_" } +} diff --git a/.github/workflows/android-compile-check.yml b/.github/workflows/android-compile-check.yml index 3df3f4ee47..8ca81b7c56 100644 --- a/.github/workflows/android-compile-check.yml +++ b/.github/workflows/android-compile-check.yml @@ -98,7 +98,7 @@ jobs: make android-env >> "$GITHUB_ENV" - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version-file: .github/flutter-version.yaml diff --git a/.github/workflows/app-smoke-tests.yml b/.github/workflows/app-smoke-tests.yml index d973e9b802..40a59e8a0a 100644 --- a/.github/workflows/app-smoke-tests.yml +++ b/.github/workflows/app-smoke-tests.yml @@ -22,7 +22,8 @@ on: type: choice options: - vpn-smoke # connect/disconnect smoke only (validates the public IP changes) — the fast confidence check - - all # every suite the platform supports + - auto-update # desktop: install a lower signed fixture and update it from the isolated staging feed + - all # every standard suite the platform supports default: vpn-smoke linux_arch: description: "Linux arch to test" @@ -47,13 +48,24 @@ concurrency: cancel-in-progress: false jobs: + validate-inputs: + runs-on: ubuntu-latest + steps: + - name: Validate auto-update selection + if: ${{ inputs.tests == 'auto-update' && !contains(fromJSON('["all", "macos", "windows"]'), inputs.platforms) }} + shell: bash + run: | + echo '::error title=Invalid smoke selection::auto-update requires platforms=all, macos, or windows' + exit 2 + # Android builds its own versioned APKs and runs on Firebase Test Lab, so it # doesn't need the desktop version stamping from `prepare`. The vpn-smoke # tier builds only the VPN suite and runs it on one virtual device with no # retries; `all` builds the full aggregator on the script's two-device # matrix (physical Pixel 8 + Arm virtual) with retries for flaky tests. android: - if: ${{ contains(fromJSON('["all", "android", ""]'), inputs.platforms) }} + needs: validate-inputs + if: ${{ inputs.tests != 'auto-update' && contains(fromJSON('["all", "android", ""]'), inputs.platforms) }} uses: ./.github/workflows/firebase-test-lab.yml secrets: inherit with: @@ -68,6 +80,8 @@ jobs: flaky_attempts: ${{ (inputs.tests || 'all') == 'all' && '2' || '' }} prepare: + needs: validate-inputs + if: ${{ inputs.tests != 'auto-update' }} runs-on: ubuntu-latest outputs: version: ${{ steps.meta.outputs.version }} @@ -107,7 +121,7 @@ jobs: # to the optional suites: vpn-smoke leaves them off, `all` turns them on. linux: needs: prepare - if: ${{ contains(fromJSON('["all", "linux", ""]'), inputs.platforms) }} + if: ${{ inputs.tests != 'auto-update' && contains(fromJSON('["all", "linux", ""]'), inputs.platforms) }} uses: ./.github/workflows/build-linux.yml secrets: inherit with: @@ -123,7 +137,7 @@ jobs: # runs only when asked for explicitly. macos: needs: prepare - if: ${{ inputs.platforms == 'macos' }} + if: ${{ inputs.platforms == 'macos' && inputs.tests != 'auto-update' }} uses: ./.github/workflows/build-macos.yml secrets: inherit with: @@ -134,9 +148,18 @@ jobs: run_connect_smoke: true run_payment_checkout_smoke: ${{ (inputs.tests || 'all') == 'all' }} + # This is deliberately separate from the regular macOS build job: it + # replaces /Applications/Lantern.app and verifies Sparkle's relaunch. It is + # selected explicitly, and never runs in the nightly sweep. + macos-auto-update: + needs: validate-inputs + if: ${{ contains(fromJSON('["all", "macos"]'), inputs.platforms) && inputs.tests == 'auto-update' }} + uses: ./.github/workflows/macos-auto-update-smoke.yml + secrets: inherit + windows: needs: prepare - if: ${{ contains(fromJSON('["all", "windows", ""]'), inputs.platforms) }} + if: ${{ contains(fromJSON('["all", "windows", ""]'), inputs.platforms) && inputs.tests != 'auto-update' }} uses: ./.github/workflows/build-windows.yml secrets: inherit with: @@ -148,3 +171,9 @@ jobs: run_auth_smoke: ${{ (inputs.tests || 'all') == 'all' }} run_split_tunnel_website_smoke: ${{ (inputs.tests || 'all') == 'all' }} run_payment_checkout_smoke: ${{ (inputs.tests || 'all') == 'all' }} + + windows-auto-update: + needs: validate-inputs + if: ${{ contains(fromJSON('["all", "windows"]'), inputs.platforms) && inputs.tests == 'auto-update' }} + uses: ./.github/workflows/windows-auto-update-smoke.yml + secrets: inherit diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 924fbbcdf3..81ee50d383 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -91,7 +91,7 @@ jobs: make android-env >> "$GITHUB_ENV" - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version-file: .github/flutter-version.yaml diff --git a/.github/workflows/build-auto-update-fixtures.yml b/.github/workflows/build-auto-update-fixtures.yml new file mode 100644 index 0000000000..003a551067 --- /dev/null +++ b/.github/workflows/build-auto-update-fixtures.yml @@ -0,0 +1,179 @@ +name: Build auto-update fixture targets + +on: + workflow_dispatch: + inputs: + publish: + description: "Publish the signed targets as a fixture-repository prerelease" + required: false + type: boolean + default: false + workflow_call: + inputs: + publish: + description: "Publish the signed targets as a fixture-repository prerelease" + required: false + type: boolean + default: false + +permissions: + contents: read + id-token: write + +concurrency: + group: build-auto-update-fixtures + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + display_version: ${{ steps.fixture.outputs.display_version }} + release_tag: ${{ steps.fixture.outputs.release_tag }} + release_version: ${{ steps.fixture.outputs.release_version }} + target_build: ${{ steps.fixture.outputs.target_build }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Prepare target version + id: fixture + shell: bash + run: | + set -euo pipefail + DISPLAY_VERSION="$(sed -nE 's/^version:[[:space:]]*([^+[:space:]]+).*/\1/p' pubspec.yaml)" + TARGET_BUILD="$((1000 + GITHUB_RUN_NUMBER))" + RELEASE_VERSION="${DISPLAY_VERSION}-beta.e2e.${GITHUB_RUN_ID}" + RELEASE_TAG="v${RELEASE_VERSION}" + [[ "$TARGET_BUILD" =~ ^[0-9]+$ && "$TARGET_BUILD" -gt 1 && "$TARGET_BUILD" -le 65535 ]] + sed -i.bak -E "s/^version:.*/version: ${DISPLAY_VERSION}+${TARGET_BUILD}/" pubspec.yaml + rm pubspec.yaml.bak + { + echo "display_version=$DISPLAY_VERSION" + echo "release_tag=$RELEASE_TAG" + echo "release_version=$RELEASE_VERSION" + echo "target_build=$TARGET_BUILD" + } >> "$GITHUB_OUTPUT" + + - name: Upload target pubspec + uses: actions/upload-artifact@v4 + with: + name: auto-update-target-pubspec + path: pubspec.yaml + retention-days: 2 + + build-macos: + needs: prepare + permissions: + contents: read + id-token: write + uses: ./.github/workflows/build-macos.yml + secrets: inherit + with: + version: ${{ needs.prepare.outputs.display_version }} + build_type: beta + installer_base_name: lantern-installer + pubspec_artifact: auto-update-target-pubspec + auto_update_e2e: true + sign_update_artifact: true + run_connect_smoke: false + run_payment_checkout_smoke: false + + build-windows: + needs: prepare + permissions: + contents: read + id-token: write + uses: ./.github/workflows/build-windows.yml + secrets: inherit + with: + version: ${{ needs.prepare.outputs.display_version }} + build_type: beta + installer_base_name: lantern-installer + pubspec_artifact: auto-update-target-pubspec + auto_update_e2e: true + sign_update_artifact: true + run_installer_smoke: false + run_connect_smoke: false + run_split_tunnel_website_smoke: false + run_config_url_smoke: false + run_auth_smoke: false + run_payment_checkout_smoke: false + + assemble: + needs: + - prepare + - build-macos + - build-windows + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Download signed installers + uses: actions/download-artifact@v4 + with: + pattern: lantern-installer-* + path: fixture-downloads + + - name: Assemble and verify fixture release + shell: bash + env: + ASSET_BASE_URL: https://github.com/getlantern/lantern-update-fixtures/releases/download/${{ needs.prepare.outputs.release_tag }} + RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} + TARGET_BUILD: ${{ needs.prepare.outputs.target_build }} + run: | + set -euo pipefail + mkdir -p fixture-release/artifacts fixture-release/metadata fixture-release/signatures + find fixture-downloads -type f -name 'lantern-installer-beta.dmg' -exec cp {} fixture-release/artifacts/ \; + find fixture-downloads -type f -name 'lantern-installer-beta.exe' -exec cp {} fixture-release/artifacts/ \; + find fixture-downloads -type f -name '*.sparkle-signature' -exec cp {} fixture-release/signatures/ \; + test -f fixture-release/artifacts/lantern-installer-beta.dmg + test -f fixture-release/artifacts/lantern-installer-beta.exe + test -f fixture-release/signatures/lantern-installer-beta.dmg.sparkle-signature + test -f fixture-release/signatures/lantern-installer-beta.exe.sparkle-signature + python scripts/ci/generate_update_metadata.py \ + --build-type beta \ + --version "$RELEASE_VERSION" \ + --sparkle-version "$TARGET_BUILD" \ + --bucket unused-fixture-bucket \ + --asset-base-url "$ASSET_BASE_URL" \ + --output-dir fixture-release/metadata \ + --sparkle-signature-dir fixture-release/signatures \ + fixture-release/artifacts/* + python scripts/ci/verify_fixture_update_artifacts.py \ + --artifact-dir fixture-release/artifacts \ + --metadata-dir fixture-release/metadata \ + --asset-base-url "$ASSET_BASE_URL" + cp fixture-release/metadata/* fixture-release/artifacts/ + + - name: Upload verified fixture release + uses: actions/upload-artifact@v4 + with: + name: auto-update-fixture-release + path: fixture-release/artifacts + if-no-files-found: error + retention-days: 7 + + - name: Publish fixture prerelease + if: ${{ inputs.publish }} + shell: bash + env: + GH_TOKEN: ${{ secrets.UPDATE_FIXTURES_GH_TOKEN }} + RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + run: | + set -euo pipefail + [[ -n "$GH_TOKEN" ]] || { + echo '::error title=Missing fixture token::Set UPDATE_FIXTURES_GH_TOKEN before publishing.' + exit 1 + } + gh release create "$RELEASE_TAG" \ + --repo getlantern/lantern-update-fixtures \ + --prerelease \ + --title "Lantern auto-update fixture $RELEASE_TAG" \ + --notes "Synthetic signed update target for Lantern E2E tests. Safe to delete after validation." \ + fixture-release/artifacts/* diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index cba75e81b3..4d43b15393 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -40,7 +40,7 @@ jobs: cache: true - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version-file: .github/flutter-version.yaml diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index c9edf0c3cb..936306f684 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -138,7 +138,7 @@ jobs: echo "FLUTTER_VERSION=$FLUTTER_VERSION" >> "$GITHUB_ENV" - name: Install Flutter (amd64) - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 if: ${{ matrix.arch == 'amd64' }} with: channel: stable diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 4fb3b2ef09..a062fa77f5 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -34,6 +34,31 @@ on: required: false type: string default: macos-15 + flutter_target: + description: "Optional Flutter entrypoint used to build the app" + required: false + type: string + default: "" + flutter_build_mode: + description: "Flutter build mode: release or profile" + required: false + type: string + default: release + pubspec_artifact: + description: "Artifact containing the version-stamped pubspec.yaml" + required: false + type: string + default: pubspec + sign_update_artifact: + description: "Generate the Sparkle signature published with this DMG" + required: false + type: boolean + default: true + auto_update_e2e: + description: "Build a fixture that reads the isolated staging appcast" + required: false + type: boolean + default: false run_connect_smoke: description: "Run the macOS connect/disconnect smoke after building" required: false @@ -79,7 +104,7 @@ jobs: - name: Download pubspec.yaml uses: actions/download-artifact@v4 with: - name: pubspec + name: ${{ inputs.pubspec_artifact }} - name: Set up Go uses: actions/setup-go@v5 @@ -110,7 +135,7 @@ jobs: ${{ runner.os }}-macos-pods- - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version-file: .github/flutter-version.yaml @@ -230,10 +255,22 @@ jobs: echo "::notice title=macOS payment smoke diagnostics::$ARTIFACT_URL" echo "[Download the macOS payment smoke screenshot and diagnostics]($ARTIFACT_URL)" >> "$GITHUB_STEP_SUMMARY" - - name: Build macOS release - run: make macos-release-ci + - name: Build macOS app + shell: bash + run: | + case "$FLUTTER_BUILD_MODE" in + release) make macos-release-ci ;; + profile) make macos-profile-ci ;; + *) + printf 'Unsupported macOS Flutter build mode: %s\n' "$FLUTTER_BUILD_MODE" >&2 + exit 2 + ;; + esac env: BUILD_TYPE: ${{ inputs.build_type }} + AUTO_UPDATE_E2E: ${{ inputs.auto_update_e2e }} + FLUTTER_BUILD_MODE: ${{ inputs.flutter_build_mode }} + FLUTTER_TARGET: ${{ inputs.flutter_target }} VERSION: ${{ inputs.version }} INSTALLER_NAME: ${{ inputs.installer_base_name }} GOMOBILECACHE: ${{ env.GOMOBILECACHE }} @@ -259,7 +296,7 @@ jobs: ditto -c -k --keepParent "$APP_PATH" "$RUNNER_TEMP/Lantern.app.zip" - name: Sign macOS update - if: ${{ inputs.build_type != 'nightly' }} + if: ${{ inputs.build_type != 'nightly' && inputs.sign_update_artifact }} shell: bash env: SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} @@ -284,7 +321,7 @@ jobs: retention-days: 2 - name: Upload macOS update signature - if: ${{ inputs.build_type != 'nightly' }} + if: ${{ inputs.build_type != 'nightly' && inputs.sign_update_artifact }} uses: actions/upload-artifact@v4 with: name: lantern-installer-dmg-signature diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 9e52addd5c..0d1a4bcbd3 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -21,6 +21,41 @@ on: installer_base_name: required: true type: string + flutter_target: + description: "Optional Flutter entrypoint used to build the app" + required: false + type: string + default: "" + flutter_build_mode: + description: "Flutter build mode: release or profile" + required: false + type: string + default: release + pubspec_artifact: + description: "Artifact containing the version-stamped pubspec.yaml" + required: false + type: string + default: pubspec + sign_update_artifact: + description: "Generate the Sparkle signature published with this installer" + required: false + type: boolean + default: true + auto_update_e2e: + description: "Build a fixture that reads the isolated staging appcast" + required: false + type: boolean + default: false + package_installer: + description: "Package and upload the Windows installer" + required: false + type: boolean + default: true + run_installer_smoke: + description: "Install the packaged app and run the Windows smoke suite" + required: false + type: boolean + default: true skip_signing: description: "Skip code signing (e.g. for nightly builds)" required: false @@ -79,7 +114,7 @@ jobs: - name: Download pubspec.yaml uses: actions/download-artifact@v4 with: - name: pubspec + name: ${{ inputs.pubspec_artifact }} - name: Set up Go uses: actions/setup-go@v5 @@ -107,11 +142,21 @@ jobs: - name: Set up MinGW run: choco install mingw -y + - name: Read pinned Flutter version + id: flutter-version + shell: pwsh + run: | + $match = Select-String -Path '.github/flutter-version.yaml' -Pattern '^\s*flutter:\s*["'']?([^"'']+)["'']?\s*$' + if (-not $match) { + throw 'Unable to read the pinned Flutter version' + } + "version=$($match.Matches[0].Groups[1].Value.Trim())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable - flutter-version-file: .github/flutter-version.yaml + flutter-version: ${{ steps.flutter-version.outputs.version }} - name: Enable Flutter Desktop Support run: | @@ -124,15 +169,8 @@ jobs: fileDir: ${{ github.workspace }} encodedString: ${{ secrets.APP_ENV }} - - name: Cache Dart pub global cache - uses: actions/cache@v4 - with: - path: ~/.pub-cache - key: ${{ runner.os }}-dart-pub-cache-${{ hashFiles('**/pubspec.lock') }} - restore-keys: | - ${{ runner.os }}-dart-pub-cache- - - name: Install Inno Setup 6 + if: ${{ inputs.package_installer }} shell: pwsh # The runner image preinstalls Inno Setup (currently 6.7.1). A bare # --version=6.5.0 now fails ("a newer version is already installed; use @@ -143,6 +181,7 @@ jobs: run: choco install -y innosetup --version=6.7.1 --allow-downgrade - name: Install unofficial Inno Setup translations + if: ${{ inputs.package_installer }} shell: pwsh # Chinese Simplified and Farsi/Persian are not bundled Inno translations; # copy the vendored .isl files into Inno's Languages dir so inno_setup.iss @@ -171,11 +210,21 @@ jobs: - name: Build Windows binaries shell: pwsh + env: + AUTO_UPDATE_E2E: ${{ inputs.auto_update_e2e }} + FLUTTER_BUILD_MODE: ${{ inputs.flutter_build_mode }} + FLUTTER_TARGET: ${{ inputs.flutter_target }} run: | - dart pub global activate fastforge - make windows-release + switch ($env:FLUTTER_BUILD_MODE) { + 'release' { make windows-release } + 'profile' { make windows-profile-ci } + default { + Write-Error "Unsupported Windows Flutter build mode: $env:FLUTTER_BUILD_MODE" + exit 2 + } + } if ($LASTEXITCODE -ne 0) { - Write-Error "make windows-release failed with exit code $LASTEXITCODE" + Write-Error "Windows build failed with exit code $LASTEXITCODE" exit $LASTEXITCODE } @@ -285,11 +334,22 @@ jobs: run: | Write-Host "Skipping embedded and installer signing for this run." + - name: Upload signed Windows app fixture + if: ${{ !inputs.package_installer }} + uses: actions/upload-artifact@v4 + with: + name: lantern-windows-app-fixture + path: build/windows/x64/runner/Release + if-no-files-found: error + retention-days: 2 + - name: Package installer + if: ${{ inputs.package_installer }} shell: pwsh env: FULL_INSTALLER_NAME: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }} run: | + dart pub global activate fastforge fastforge package ` --platform=windows ` --targets=exe ` @@ -306,6 +366,7 @@ jobs: Move-Item "dist/$env:APP_VERSION/$env:APP_NAME-$env:APP_VERSION-windows-setup.exe" "$env:FULL_INSTALLER_NAME.exe" - name: Windows installer smoke suite + if: ${{ inputs.package_installer && inputs.run_installer_smoke }} shell: pwsh timeout-minutes: 30 env: @@ -339,7 +400,7 @@ jobs: flutter test integration_test/auth/auth_smoke_test.dart -d windows --reporter=expanded --dart-define=DISABLE_SYSTEM_TRAY=true - name: Sign installer - if: ${{ !inputs.skip_signing }} + if: ${{ inputs.package_installer && !inputs.skip_signing }} shell: pwsh env: SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} @@ -354,7 +415,7 @@ jobs: -Description "Installer - GitHub Actions build ${{ inputs.version }}" - name: Sign Windows update - if: ${{ inputs.build_type != 'nightly' }} + if: ${{ inputs.package_installer && inputs.build_type != 'nightly' && inputs.sign_update_artifact }} shell: pwsh env: SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} @@ -365,6 +426,7 @@ jobs: -SignaturePath "$env:RUNNER_TEMP\$env:FULL_INSTALLER_NAME.exe.sparkle-signature" - name: Upload Windows installer + if: ${{ inputs.package_installer }} uses: actions/upload-artifact@v4 with: name: lantern-installer-exe @@ -372,7 +434,7 @@ jobs: retention-days: 2 - name: Upload Windows update signature - if: ${{ inputs.build_type != 'nightly' }} + if: ${{ inputs.package_installer && inputs.build_type != 'nightly' && inputs.sign_update_artifact }} uses: actions/upload-artifact@v4 with: name: lantern-installer-exe-signature diff --git a/.github/workflows/firebase-test-lab.yml b/.github/workflows/firebase-test-lab.yml index 898d431c64..a1d2b48b31 100644 --- a/.github/workflows/firebase-test-lab.yml +++ b/.github/workflows/firebase-test-lab.yml @@ -107,7 +107,7 @@ jobs: make android-env >> "$GITHUB_ENV" - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version-file: .github/flutter-version.yaml diff --git a/.github/workflows/flutter-test.yml b/.github/workflows/flutter-test.yml index c3dba788e2..4bc0e12c5b 100644 --- a/.github/workflows/flutter-test.yml +++ b/.github/workflows/flutter-test.yml @@ -51,7 +51,7 @@ jobs: ${{ runner.os }}-flutter- - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version-file: .github/flutter-version.yaml diff --git a/.github/workflows/macos-auto-update-smoke.yml b/.github/workflows/macos-auto-update-smoke.yml new file mode 100644 index 0000000000..b388f369e0 --- /dev/null +++ b/.github/workflows/macos-auto-update-smoke.yml @@ -0,0 +1,185 @@ +name: macOS auto-update smoke + +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +concurrency: + group: macos-auto-update-smoke + cancel-in-progress: false + +jobs: + resolve-target: + name: Resolve staging fixture target + runs-on: ubuntu-latest + outputs: + display_version: ${{ steps.target.outputs.display_version }} + fixture_build: ${{ steps.target.outputs.fixture_build }} + target_build: ${{ steps.target.outputs.target_build }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: scripts/requirements.txt + + - name: Install appcast parser dependency + run: python -m pip install -r scripts/requirements.txt + + - name: Test target resolver + run: python -m unittest scripts/ci/resolve_desktop_update_target_test.py + + - name: Resolve staging fixture target + id: target + shell: bash + env: + TARGET_DIR: ${{ runner.temp }}/macos-auto-update-target + run: | + set -o pipefail + mkdir -p "$TARGET_DIR/pubspec" + python scripts/ci/resolve_desktop_update_target.py \ + --platform macos \ + --appcast-url "https://update.staging.iantem.io/update/lantern/appcast.xml?channel=beta" \ + --appcast-output "$TARGET_DIR/appcast.xml" \ + --target-output "$TARGET_DIR/target.json" \ + --pubspec-input pubspec.yaml \ + --pubspec-output "$TARGET_DIR/pubspec/pubspec.yaml" \ + --github-output "$GITHUB_OUTPUT" \ + 2>&1 | tee "$TARGET_DIR/resolve.log" + + - name: Summarize target + if: ${{ steps.target.outcome == 'success' }} + shell: bash + env: + DISPLAY_VERSION: ${{ steps.target.outputs.display_version }} + FIXTURE_BUILD: ${{ steps.target.outputs.fixture_build }} + TARGET_BUILD: ${{ steps.target.outputs.target_build }} + run: | + { + echo "### macOS auto-update target" + echo + echo "- Live beta: \`$DISPLAY_VERSION\` build \`$TARGET_BUILD\`" + echo "- Unpublished fixture: \`$DISPLAY_VERSION\` build \`$FIXTURE_BUILD\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload target diagnostics + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: macos-auto-update-target + path: | + ${{ runner.temp }}/macos-auto-update-target/appcast.xml + ${{ runner.temp }}/macos-auto-update-target/target.json + ${{ runner.temp }}/macos-auto-update-target/resolve.log + if-no-files-found: warn + retention-days: 7 + + - name: Upload lower-build pubspec + if: ${{ steps.target.outcome == 'success' }} + uses: actions/upload-artifact@v4 + with: + name: macos-auto-update-pubspec + path: ${{ runner.temp }}/macos-auto-update-target/pubspec/pubspec.yaml + if-no-files-found: error + retention-days: 2 + + build-fixture: + name: Build signed lower fixture + needs: resolve-target + permissions: + contents: read + id-token: write + uses: ./.github/workflows/build-macos.yml + secrets: inherit + with: + version: ${{ needs.resolve-target.outputs.display_version }} + build_type: beta + installer_base_name: lantern-autoupdate-fixture + runner_label: lantern-macos-smoke + flutter_target: integration_test/auto_update/desktop_auto_update_smoke_test.dart + flutter_build_mode: profile + pubspec_artifact: macos-auto-update-pubspec + sign_update_artifact: false + auto_update_e2e: true + run_connect_smoke: false + run_payment_checkout_smoke: false + + exercise-update: + name: Install and update through Sparkle + needs: + - resolve-target + - build-fixture + runs-on: + - self-hosted + - lantern-macos-smoke + timeout-minutes: 30 + env: + LANTERN_AUTO_UPDATE_SMOKE: "true" + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Download fixture pubspec + uses: actions/download-artifact@v4 + with: + name: macos-auto-update-pubspec + + - name: Install Flutter + uses: subosito/flutter-action@v2.23.0 + with: + channel: stable + flutter-version-file: .github/flutter-version.yaml + + - name: Resolve Flutter dependencies + run: flutter pub get + + - name: Download lower fixture + uses: actions/download-artifact@v4 + with: + name: lantern-installer-dmg + path: ${{ runner.temp }}/macos-auto-update-fixture + + - name: Download resolved target + uses: actions/download-artifact@v4 + with: + name: macos-auto-update-target + path: ${{ runner.temp }}/macos-auto-update-target + + - name: Run Flutter-led auto-update smoke + shell: bash + env: + APPCAST_XML: ${{ runner.temp }}/macos-auto-update-target/appcast.xml + ARTIFACT_DIR: ${{ runner.temp }}/macos-auto-update-smoke + FIXTURE_DMG: ${{ runner.temp }}/macos-auto-update-fixture/lantern-autoupdate-fixture-beta.dmg + TARGET_JSON: ${{ runner.temp }}/macos-auto-update-target/target.json + run: bash ./.github/scripts/macos_auto_update_smoke.sh + + - name: Upload auto-update diagnostics + if: ${{ always() }} + id: diagnostics + uses: actions/upload-artifact@v4 + with: + name: macos-auto-update-smoke + path: ${{ runner.temp }}/macos-auto-update-smoke + if-no-files-found: warn + retention-days: 7 + + - name: Link diagnostics + if: ${{ always() && steps.diagnostics.outputs.artifact-url != '' }} + shell: bash + env: + ARTIFACT_URL: ${{ steps.diagnostics.outputs.artifact-url }} + run: | + echo "::notice title=macOS auto-update diagnostics::$ARTIFACT_URL" + echo "[Download macOS auto-update diagnostics]($ARTIFACT_URL)" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/windows-auto-update-smoke.yml b/.github/workflows/windows-auto-update-smoke.yml new file mode 100644 index 0000000000..d7cad08952 --- /dev/null +++ b/.github/workflows/windows-auto-update-smoke.yml @@ -0,0 +1,200 @@ +name: Windows auto-update smoke + +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +concurrency: + group: windows-auto-update-smoke + cancel-in-progress: false + +jobs: + resolve-target: + name: Resolve staging fixture target + runs-on: ubuntu-latest + outputs: + display_version: ${{ steps.target.outputs.display_version }} + fixture_build: ${{ steps.target.outputs.fixture_build }} + target_build: ${{ steps.target.outputs.target_build }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: scripts/requirements.txt + + - name: Install appcast parser dependency + run: python -m pip install -r scripts/requirements.txt + + - name: Test target resolver + run: python -m unittest scripts/ci/resolve_desktop_update_target_test.py + + - name: Resolve staging fixture target + id: target + shell: bash + env: + TARGET_DIR: ${{ runner.temp }}/windows-auto-update-target + run: | + set -o pipefail + mkdir -p "$TARGET_DIR/pubspec" + python scripts/ci/resolve_desktop_update_target.py \ + --platform windows \ + --appcast-url "https://update.staging.iantem.io/update/lantern/appcast.xml?channel=beta" \ + --appcast-output "$TARGET_DIR/appcast.xml" \ + --target-output "$TARGET_DIR/target.json" \ + --pubspec-input pubspec.yaml \ + --pubspec-output "$TARGET_DIR/pubspec/pubspec.yaml" \ + --github-output "$GITHUB_OUTPUT" \ + 2>&1 | tee "$TARGET_DIR/resolve.log" + + - name: Summarize target + if: ${{ steps.target.outcome == 'success' }} + shell: bash + env: + DISPLAY_VERSION: ${{ steps.target.outputs.display_version }} + FIXTURE_BUILD: ${{ steps.target.outputs.fixture_build }} + TARGET_BUILD: ${{ steps.target.outputs.target_build }} + run: | + { + echo "### Windows auto-update target" + echo + echo "- Live beta: \`$DISPLAY_VERSION\` build \`$TARGET_BUILD\`" + echo "- Unpublished fixture: \`$DISPLAY_VERSION\` build \`$FIXTURE_BUILD\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload target diagnostics + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: windows-auto-update-target + path: | + ${{ runner.temp }}/windows-auto-update-target/appcast.xml + ${{ runner.temp }}/windows-auto-update-target/target.json + ${{ runner.temp }}/windows-auto-update-target/resolve.log + if-no-files-found: warn + retention-days: 7 + + - name: Upload lower-build pubspec + if: ${{ steps.target.outcome == 'success' }} + uses: actions/upload-artifact@v4 + with: + name: windows-auto-update-pubspec + path: ${{ runner.temp }}/windows-auto-update-target/pubspec/pubspec.yaml + if-no-files-found: error + retention-days: 2 + + build-fixture: + name: Build signed lower fixture + needs: resolve-target + permissions: + contents: read + id-token: write + uses: ./.github/workflows/build-windows.yml + secrets: inherit + with: + version: ${{ needs.resolve-target.outputs.display_version }} + build_type: beta + installer_base_name: lantern-autoupdate-fixture + flutter_target: integration_test/auto_update/desktop_auto_update_smoke_test.dart + flutter_build_mode: profile + pubspec_artifact: windows-auto-update-pubspec + sign_update_artifact: false + auto_update_e2e: true + # Exercise WinSparkle's target installer without rebuilding the signed + # profile fixture through Fastforge's installer packaging step. + package_installer: false + run_installer_smoke: false + run_connect_smoke: false + run_split_tunnel_website_smoke: false + run_config_url_smoke: false + run_auth_smoke: false + run_payment_checkout_smoke: false + + exercise-update: + name: Install and update through WinSparkle + needs: + - resolve-target + - build-fixture + runs-on: windows-latest + timeout-minutes: 30 + env: + LANTERN_AUTO_UPDATE_SMOKE: "true" + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Download fixture pubspec + uses: actions/download-artifact@v4 + with: + name: windows-auto-update-pubspec + + - name: Read pinned Flutter version + id: flutter-version + shell: pwsh + run: | + $match = Select-String -Path '.github/flutter-version.yaml' -Pattern '^\s*flutter:\s*["'']?([^"'']+)["'']?\s*$' + if (-not $match) { + throw 'Unable to read the pinned Flutter version' + } + "version=$($match.Matches[0].Groups[1].Value.Trim())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Install Flutter + uses: subosito/flutter-action@v2.23.0 + with: + channel: stable + flutter-version: ${{ steps.flutter-version.outputs.version }} + + - name: Resolve Flutter dependencies + run: flutter pub get + + - name: Download signed lower fixture + uses: actions/download-artifact@v4 + with: + name: lantern-windows-app-fixture + path: ${{ runner.temp }}/windows-auto-update-fixture + + - name: Download resolved target + uses: actions/download-artifact@v4 + with: + name: windows-auto-update-target + path: ${{ runner.temp }}/windows-auto-update-target + + - name: Run Flutter-led auto-update smoke + shell: pwsh + env: + APPCAST_XML: ${{ runner.temp }}/windows-auto-update-target/appcast.xml + ARTIFACT_DIR: ${{ runner.temp }}/windows-auto-update-smoke + FIXTURE_APP_DIR: ${{ runner.temp }}/windows-auto-update-fixture + TARGET_JSON: ${{ runner.temp }}/windows-auto-update-target/target.json + run: ./.github/scripts/windows_auto_update_smoke.ps1 + + - name: Upload auto-update diagnostics + if: ${{ always() }} + id: diagnostics + uses: actions/upload-artifact@v4 + with: + name: windows-auto-update-smoke + path: ${{ runner.temp }}/windows-auto-update-smoke + if-no-files-found: warn + retention-days: 7 + + - name: Link diagnostics + if: ${{ always() && steps.diagnostics.outputs.artifact-url != '' }} + shell: pwsh + env: + ARTIFACT_URL: ${{ steps.diagnostics.outputs.artifact-url }} + run: | + Write-Host "::notice title=Windows auto-update diagnostics::$env:ARTIFACT_URL" + "[Download Windows auto-update diagnostics]($env:ARTIFACT_URL)" | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/Makefile b/Makefile index 5dc47cb626..a68e444dbe 100644 --- a/Makefile +++ b/Makefile @@ -102,6 +102,8 @@ DARWIN_LIB_BUILD := $(BIN_DIR)/macos/$(DARWIN_LIB) DARWIN_RELEASE_DIR := $(BUILD_DIR)/macos/Build/Products/Release DARWIN_DEBUG_BUILD := $(BUILD_DIR)/macos/Build/Products/Debug/$(DARWIN_APP_NAME) DARWIN_RELEASE_BUILD := $(DARWIN_RELEASE_DIR)/$(DARWIN_APP_NAME) +DARWIN_PROFILE_DIR := $(BUILD_DIR)/macos/Build/Products/Profile +DARWIN_PROFILE_BUILD := $(DARWIN_PROFILE_DIR)/$(DARWIN_APP_NAME) MACOS_ENTITLEMENTS := macos/Runner/Release.entitlements MACOS_INSTALLER := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE)).dmg MACOS_DIR := macos/ @@ -156,7 +158,10 @@ WINDOWS_LIB_AMD64 := $(BIN_DIR)/windows-amd64/$(WINDOWS_LIB) WINDOWS_LIB_ARM64 := $(BIN_DIR)/windows-arm64/$(WINDOWS_LIB) WINDOWS_LIB_BUILD := $(BIN_DIR)/windows/$(WINDOWS_LIB) WINDOWS_DEBUG_DIR := $(BUILD_DIR)/windows/x64/runner/Debug +WINDOWS_PROFILE_DIR := $(BUILD_DIR)/windows/x64/runner/Profile WINDOWS_RELEASE_DIR := $(BUILD_DIR)/windows/x64/runner/Release +LANTERND_WINDOWS_PROFILE := $(WINDOWS_PROFILE_DIR)/$(LANTERND).exe +LANTERND_WINDOWS_PROFILE_ARM64 := $(WINDOWS_PROFILE_DIR)/arm64/$(LANTERND).exe LANTERND_WINDOWS_RELEASE := $(WINDOWS_RELEASE_DIR)/$(LANTERND).exe LANTERND_WINDOWS_RELEASE_ARM64 := $(WINDOWS_RELEASE_DIR)/arm64/$(LANTERND).exe @@ -268,7 +273,9 @@ SIGN_ID="Developer ID Application: Brave New Software Project, Inc (ACZRKC3LQ9)" get-command = $(shell which="$$(which $(1) 2> /dev/null)" && if [[ ! -z "$$which" ]]; then printf %q "$$which"; fi) APPDMG := $(call get-command,appdmg) -DART_DEFINES := --dart-define=BUILD_TYPE=$(BUILD_TYPE) $(if $(VERSION),--dart-define=VERSION=$(VERSION),) +AUTO_UPDATE_E2E_DART_DEFINE := $(if $(filter true 1 yes,$(AUTO_UPDATE_E2E)),--dart-define=AUTO_UPDATE_E2E=true,) +DART_DEFINES := --dart-define=BUILD_TYPE=$(BUILD_TYPE) $(if $(VERSION),--dart-define=VERSION=$(VERSION),) $(AUTO_UPDATE_E2E_DART_DEFINE) +FLUTTER_TARGET_ARG := $(if $(FLUTTER_TARGET),--target=$(FLUTTER_TARGET),) STEALTH_NOVPN_BUILD_VARS := BUILD_TYPE=stealth-novpn STEALTH_MODE=stealth-novpn STEALTH_LEAKAGE_MODE=stealth-novpn STEALTH_VPN_BUILD_VARS := BUILD_TYPE=stealth-vpn STEALTH_MODE=stealth-vpn STEALTH_LEAKAGE_MODE=stealth-vpn STEALTH_ICON_SEED ?= @@ -522,10 +529,26 @@ macos-unit-tests: $(MACOS_FRAMEWORK_OUTPUT) $(MAYBE_STEALTH_PROFILE) $(DARWIN_RELEASE_BUILD): $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (release) for macOS..." rm -vf $(MACOS_INSTALLER) - flutter build macos --release $(DART_DEFINES) $(STEALTH_DART_DEFINES) + flutter build macos --release $(FLUTTER_TARGET_ARG) $(DART_DEFINES) $(STEALTH_DART_DEFINES) build-macos-release: $(DARWIN_RELEASE_BUILD) +$(DARWIN_PROFILE_BUILD): $(MAYBE_STEALTH_PROFILE) + @echo "Building Flutter app (profile) for macOS..." + rm -vf $(MACOS_INSTALLER) + flutter build macos --profile $(FLUTTER_TARGET_ARG) $(DART_DEFINES) $(STEALTH_DART_DEFINES) + +.PHONY: build-macos-profile stage-macos-profile +build-macos-profile: $(DARWIN_PROFILE_BUILD) + +# Keep packaging and signing on the established Release staging path. The +# profile fixture is test-only, but it still goes through the same Developer ID +# signing, DMG packaging, and notarization steps as a release artifact. +stage-macos-profile: build-macos-profile + rm -rf $(DARWIN_RELEASE_BUILD) + mkdir -p $(DARWIN_RELEASE_DIR) + ditto $(DARWIN_PROFILE_BUILD) $(DARWIN_RELEASE_BUILD) + .PHONY: notarize-darwin notarize-darwin: require-ac-username require-ac-password @echo "Notarizing distribution package..." @@ -573,6 +596,9 @@ macos-release: clean macos pubget gen build-macos-release sign-app package-macos .PHONY: macos-release-ci macos-release-ci: macos pubget gen build-macos-release sign-app package-macos notarize-darwin +.PHONY: macos-profile-ci +macos-profile-ci: macos pubget gen stage-macos-profile sign-app package-macos notarize-darwin + # Linux Build .PHONY: install-linux-deps @@ -738,12 +764,26 @@ copy-lanternd-debug: $(LANTERND_WINDOWS_AMD64) $(call MKDIR_P,$(WINDOWS_DEBUG_DIR)) $(call COPY_FILE,$(LANTERND_WINDOWS_AMD64),$(WINDOWS_DEBUG_DIR)/$(LANTERND).exe) +copy-lanternd-profile: $(LANTERND_WINDOWS_AMD64) + $(call MKDIR_P,$(WINDOWS_PROFILE_DIR)) + $(call COPY_FILE,$(LANTERND_WINDOWS_AMD64),$(LANTERND_WINDOWS_PROFILE)) + +copy-lanternd-profile-arm64: $(LANTERND_WINDOWS_ARM64) + $(call MKDIR_P,$(dir $(LANTERND_WINDOWS_PROFILE_ARM64))) + $(call COPY_FILE,$(LANTERND_WINDOWS_ARM64),$(LANTERND_WINDOWS_PROFILE_ARM64)) + .PHONY: prepare-windows-release prepare-windows-release: lanternd-windows-amd64 lanternd-windows-arm64 $(MAKE) copy-lanternd-release $(MAKE) copy-lanternd-release-arm64 $(call WRITE_TEXT_FILE,$(LANTERND_SERVICE_LOG_LEVEL),$(WINDOWS_RELEASE_DIR)/lanternd-log-level) +.PHONY: prepare-windows-profile +prepare-windows-profile: lanternd-windows-amd64 lanternd-windows-arm64 + $(MAKE) copy-lanternd-profile + $(MAKE) copy-lanternd-profile-arm64 + $(call WRITE_TEXT_FILE,$(LANTERND_SERVICE_LOG_LEVEL),$(WINDOWS_PROFILE_DIR)/lanternd-log-level) + .PHONY: windows-debug windows-debug: windows $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (debug) for Windows..." @@ -752,11 +792,24 @@ windows-debug: windows $(MAYBE_STEALTH_PROFILE) .PHONY: build-windows-release build-windows-release: $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (release) for Windows..." - flutter build windows --release --verbose $(DART_DEFINES) $(STEALTH_DART_DEFINES) + flutter build windows --release --verbose $(FLUTTER_TARGET_ARG) $(DART_DEFINES) $(STEALTH_DART_DEFINES) + +.PHONY: build-windows-profile stage-windows-profile +build-windows-profile: $(MAYBE_STEALTH_PROFILE) + @echo "Building Flutter app (profile) for Windows..." + flutter build windows --profile --verbose $(FLUTTER_TARGET_ARG) $(DART_DEFINES) $(STEALTH_DART_DEFINES) + +# Fastforge packages the Release directory. Stage the test-only profile build +# there after its native service binaries have been added. +stage-windows-profile: build-windows-profile prepare-windows-profile + $(PS) "if (Test-Path '$(WINDOWS_RELEASE_DIR)') { Remove-Item -Recurse -Force -LiteralPath '$(WINDOWS_RELEASE_DIR)' }; New-Item -ItemType Directory -Force -Path '$(WINDOWS_RELEASE_DIR)' | Out-Null; Copy-Item -Recurse -Force -Path '$(WINDOWS_PROFILE_DIR)\\*' -Destination '$(WINDOWS_RELEASE_DIR)'" .PHONY: windows-release windows-release: clean windows pubget gen build-windows-release prepare-windows-release +.PHONY: windows-profile-ci +windows-profile-ci: clean windows pubget gen stage-windows-profile + .PHONY: install-gomobile install-gomobile: GOTOOLCHAIN=$(GO_VERSION) go install -v golang.org/x/mobile/cmd/gomobile@$(GOMOBILE_VERSION) diff --git a/integration_test/auto_update/auto_update_robot.dart b/integration_test/auto_update/auto_update_robot.dart new file mode 100644 index 0000000000..7811f4c47b --- /dev/null +++ b/integration_test/auto_update/auto_update_robot.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lantern/core/utils/storage_utils.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../utils/app_robot.dart'; +import '../utils/widget_wait_utils.dart'; + +String get autoUpdateHandoffPath { + if (Platform.isMacOS) { + return '/Users/Shared/Lantern/E2E/auto-update-handoff.json'; + } + if (Platform.isWindows) { + final programData = Platform.environment['ProgramData']; + if (programData == null || programData.isEmpty) { + throw StateError('ProgramData is unavailable for the update handoff'); + } + return '$programData\\Lantern\\E2E\\auto-update-handoff.json'; + } + throw UnsupportedError('Auto-update handoff is desktop-only'); +} + +/// Drives Lantern up to the native Sparkle boundary. +class AutoUpdateRobot { + AutoUpdateRobot(this.tester) : app = AppRobot(tester); + + final WidgetTester tester; + final AppRobot app; + + final Finder checkForUpdates = find.byKey( + const Key('setting.check_for_updates_tile'), + ); + + Future triggerUpdateCheck() async { + e2eLog('Opening Settings for the auto-update smoke'); + await app.openSettings(); + await WidgetWaitUtils.waitForFinder( + tester, + checkForUpdates, + timeout: const Duration(seconds: 30), + reason: + 'Check for Updates did not appear. ' + 'Visible keys: ${app.visibleKeys().join(', ')}', + ); + await tester.ensureVisible(checkForUpdates); + await _captureScreenshot('auto-update-before'); + + e2eLog('Triggering Check for Updates'); + await app.tap(checkForUpdates, name: 'Check for Updates', settle: false); + } + + Future _captureScreenshot(String name) async { + try { + final renderView = tester.binding.renderViews.first; + final layer = renderView.debugLayer; + if (layer is! OffsetLayer) { + e2eLog('Screenshot $name skipped: root layer is not capturable'); + return; + } + final image = await layer.toImage(renderView.paintBounds); + try { + final data = await image.toByteData(format: ui.ImageByteFormat.png); + if (data == null) { + e2eLog('Screenshot $name skipped: no image data'); + return; + } + final directory = Directory( + '${await AppStorageUtils.getAppLogDirectory()}/screenshots', + ); + await directory.create(recursive: true); + final file = File('${directory.path}/$name.png'); + await file.writeAsBytes(data.buffer.asUint8List(), flush: true); + e2eLog('Screenshot saved: ${file.path}'); + } finally { + image.dispose(); + } + } catch (error) { + e2eLog('Screenshot $name failed: $error'); + } + } + + Future writeNativeHandoff() async { + final packageInfo = await PackageInfo.fromPlatform(); + final handoff = File(autoUpdateHandoffPath); + final payload = { + 'pid': pid, + 'display_version': packageInfo.version, + 'build_number': packageInfo.buildNumber, + 'created_at': DateTime.now().toUtc().toIso8601String(), + }; + await handoff.parent.create(recursive: true); + await handoff.writeAsString('${jsonEncode(payload)}\n', flush: true); + e2eLog('Native updater handoff ready at ${handoff.path}'); + } +} diff --git a/integration_test/auto_update/desktop_auto_update_smoke_test.dart b/integration_test/auto_update/desktop_auto_update_smoke_test.dart new file mode 100644 index 0000000000..bb05ab6cb9 --- /dev/null +++ b/integration_test/auto_update/desktop_auto_update_smoke_test.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:lantern/core/common/app_build_info.dart'; +import 'package:lantern/main.dart' as app; + +import 'auto_update_robot.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'lower signed beta fixture hands off the native update prompt', + (tester) async { + expect( + Platform.isMacOS || Platform.isWindows, + isTrue, + reason: 'This smoke is desktop-only', + ); + expect(kProfileMode, isTrue, reason: 'The fixture must be a profile app'); + expect( + AppBuildInfo.autoUpdateE2E, + isTrue, + reason: 'The fixture must use the isolated staging appcast', + ); + expect( + AppBuildInfo.buildType, + 'beta', + reason: 'The fixture must use the beta update channel', + ); + + await app.main(); + final robot = AutoUpdateRobot(tester); + await robot.triggerUpdateCheck(); + await robot.writeNativeHandoff(); + }, + timeout: const Timeout(Duration(minutes: 3)), + ); +} diff --git a/integration_test/utils/app_robot.dart b/integration_test/utils/app_robot.dart index cefb6a238a..132aba8175 100644 --- a/integration_test/utils/app_robot.dart +++ b/integration_test/utils/app_robot.dart @@ -10,7 +10,6 @@ import 'widget_wait_utils.dart'; void e2eLog(String message) => debugPrint('[E2E] $message'); /// Drives the app shell during integration tests, independent of any feature. -/// Android-only for now; other platforms still use `vpn/vpn_smoke_helpers.dart`. class AppRobot { AppRobot(this.tester); @@ -20,6 +19,9 @@ class AppRobot { final Finder onboardingScreen = find.byKey(const Key('onboarding.screen')); final Finder onboardingSkip = find.byKey(const Key('onboarding.skip')); final Finder onboardingPrimary = find.byKey(const Key('onboarding.primary')); + final Finder macosExtensionScreen = find.byKey( + const Key('macos_extension.screen'), + ); /// String-valued widget keys currently in the tree, sorted — a lightweight /// "what's on screen" dump for failure diagnostics. `Key('foo')` is a @@ -52,7 +54,7 @@ class AppRobot { /// Opens Settings through the UI: home menu button. Future openSettings() async { await waitForHomeReady(); - await _tap( + await tap( find.byKey(const Key('home.menu_button')), name: 'Home menu button', ); @@ -61,7 +63,7 @@ class AppRobot { /// Opens the Language screen through the UI: Settings -> Language. Future openLanguage() async { await openSettings(); - await _tap( + await tap( find.byKey(const Key('setting.language_tile')), name: 'Settings language tile', ); @@ -78,7 +80,7 @@ class AppRobot { /// same list. Future openAppearance() async { await openSettings(); - await _tap( + await tap( find.byKey(const Key('setting.appearance_tile')), name: 'Settings appearance tile', ); @@ -100,7 +102,7 @@ class AppRobot { e2eLog('No upgrade button on Settings — account is Pro'); return false; } - await _tap(upgrade, name: 'Upgrade to Pro button'); + await tap(upgrade, name: 'Upgrade to Pro button'); await WidgetWaitUtils.waitForFinder( tester, find.byKey(const Key('plans.list')), @@ -115,11 +117,11 @@ class AppRobot { /// home menu -> Settings -> Support -> Report an issue. Future openReportIssue() async { await openSettings(); - await _tap( + await tap( find.byKey(const Key('setting.support_tile')), name: 'Settings support tile', ); - await _tap( + await tap( find.byKey(const Key('support.report_issue_tile')), name: 'Support report-issue tile', ); @@ -180,8 +182,12 @@ class AppRobot { reason: 'None of ${finders.keys.join(', ')} appeared within $timeout', ); - /// Waits for [target], taps it, and lets the navigation settle. - Future _tap(Finder target, {required String name}) async { + /// Waits for [target] and taps it. Native handoffs can skip settling. + Future tap( + Finder target, { + required String name, + bool settle = true, + }) async { e2eLog('Tapping $name'); await WidgetWaitUtils.waitForFinder( tester, @@ -191,7 +197,11 @@ class AppRobot { ); await tester.ensureVisible(target); await tester.tap(target); - await tester.pumpAndSettle(); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(const Duration(milliseconds: 300)); + } } /// Waits for the home screen after launch. Does not touch onboarding. @@ -244,6 +254,42 @@ class AppRobot { return true; } + /// Closes the macOS extension screen when a non-VPN smoke starts fresh. + Future dismissMacOSExtensionScreenIfShown() async { + if (macosExtensionScreen.evaluate().isEmpty) { + return false; + } + e2eLog('macOS system extension screen shown — dismissing'); + + final close = find + .descendant( + of: macosExtensionScreen, + matching: find.byType(CloseButton), + ) + .hitTestable(); + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(deadline)) { + if (macosExtensionScreen.evaluate().isEmpty) { + return true; + } + if (close.evaluate().isNotEmpty) { + await tester.tap(close); + await tester.pump(const Duration(milliseconds: 400)); + break; + } + await tester.pump(const Duration(milliseconds: 200)); + } + + await WidgetWaitUtils.waitForFinderToDisappear( + tester, + macosExtensionScreen, + timeout: const Duration(seconds: 10), + reason: 'macOS system extension screen did not close after dismiss', + ); + e2eLog('macOS system extension screen dismissed'); + return true; + } + /// Waits until [control] is hit-testable — the app-ready gate — clearing /// onboarding that appears while waiting. Home renders before onboarding is /// pushed on top of it, so we first spend up to [onboardingGrace] letting @@ -268,6 +314,10 @@ class AppRobot { await dismissOnboardingIfShown(); continue; } + if (macosExtensionScreen.evaluate().isNotEmpty) { + await dismissMacOSExtensionScreenIfShown(); + continue; + } if (control.hitTestable().evaluate().isNotEmpty) { return; diff --git a/lib/core/common/app_build_info.dart b/lib/core/common/app_build_info.dart index 84f0daf471..81a971e141 100644 --- a/lib/core/common/app_build_info.dart +++ b/lib/core/common/app_build_info.dart @@ -17,6 +17,11 @@ class AppBuildInfo { defaultValue: false, ); + static const bool autoUpdateE2E = bool.fromEnvironment( + 'AUTO_UPDATE_E2E', + defaultValue: false, + ); + static const String stealthMode = String.fromEnvironment( 'STEALTH_MODE', defaultValue: 'normal', diff --git a/lib/core/common/app_urls.dart b/lib/core/common/app_urls.dart index 0ec820587e..b367d6965d 100644 --- a/lib/core/common/app_urls.dart +++ b/lib/core/common/app_urls.dart @@ -1,3 +1,5 @@ +import 'package:lantern/core/common/app_build_info.dart'; + class AppUrls { static String lanternOfficial = 'https://lantern.io'; static String support = 'https://support.lantern.io'; @@ -26,13 +28,19 @@ class AppUrls { 'https://update.getlantern.org/update/lantern'; static const appcastProd = '$updateServiceLantern/appcast.xml?channel=stable'; static const appcastBeta = '$updateServiceLantern/appcast.xml?channel=beta'; + static const appcastE2E = + 'https://update.staging.iantem.io/update/lantern/appcast.xml?channel=beta'; static String manuallyServerSetupURL = 'https://github.com/getlantern/lantern-server-manager'; static String digitalOceanBillingUrl = 'https://cloud.digitalocean.com/account/billing'; static const androidSideloadUpdateEndpoint = updateServiceLantern; - static String appcastFor(String buildType) { + static String appcastFor( + String buildType, { + bool autoUpdateE2E = AppBuildInfo.autoUpdateE2E, + }) { + if (autoUpdateE2E) return appcastE2E; switch (buildType) { case 'production': return appcastProd; diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 358904b687..2f5191f97b 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -123,6 +123,7 @@ class _HomeState extends ConsumerState { elevation: 5, leading: IconButton( key: const Key('home.menu_button'), + tooltip: 'settings'.i18n, onPressed: () { appRouter.push(Setting()); }, diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index bc2bf02217..26b6b56c9d 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -194,7 +194,9 @@ class _SettingState extends ConsumerState children: [ DividerSpace(), AppTile( + tileKey: const Key('setting.check_for_updates_tile'), label: 'check_for_updates'.i18n, + semanticsLabel: 'check_for_updates'.i18n, icon: AppImagePaths.update, onPressed: () async => await settingMenuTap( _SettingType.checkForUpdates, diff --git a/scripts/ci/generate_update_metadata.py b/scripts/ci/generate_update_metadata.py index a8c3aab937..d3b40f66ee 100755 --- a/scripts/ci/generate_update_metadata.py +++ b/scripts/ci/generate_update_metadata.py @@ -8,6 +8,7 @@ import binascii import hashlib import json +import urllib.parse from pathlib import Path from typing import Optional, Tuple @@ -72,6 +73,7 @@ def sidecar_for( bucket: str, signature_dir: Optional[Path] = None, sparkle_version: Optional[str] = None, + asset_base_url: Optional[str] = None, ) -> Optional[dict[str, object]]: info = artifact_info(path.name) if info is None: @@ -79,7 +81,10 @@ def sidecar_for( platform, os_name, arch = info channel = release_channel(build_type) - url = f"https://s3.amazonaws.com/{bucket}/releases/{build_type}/{version}/{path.name}" + if asset_base_url: + url = f"{asset_base_url.rstrip('/')}/{urllib.parse.quote(path.name)}" + else: + url = f"https://s3.amazonaws.com/{bucket}/releases/{build_type}/{version}/{path.name}" metadata: dict[str, object] = { "schema_version": 1, "app": "lantern", @@ -113,6 +118,10 @@ def main() -> None: parser.add_argument("--bucket", required=True) parser.add_argument("--output-dir", required=True, type=Path) parser.add_argument("--sparkle-signature-dir", required=True, type=Path) + parser.add_argument( + "--asset-base-url", + help="Override the artifact directory URL for synthetic fixture releases", + ) parser.add_argument("artifacts", nargs="+", type=Path) args = parser.parse_args() @@ -128,6 +137,7 @@ def main() -> None: args.bucket, args.sparkle_signature_dir, args.sparkle_version, + args.asset_base_url, ) if metadata is None: continue diff --git a/scripts/ci/generate_update_metadata_test.py b/scripts/ci/generate_update_metadata_test.py index 585bf4f906..e97f9277a4 100644 --- a/scripts/ci/generate_update_metadata_test.py +++ b/scripts/ci/generate_update_metadata_test.py @@ -154,6 +154,33 @@ def test_sidecar_for_adds_sparkle_signature_for_desktop(self) -> None: self.assertEqual(metadata["sparkle_version"], "920") self.assertEqual(metadata["sparkle_ed_signature"], signature) + def test_sidecar_can_point_at_a_fixture_release(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" + artifact.write_bytes(b"windows") + signature = base64.b64encode(bytes(range(64))).decode("ascii") + (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( + signature, + encoding="ascii", + ) + + metadata = generate_update_metadata.sidecar_for( + artifact, + "beta", + "9.2.0-beta.e2e.123", + "unused-bucket", + pathlib.Path(tmp), + "123", + "https://github.com/getlantern/lantern-update-fixtures/" + "releases/download/v9.2.0-beta.e2e.123", + ) + + self.assertEqual( + metadata["url"], + "https://github.com/getlantern/lantern-update-fixtures/releases/" + "download/v9.2.0-beta.e2e.123/lantern-installer-beta.exe", + ) + def test_updater_signature_rejects_invalid_base64(self) -> None: with tempfile.TemporaryDirectory() as tmp: artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" diff --git a/scripts/ci/resolve_desktop_update_target.py b/scripts/ci/resolve_desktop_update_target.py new file mode 100644 index 0000000000..91609c54b1 --- /dev/null +++ b/scripts/ci/resolve_desktop_update_target.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Resolve and validate a desktop beta target for the update smoke test.""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import hashlib +import json +import pathlib +import re +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import asdict, dataclass + +from defusedxml import ElementTree as ET +from defusedxml.common import DefusedXmlException + + +SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle" +DEFAULT_APPCAST_URL = ( + "https://update.getlantern.org/update/lantern/appcast.xml?channel=beta" +) +USER_AGENT = "LanternAutoUpdateSmoke/1.0" +DISPLAY_VERSION_RE = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") + + +class TargetError(Exception): + """Raised when the beta appcast cannot provide a safe update target.""" + + +@dataclass(frozen=True) +class UpdateTarget: + platform: str + appcast_url: str + appcast_sha256: str + target_build: int + fixture_build: int + display_version: str + artifact_url: str + artifact_length: int + ed_signature: str + + @property + def fixture_pubspec_version(self) -> str: + return f"{self.display_version}+{self.fixture_build}" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise TargetError(message) + + +def fetch_appcast(url: str) -> bytes: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/rss+xml, application/xml;q=0.9", + "User-Agent": USER_AGENT, + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + require(response.status == 200, f"appcast returned HTTP {response.status}") + return response.read() + except urllib.error.HTTPError as err: + content_type = err.headers.get_content_type() + body = err.read(256).decode("utf-8", errors="replace").strip() + detail = f": {body}" if content_type == "text/plain" and body else "" + raise TargetError(f"appcast returned HTTP {err.code}{detail}") from err + except urllib.error.URLError as err: + raise TargetError(f"unable to fetch appcast: {err.reason}") from err + + +def parse_target(xml_data: bytes, appcast_url: str, platform: str) -> UpdateTarget: + require(platform in {"macos", "windows"}, f"unsupported platform {platform!r}") + try: + root = ET.fromstring(xml_data) + except (DefusedXmlException, ET.ParseError) as err: + raise TargetError(f"invalid or unsafe appcast XML: {err}") from err + + item = root.find("./channel/item") + require(item is not None, "appcast has no release item") + + version_node = item.find(f"{{{SPARKLE_NS}}}version") + version = ( + version_node.text.strip() + if version_node is not None and version_node.text + else "" + ) + require( + version.isascii() and version.isdigit(), + f"appcast Sparkle version must be numeric; got {version!r}", + ) + target_build = int(version) + require(target_build > 1, "appcast Sparkle build must be greater than 1") + + short_version_node = item.find(f"{{{SPARKLE_NS}}}shortVersionString") + display_version = ( + short_version_node.text.strip() + if short_version_node is not None and short_version_node.text + else "" + ) + require( + DISPLAY_VERSION_RE.fullmatch(display_version) is not None, + "appcast display version must be a valid dotted version", + ) + + platform_name = "macOS" if platform == "macos" else "Windows" + platform_enclosures = [ + enclosure + for enclosure in item.findall("enclosure") + if enclosure.attrib.get(f"{{{SPARKLE_NS}}}os") == platform + ] + require(platform_enclosures, f"appcast has no {platform_name} enclosure") + require( + len(platform_enclosures) == 1, + f"appcast has more than one {platform_name} enclosure", + ) + enclosure = platform_enclosures[0] + + artifact_url = enclosure.attrib.get("url", "").strip() + parsed_url = urllib.parse.urlparse(artifact_url) + require( + parsed_url.scheme == "https" and bool(parsed_url.netloc), + f"{platform_name} update URL must use HTTPS", + ) + expected_suffix = ".dmg" if platform == "macos" else ".exe" + require( + parsed_url.path.lower().endswith(expected_suffix), + f"{platform_name} update URL must point to a {expected_suffix.upper()[1:]}", + ) + + length_text = enclosure.attrib.get("length", "").strip() + require( + length_text.isascii() and length_text.isdigit(), + f"{platform_name} enclosure length must be numeric", + ) + artifact_length = int(length_text) + require(artifact_length > 0, f"{platform_name} enclosure length must be positive") + + signature = enclosure.attrib.get(f"{{{SPARKLE_NS}}}edSignature", "").strip() + require(bool(signature), f"{platform_name} enclosure is missing an EdDSA signature") + try: + decoded_signature = base64.b64decode(signature, validate=True) + except (binascii.Error, ValueError) as err: + raise TargetError(f"{platform_name} EdDSA signature is not valid base64") from err + require( + len(decoded_signature) == 64, + f"{platform_name} EdDSA signature must decode to 64 bytes", + ) + + return UpdateTarget( + platform=platform, + appcast_url=appcast_url, + appcast_sha256=hashlib.sha256(xml_data).hexdigest(), + target_build=target_build, + fixture_build=target_build - 1, + display_version=display_version, + artifact_url=artifact_url, + artifact_length=artifact_length, + ed_signature=signature, + ) + + +def write_fixture_pubspec( + source: pathlib.Path, + destination: pathlib.Path, + target: UpdateTarget, +) -> None: + original = source.read_text(encoding="utf-8") + updated, count = re.subn( + r"(?m)^version:\s*[^\r\n]+$", + f"version: {target.fixture_pubspec_version}", + original, + count=1, + ) + require(count == 1, f"could not replace version in {source}") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(updated, encoding="utf-8") + + +def write_github_output(path: pathlib.Path, target: UpdateTarget) -> None: + outputs = { + "target_build": str(target.target_build), + "fixture_build": str(target.fixture_build), + "display_version": target.display_version, + "fixture_version": target.fixture_pubspec_version, + } + with path.open("a", encoding="utf-8") as output: + for name, value in outputs.items(): + output.write(f"{name}={value}\n") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", required=True, choices=("macos", "windows")) + parser.add_argument("--appcast-url", default=DEFAULT_APPCAST_URL) + parser.add_argument("--appcast-output", required=True, type=pathlib.Path) + parser.add_argument("--target-output", required=True, type=pathlib.Path) + parser.add_argument("--pubspec-input", required=True, type=pathlib.Path) + parser.add_argument("--pubspec-output", required=True, type=pathlib.Path) + parser.add_argument("--github-output", type=pathlib.Path) + args = parser.parse_args() + + try: + xml_data = fetch_appcast(args.appcast_url) + args.appcast_output.parent.mkdir(parents=True, exist_ok=True) + args.appcast_output.write_bytes(xml_data) + target = parse_target(xml_data, args.appcast_url, args.platform) + args.target_output.parent.mkdir(parents=True, exist_ok=True) + args.target_output.write_text( + json.dumps(asdict(target), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + write_fixture_pubspec(args.pubspec_input, args.pubspec_output, target) + if args.github_output is not None: + write_github_output(args.github_output, target) + except (OSError, TargetError) as err: + raise SystemExit( + f"unable to resolve {args.platform} beta update target: {err}" + ) from err + + print( + f"[E2E] resolved live beta {target.platform} " + f"{target.display_version} build {target.target_build}; " + f"fixture build is {target.fixture_build}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/resolve_desktop_update_target_test.py b/scripts/ci/resolve_desktop_update_target_test.py new file mode 100644 index 0000000000..83d93b564b --- /dev/null +++ b/scripts/ci/resolve_desktop_update_target_test.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import base64 +import pathlib +import sys +import tempfile +import unittest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +import resolve_desktop_update_target as resolver + + +SIGNATURE = base64.b64encode(bytes(range(64))).decode("ascii") +APPCAST_URL = "https://update.example.com/appcast.xml?channel=beta" + + +def appcast( + *, + build: str = "920", + display_version: str = "9.2.0", + os_name: str = "macos", + url: str = "https://example.com/lantern-installer-beta.dmg", + length: str = "12345", + signature: str = SIGNATURE, +) -> bytes: + return f""" + + + + {build} + {display_version} + + + + +""".encode() + + +class ResolveDesktopUpdateTargetTest(unittest.TestCase): + def test_parse_target_accepts_signed_numeric_macos_release(self) -> None: + target = resolver.parse_target(appcast(), APPCAST_URL, "macos") + + self.assertEqual(target.platform, "macos") + self.assertEqual(target.target_build, 920) + self.assertEqual(target.fixture_build, 919) + self.assertEqual(target.display_version, "9.2.0") + self.assertEqual(target.fixture_pubspec_version, "9.2.0+919") + self.assertEqual(target.ed_signature, SIGNATURE) + + def test_parse_target_accepts_signed_numeric_windows_release(self) -> None: + target = resolver.parse_target( + appcast( + os_name="windows", + url="https://example.com/lantern-installer-beta.exe", + ), + APPCAST_URL, + "windows", + ) + + self.assertEqual(target.platform, "windows") + self.assertEqual(target.artifact_url, "https://example.com/lantern-installer-beta.exe") + self.assertEqual(target.artifact_length, 12345) + self.assertEqual(target.ed_signature, SIGNATURE) + + def test_parse_target_rejects_non_numeric_sparkle_version(self) -> None: + with self.assertRaises(resolver.TargetError) as raised: + resolver.parse_target(appcast(build="9.2.0-beta"), APPCAST_URL, "macos") + self.assertEqual( + str(raised.exception), + "appcast Sparkle version must be numeric; got '9.2.0-beta'", + ) + + def test_parse_target_requires_display_version(self) -> None: + with self.assertRaisesRegex(resolver.TargetError, "display version"): + resolver.parse_target(appcast(display_version=""), APPCAST_URL, "macos") + with self.assertRaisesRegex(resolver.TargetError, "display version"): + resolver.parse_target( + appcast(display_version="9.2.0+metadata"), + APPCAST_URL, + "macos", + ) + + def test_parse_target_requires_macos_dmg(self) -> None: + with self.assertRaisesRegex(resolver.TargetError, "no macOS enclosure"): + resolver.parse_target(appcast(os_name="windows"), APPCAST_URL, "macos") + + with self.assertRaisesRegex(resolver.TargetError, "point to a DMG"): + resolver.parse_target( + appcast(url="https://example.com/lantern.exe"), + APPCAST_URL, + "macos", + ) + + def test_parse_target_requires_https(self) -> None: + with self.assertRaisesRegex(resolver.TargetError, "must use HTTPS"): + resolver.parse_target( + appcast(url="http://example.com/lantern.dmg"), + APPCAST_URL, + "macos", + ) + + def test_parse_target_requires_valid_signature(self) -> None: + signatures = ( + "", + "not-base64", + base64.b64encode(b"short").decode("ascii"), + ) + for signature in signatures: + with self.subTest(signature=signature): + with self.assertRaisesRegex(resolver.TargetError, "signature"): + resolver.parse_target(appcast(signature=signature), APPCAST_URL, "macos") + + def test_parse_target_rejects_unsafe_xml(self) -> None: + xml_data = appcast().replace( + b']>920<", b">&build;<") + with self.assertRaisesRegex(resolver.TargetError, "unsafe appcast"): + resolver.parse_target(xml_data, APPCAST_URL, "macos") + + def test_write_fixture_pubspec_only_replaces_version(self) -> None: + target = resolver.parse_target(appcast(), APPCAST_URL, "macos") + with tempfile.TemporaryDirectory() as tmp: + source = pathlib.Path(tmp) / "source.yaml" + destination = pathlib.Path(tmp) / "pubspec.yaml" + source.write_text("name: lantern\nversion: 1.0.0+1\ndescription: Test\n") + + resolver.write_fixture_pubspec(source, destination, target) + + self.assertEqual( + destination.read_text(), + "name: lantern\nversion: 9.2.0+919\ndescription: Test\n", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/verify_fixture_update_artifacts.py b/scripts/ci/verify_fixture_update_artifacts.py new file mode 100644 index 0000000000..bea721c860 --- /dev/null +++ b/scripts/ci/verify_fixture_update_artifacts.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Verify signed desktop fixture artifacts and their update sidecars.""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import hashlib +import json +import pathlib +import plistlib +import re +import subprocess +import tempfile +import urllib.parse + + +class VerificationError(Exception): + pass + + +def require(condition: bool, message: str) -> None: + if not condition: + raise VerificationError(message) + + +def public_key(info_plist: pathlib.Path, runner_rc: pathlib.Path) -> bytes: + with info_plist.open("rb") as source: + mac_key = plistlib.load(source).get("SUPublicEDKey", "") + match = re.search( + r'EdDSAPub\s+EDDSA\s+\{"([A-Za-z0-9+/=]+)"\}', + runner_rc.read_text(encoding="utf-8"), + ) + require(match is not None, "Windows EdDSAPub resource is missing") + windows_key = match.group(1) + require(mac_key == windows_key, "macOS and Windows update public keys differ") + try: + decoded = base64.b64decode(mac_key, validate=True) + except (binascii.Error, ValueError) as err: + raise VerificationError("update public key is not valid base64") from err + require(len(decoded) == 32, "Ed25519 public key must decode to 32 bytes") + return decoded + + +def sha256(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def verify_signature(artifact: pathlib.Path, signature: str, key: bytes) -> None: + try: + decoded_signature = base64.b64decode(signature, validate=True) + except (binascii.Error, ValueError) as err: + raise VerificationError(f"invalid signature for {artifact.name}") from err + require(len(decoded_signature) == 64, f"invalid signature length for {artifact.name}") + + # SubjectPublicKeyInfo prefix for a raw Ed25519 public key (RFC 8410). + spki = bytes.fromhex("302a300506032b6570032100") + key + with tempfile.TemporaryDirectory() as temporary_directory: + temporary = pathlib.Path(temporary_directory) + key_path = temporary / "public-key.der" + signature_path = temporary / "signature.bin" + key_path.write_bytes(spki) + signature_path.write_bytes(decoded_signature) + result = subprocess.run( + [ + "openssl", + "pkeyutl", + "-verify", + "-pubin", + "-inkey", + str(key_path), + "-keyform", + "DER", + "-rawin", + "-in", + str(artifact), + "-sigfile", + str(signature_path), + ], + check=False, + capture_output=True, + text=True, + ) + require( + result.returncode == 0, + f"Ed25519 verification failed for {artifact.name}: " + f"{result.stderr.strip() or result.stdout.strip()}", + ) + + +def verify_sidecar( + sidecar: pathlib.Path, + artifact_directory: pathlib.Path, + asset_base_url: str, + key: bytes, +) -> None: + metadata = json.loads(sidecar.read_text(encoding="utf-8")) + filename = metadata.get("filename", "") + require(isinstance(filename, str) and bool(filename), f"invalid filename in {sidecar.name}") + artifact = artifact_directory / filename + require(artifact.is_file(), f"artifact is missing for {sidecar.name}: {filename}") + require(metadata.get("size") == artifact.stat().st_size, f"size mismatch for {filename}") + require(metadata.get("sha256") == sha256(artifact), f"checksum mismatch for {filename}") + expected_url = f"{asset_base_url.rstrip('/')}/{urllib.parse.quote(filename)}" + require(metadata.get("url") == expected_url, f"unexpected fixture URL for {filename}") + sparkle_version = str(metadata.get("sparkle_version", "")) + require( + sparkle_version.isascii() and sparkle_version.isdigit(), + f"invalid Sparkle version for {filename}", + ) + verify_signature(artifact, metadata.get("sparkle_ed_signature", ""), key) + print(f"verified {filename}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifact-dir", required=True, type=pathlib.Path) + parser.add_argument("--metadata-dir", required=True, type=pathlib.Path) + parser.add_argument("--asset-base-url", required=True) + parser.add_argument("--info-plist", default="macos/Runner/Info.plist", type=pathlib.Path) + parser.add_argument("--runner-rc", default="windows/runner/Runner.rc", type=pathlib.Path) + args = parser.parse_args() + + try: + key = public_key(args.info_plist, args.runner_rc) + sidecars = sorted(args.metadata_dir.glob("*.update.json")) + require(bool(sidecars), "no fixture sidecars found") + for sidecar in sidecars: + verify_sidecar(sidecar, args.artifact_dir, args.asset_base_url, key) + except (OSError, VerificationError, json.JSONDecodeError) as err: + raise SystemExit(f"fixture verification failed: {err}") from err + + +if __name__ == "__main__": + main() diff --git a/test/core/common/app_urls_test.dart b/test/core/common/app_urls_test.dart index 4bf2125b74..38ee133ece 100644 --- a/test/core/common/app_urls_test.dart +++ b/test/core/common/app_urls_test.dart @@ -17,5 +17,12 @@ void main() { 'https://update.getlantern.org/update/lantern/appcast.xml?channel=stable', ); }); + + test('uses the isolated staging feed for E2E fixtures', () { + expect( + AppUrls.appcastFor('beta', autoUpdateE2E: true), + 'https://update.staging.iantem.io/update/lantern/appcast.xml?channel=beta', + ); + }); }); } diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart new file mode 100644 index 0000000000..b38629cca9 --- /dev/null +++ b/test_driver/integration_test.dart @@ -0,0 +1,3 @@ +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); From bf9ced2a0e7483ee90d8ae11152f369955920c3a Mon Sep 17 00:00:00 2001 From: atavism Date: Mon, 17 Aug 2026 08:56:57 -0700 Subject: [PATCH 7/8] code review updates --- .../workflows/build-auto-update-fixtures.yml | 4 +- .github/workflows/macos-auto-update-smoke.yml | 3 +- .../workflows/windows-auto-update-smoke.yml | 3 +- .../auto_update/auto_update_robot.dart | 35 +----------------- integration_test/utils/app_robot.dart | 37 +++++++++++++++++++ test_driver/integration_test.dart | 2 + 6 files changed, 46 insertions(+), 38 deletions(-) diff --git a/.github/workflows/build-auto-update-fixtures.yml b/.github/workflows/build-auto-update-fixtures.yml index 003a551067..d495301724 100644 --- a/.github/workflows/build-auto-update-fixtures.yml +++ b/.github/workflows/build-auto-update-fixtures.yml @@ -79,7 +79,7 @@ jobs: auto_update_e2e: true sign_update_artifact: true run_connect_smoke: false - run_payment_checkout_smoke: false + run_payment_smoke: false build-windows: needs: prepare @@ -100,7 +100,7 @@ jobs: run_split_tunnel_website_smoke: false run_config_url_smoke: false run_auth_smoke: false - run_payment_checkout_smoke: false + run_payment_smoke: false assemble: needs: diff --git a/.github/workflows/macos-auto-update-smoke.yml b/.github/workflows/macos-auto-update-smoke.yml index b388f369e0..00d21bea66 100644 --- a/.github/workflows/macos-auto-update-smoke.yml +++ b/.github/workflows/macos-auto-update-smoke.yml @@ -106,12 +106,13 @@ jobs: installer_base_name: lantern-autoupdate-fixture runner_label: lantern-macos-smoke flutter_target: integration_test/auto_update/desktop_auto_update_smoke_test.dart + # flutter drive needs the VM service exposed by a profile fixture. flutter_build_mode: profile pubspec_artifact: macos-auto-update-pubspec sign_update_artifact: false auto_update_e2e: true run_connect_smoke: false - run_payment_checkout_smoke: false + run_payment_smoke: false exercise-update: name: Install and update through Sparkle diff --git a/.github/workflows/windows-auto-update-smoke.yml b/.github/workflows/windows-auto-update-smoke.yml index d7cad08952..a62cac5f3e 100644 --- a/.github/workflows/windows-auto-update-smoke.yml +++ b/.github/workflows/windows-auto-update-smoke.yml @@ -105,6 +105,7 @@ jobs: build_type: beta installer_base_name: lantern-autoupdate-fixture flutter_target: integration_test/auto_update/desktop_auto_update_smoke_test.dart + # flutter drive needs the VM service exposed by a profile fixture. flutter_build_mode: profile pubspec_artifact: windows-auto-update-pubspec sign_update_artifact: false @@ -117,7 +118,7 @@ jobs: run_split_tunnel_website_smoke: false run_config_url_smoke: false run_auth_smoke: false - run_payment_checkout_smoke: false + run_payment_smoke: false exercise-update: name: Install and update through WinSparkle diff --git a/integration_test/auto_update/auto_update_robot.dart b/integration_test/auto_update/auto_update_robot.dart index 7811f4c47b..9c7ac38c9f 100644 --- a/integration_test/auto_update/auto_update_robot.dart +++ b/integration_test/auto_update/auto_update_robot.dart @@ -1,10 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:ui' as ui; -import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:lantern/core/utils/storage_utils.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../utils/app_robot.dart'; @@ -47,42 +44,12 @@ class AutoUpdateRobot { 'Visible keys: ${app.visibleKeys().join(', ')}', ); await tester.ensureVisible(checkForUpdates); - await _captureScreenshot('auto-update-before'); + await app.captureScreenshot('auto-update-before'); e2eLog('Triggering Check for Updates'); await app.tap(checkForUpdates, name: 'Check for Updates', settle: false); } - Future _captureScreenshot(String name) async { - try { - final renderView = tester.binding.renderViews.first; - final layer = renderView.debugLayer; - if (layer is! OffsetLayer) { - e2eLog('Screenshot $name skipped: root layer is not capturable'); - return; - } - final image = await layer.toImage(renderView.paintBounds); - try { - final data = await image.toByteData(format: ui.ImageByteFormat.png); - if (data == null) { - e2eLog('Screenshot $name skipped: no image data'); - return; - } - final directory = Directory( - '${await AppStorageUtils.getAppLogDirectory()}/screenshots', - ); - await directory.create(recursive: true); - final file = File('${directory.path}/$name.png'); - await file.writeAsBytes(data.buffer.asUint8List(), flush: true); - e2eLog('Screenshot saved: ${file.path}'); - } finally { - image.dispose(); - } - } catch (error) { - e2eLog('Screenshot $name failed: $error'); - } - } - Future writeNativeHandoff() async { final packageInfo = await PackageInfo.fromPlatform(); final handoff = File(autoUpdateHandoffPath); diff --git a/integration_test/utils/app_robot.dart b/integration_test/utils/app_robot.dart index b0addcdaa2..57210c8dc9 100644 --- a/integration_test/utils/app_robot.dart +++ b/integration_test/utils/app_robot.dart @@ -1,7 +1,12 @@ +import 'dart:io'; +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lantern/core/common/common.dart' show appRouter; +import 'package:lantern/core/utils/storage_utils.dart'; import 'widget_wait_utils.dart'; @@ -39,6 +44,38 @@ class AppRobot { return keys.toList()..sort(); } + /// Captures the current Flutter surface without failing the smoke when the + /// platform does not expose a capturable root layer. + Future captureScreenshot(String name) async { + try { + final renderView = tester.binding.renderViews.first; + final layer = renderView.debugLayer; + if (layer is! OffsetLayer) { + e2eLog('Screenshot $name skipped: root layer is not capturable'); + return; + } + final image = await layer.toImage(renderView.paintBounds); + try { + final data = await image.toByteData(format: ui.ImageByteFormat.png); + if (data == null) { + e2eLog('Screenshot $name skipped: no image data'); + return; + } + final directory = Directory( + '${await AppStorageUtils.getAppLogDirectory()}/screenshots', + ); + await directory.create(recursive: true); + final file = File('${directory.path}/$name.png'); + await file.writeAsBytes(data.buffer.asUint8List(), flush: true); + e2eLog('Screenshot saved: ${file.path}'); + } finally { + image.dispose(); + } + } catch (error) { + e2eLog('Screenshot $name failed: $error'); + } + } + /// Waits until home is usable. Onboarding is pushed on top shortly after /// home renders, so gate on the menu button via [waitForControlReady], /// which waits out that window and clears onboarding. diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart index b38629cca9..6f82e7ae7e 100644 --- a/test_driver/integration_test.dart +++ b/test_driver/integration_test.dart @@ -1,3 +1,5 @@ import 'package:integration_test/integration_test_driver.dart'; +// The auto-update smoke uses flutter drive so it can control the installed, +// signed profile fixture and leave it running for the native updater handoff. Future main() => integrationDriver(); From f0a656fb4cfb6ae83939d5971f891a854558fed9 Mon Sep 17 00:00:00 2001 From: atavism Date: Mon, 17 Aug 2026 09:16:55 -0700 Subject: [PATCH 8/8] code review updates --- .github/scripts/macos_auto_update_smoke.sh | 3 +++ .../scripts/macos_sparkle_handoff.applescript | 18 +++++++++++------- .github/scripts/windows_auto_update_smoke.ps1 | 5 +++-- .../workflows/build-auto-update-fixtures.yml | 1 - .github/workflows/build-windows.yml | 4 ++-- Makefile | 5 +++-- 6 files changed, 22 insertions(+), 14 deletions(-) diff --git a/.github/scripts/macos_auto_update_smoke.sh b/.github/scripts/macos_auto_update_smoke.sh index 565a5ff459..3824f009b6 100644 --- a/.github/scripts/macos_auto_update_smoke.sh +++ b/.github/scripts/macos_auto_update_smoke.sh @@ -184,6 +184,7 @@ capture_diagnostics() { on_exit() { local status=$? + trap - EXIT INT TERM set +e capture_diagnostics capture_screenshot final @@ -192,6 +193,8 @@ on_exit() { exit "$status" } trap on_exit EXIT +trap 'exit 130' INT +trap 'exit 143' TERM install_fixture() { MOUNT_PATH="$(mktemp -d)" diff --git a/.github/scripts/macos_sparkle_handoff.applescript b/.github/scripts/macos_sparkle_handoff.applescript index 44109e167c..9bd89d51b8 100644 --- a/.github/scripts/macos_sparkle_handoff.applescript +++ b/.github/scripts/macos_sparkle_handoff.applescript @@ -98,16 +98,20 @@ end waitForMainWindow on installUntilExit(targetPID, timeoutSeconds) set deadline to (current date) + timeoutSeconds + set pressed to false repeat while (current date) is less than deadline set processRef to my processForPID(targetPID) if processRef is missing value then return "original process exited" - set buttonRef to my findInstallButton(processRef) - if buttonRef is not missing value then - tell application "System Events" - set buttonName to name of buttonRef as text - perform action "AXPress" of buttonRef - end tell - log "[E2E] pressed Sparkle " & buttonName + if not pressed then + set buttonRef to my findInstallButton(processRef) + if buttonRef is not missing value then + tell application "System Events" + set buttonName to name of buttonRef as text + perform action "AXPress" of buttonRef + end tell + set pressed to true + log "[E2E] pressed Sparkle " & buttonName + end if end if delay pollInterval end repeat diff --git a/.github/scripts/windows_auto_update_smoke.ps1 b/.github/scripts/windows_auto_update_smoke.ps1 index b7885e2e08..e9bea277d0 100644 --- a/.github/scripts/windows_auto_update_smoke.ps1 +++ b/.github/scripts/windows_auto_update_smoke.ps1 @@ -147,7 +147,7 @@ function Get-Window([string[]]$NamePrefixes) { ) foreach ($window in [System.Windows.Automation.AutomationElement]::RootElement.FindAll( [System.Windows.Automation.TreeScope]::Children, $condition)) { - $name = $window.Current.Name + $name = [string]$window.Current.Name foreach ($prefix in $NamePrefixes) { if ($name -eq $prefix -or $name.StartsWith("$prefix ")) { return $window } } @@ -162,7 +162,8 @@ function Get-Button($Window, [string[]]$Names) { [System.Windows.Automation.ControlType]::Button ) foreach ($button in $Window.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)) { - if ($button.Current.IsEnabled -and $Names -contains $button.Current.Name.Replace('&', '')) { + $name = ([string]$button.Current.Name).Replace('&', '') + if ($button.Current.IsEnabled -and $Names -contains $name) { return $button } } diff --git a/.github/workflows/build-auto-update-fixtures.yml b/.github/workflows/build-auto-update-fixtures.yml index d495301724..db5edc4561 100644 --- a/.github/workflows/build-auto-update-fixtures.yml +++ b/.github/workflows/build-auto-update-fixtures.yml @@ -18,7 +18,6 @@ on: permissions: contents: read - id-token: write concurrency: group: build-auto-update-fixtures diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 8e4b2af298..9d449b4eef 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -436,7 +436,7 @@ jobs: -Description "Installer - GitHub Actions build ${{ inputs.version }}" - name: Sign Windows update - if: ${{ inputs.package_installer && inputs.build_type != 'nightly' && inputs.sign_update_artifact }} + if: ${{ inputs.package_installer && !inputs.skip_signing && inputs.build_type != 'nightly' && inputs.sign_update_artifact }} shell: pwsh env: SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} @@ -455,7 +455,7 @@ jobs: retention-days: 2 - name: Upload Windows update signature - if: ${{ inputs.package_installer && inputs.build_type != 'nightly' && inputs.sign_update_artifact }} + if: ${{ inputs.package_installer && !inputs.skip_signing && inputs.build_type != 'nightly' && inputs.sign_update_artifact }} uses: actions/upload-artifact@v4 with: name: lantern-installer-exe-signature diff --git a/Makefile b/Makefile index a68e444dbe..9fd8f77d81 100644 --- a/Makefile +++ b/Makefile @@ -538,7 +538,7 @@ $(DARWIN_PROFILE_BUILD): $(MAYBE_STEALTH_PROFILE) rm -vf $(MACOS_INSTALLER) flutter build macos --profile $(FLUTTER_TARGET_ARG) $(DART_DEFINES) $(STEALTH_DART_DEFINES) -.PHONY: build-macos-profile stage-macos-profile +.PHONY: build-macos-release build-macos-profile stage-macos-profile build-macos-profile: $(DARWIN_PROFILE_BUILD) # Keep packaging and signing on the established Release staging path. The @@ -764,6 +764,7 @@ copy-lanternd-debug: $(LANTERND_WINDOWS_AMD64) $(call MKDIR_P,$(WINDOWS_DEBUG_DIR)) $(call COPY_FILE,$(LANTERND_WINDOWS_AMD64),$(WINDOWS_DEBUG_DIR)/$(LANTERND).exe) +.PHONY: copy-lanternd-profile copy-lanternd-profile-arm64 copy-lanternd-profile: $(LANTERND_WINDOWS_AMD64) $(call MKDIR_P,$(WINDOWS_PROFILE_DIR)) $(call COPY_FILE,$(LANTERND_WINDOWS_AMD64),$(LANTERND_WINDOWS_PROFILE)) @@ -802,7 +803,7 @@ build-windows-profile: $(MAYBE_STEALTH_PROFILE) # Fastforge packages the Release directory. Stage the test-only profile build # there after its native service binaries have been added. stage-windows-profile: build-windows-profile prepare-windows-profile - $(PS) "if (Test-Path '$(WINDOWS_RELEASE_DIR)') { Remove-Item -Recurse -Force -LiteralPath '$(WINDOWS_RELEASE_DIR)' }; New-Item -ItemType Directory -Force -Path '$(WINDOWS_RELEASE_DIR)' | Out-Null; Copy-Item -Recurse -Force -Path '$(WINDOWS_PROFILE_DIR)\\*' -Destination '$(WINDOWS_RELEASE_DIR)'" + $(PS) "if (Test-Path '$(WINDOWS_RELEASE_DIR)') { Remove-Item -Recurse -Force -LiteralPath '$(WINDOWS_RELEASE_DIR)' }; New-Item -ItemType Directory -Force -Path '$(WINDOWS_RELEASE_DIR)' | Out-Null; Copy-Item -Recurse -Force -Path '$(WINDOWS_PROFILE_DIR)/*' -Destination '$(WINDOWS_RELEASE_DIR)'" .PHONY: windows-release windows-release: clean windows pubget gen build-windows-release prepare-windows-release