diff --git a/.github/scripts/macos_smoke_suite.sh b/.github/scripts/macos_smoke_suite.sh new file mode 100755 index 0000000000..8dbb8b86a1 --- /dev/null +++ b/.github/scripts/macos_smoke_suite.sh @@ -0,0 +1,310 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_PATH="${TEST_PATH:-integration_test/vpn/macos_connect_smoke_test.dart}" +ARTIFACT_DIR="${ARTIFACT_DIR:-smoke-artifacts/macos}" +RUN_CONNECT_SMOKE="${RUN_CONNECT_SMOKE:-true}" +ENABLE_IP_CHECK="${ENABLE_IP_CHECK:-false}" +FORCE_FULL_TUNNEL="${FORCE_FULL_TUNNEL:-true}" +EXTENSION_TIMEOUT_SECONDS="${EXTENSION_TIMEOUT_SECONDS:-120}" +APP_INSTALL_DIR="${APP_INSTALL_DIR:-${RUNNER_TEMP:-/tmp}/lantern-macos-smoke/Lantern.app}" +LANTERN_LOG_DIR="${LANTERN_LOG_DIR:-/Users/Shared/Lantern/Logs}" +DMG_MOUNT_DIR="" + +log_step() { + printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2 +} + +find_first_name() { + local root="$1" + local name="$2" + + if [[ -z "$root" || ! -e "$root" ]]; then + return 1 + fi + + find "$root" -maxdepth 4 -name "$name" -print -quit +} + +copy_app_bundle() { + local source="$1" + local destination="$2" + + log_step "Copying Lantern app from $source" + rm -rf "$destination" + mkdir -p "$(dirname "$destination")" + ditto "$source" "$destination" + xattr -dr com.apple.quarantine "$destination" 2>/dev/null || true + printf '%s\n' "$destination" +} + +extract_app_zip() { + local zip_path="$1" + local tmp_dir + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' RETURN + + log_step "Extracting Lantern app from $zip_path" + ditto -x -k "$zip_path" "$tmp_dir" + + local app_path + app_path="$(find_first_name "$tmp_dir" "Lantern.app" || true)" + if [[ -z "$app_path" ]]; then + printf 'Lantern.app not found inside %s\n' "$zip_path" >&2 + return 1 + fi + + copy_app_bundle "$app_path" "$APP_INSTALL_DIR" +} + +detach_dmg() { + if [[ -n "$DMG_MOUNT_DIR" && -d "$DMG_MOUNT_DIR" ]]; then + local mount_dir="$DMG_MOUNT_DIR" + hdiutil detach "$mount_dir" -quiet || true + rmdir "$mount_dir" 2>/dev/null || true + DMG_MOUNT_DIR="" + fi +} + +copy_app_from_dmg() { + local dmg_path="$1" + local mount_dir + mount_dir="$(mktemp -d)" + DMG_MOUNT_DIR="$mount_dir" + + log_step "Mounting Lantern DMG $dmg_path" + hdiutil attach "$dmg_path" -nobrowse -readonly -mountpoint "$mount_dir" >/dev/null + + local app_path + app_path="$(find_first_name "$mount_dir" "Lantern.app" || true)" + if [[ -z "$app_path" ]]; then + printf 'Lantern.app not found inside %s\n' "$dmg_path" >&2 + detach_dmg + return 1 + fi + + copy_app_bundle "$app_path" "$APP_INSTALL_DIR" + detach_dmg +} + +resolve_app_path() { + if [[ -n "${APP_PATH:-}" && -x "$APP_PATH/Contents/MacOS/Lantern" ]]; then + printf '%s\n' "$APP_PATH" + return + fi + + local dmg_path + dmg_path="$(find_first_name "${DMG_ARTIFACT_DIR:-}" "*.dmg" || true)" + if [[ -n "$dmg_path" ]]; then + copy_app_from_dmg "$dmg_path" + return + fi + + local app_zip + app_zip="$(find_first_name "${APP_ARTIFACT_DIR:-}" "Lantern.app.zip" || true)" + if [[ -n "$app_zip" ]]; then + extract_app_zip "$app_zip" + return + fi + + local app_artifact + app_artifact="$(find_first_name "${APP_ARTIFACT_DIR:-}" "Lantern.app" || true)" + if [[ -n "$app_artifact" ]]; then + copy_app_bundle "$app_artifact" "$APP_INSTALL_DIR" + return + fi + + local candidates=( + "build/macos/Build/Products/Release/Lantern.app" + "build/macos/Build/Products/Debug/Lantern.app" + "build/macos/Runner.app" + ) + + for candidate in "${candidates[@]}"; do + if [[ -d "$candidate" ]]; then + printf '%s\n' "$candidate" + return + fi + done + + printf 'Lantern.app was not found. Set APP_PATH, or provide APP_ARTIFACT_DIR/DMG_ARTIFACT_DIR.\n' >&2 + return 1 +} + +capture_command() { + local name="$1" + shift + + log_step "Capturing $name" + "$@" >"$ARTIFACT_DIR/$name.txt" 2>&1 || true +} + +capture_lantern_logs() { + local output_dir="$ARTIFACT_DIR/lantern-logs" + mkdir -p "$output_dir" + + if [[ -d "$LANTERN_LOG_DIR" ]]; then + cp -R "$LANTERN_LOG_DIR/." "$output_dir/" 2>/dev/null || true + else + printf 'No Lantern log directory found at %s\n' "$LANTERN_LOG_DIR" \ + >"$output_dir/missing.txt" + fi +} + +reset_lantern_logs() { + log_step "Resetting Lantern logs at $LANTERN_LOG_DIR" + rm -rf "$LANTERN_LOG_DIR" + mkdir -p "$LANTERN_LOG_DIR" +} + +capture_unified_logs() { + log_step "Capturing unified logs" + log show \ + --last 30m \ + --style syslog \ + --predicate 'subsystem == "org.getlantern.lantern" OR subsystem == "org.getlantern.lantern.PacketTunnel"' \ + >"$ARTIFACT_DIR/unified-lantern.log" 2>&1 || true +} + +capture_screenshot() { + log_step "Capturing screenshot" + screencapture -x "$ARTIFACT_DIR/screenshot.png" 2>/dev/null || true +} + +capture_diagnostics() { + local reason="$1" + + mkdir -p "$ARTIFACT_DIR" + log_step "Capturing diagnostics: $reason" + { + printf 'reason=%s\n' "$reason" + date + } >"$ARTIFACT_DIR/diagnostics.txt" + + capture_command "systemextensionsctl-list" systemextensionsctl list + capture_command "process-list" ps aux + capture_command "packet-tunnel-processes" pgrep -fl "org.getlantern.lantern.PacketTunnel" + capture_lantern_logs + capture_unified_logs + capture_screenshot +} + +quit_lantern() { + log_step "Asking Lantern to quit" + osascript -e 'tell application id "org.getlantern.lantern" to quit' >/dev/null 2>&1 || true + osascript -e 'tell application "Lantern" to quit' >/dev/null 2>&1 || true + sleep 2 +} + +packet_tunnel_processes() { + pgrep -fl "org.getlantern.lantern.PacketTunnel" 2>/dev/null || true +} + +wait_for_packet_tunnel_exit() { + local timeout_seconds="${1:-30}" + + for ((i = 0; i < timeout_seconds; i++)); do + if [[ -z "$(packet_tunnel_processes)" ]]; then + log_step "PacketTunnel is not running" + return 0 + fi + sleep 1 + done + + packet_tunnel_processes >"$ARTIFACT_DIR/packet-tunnel-still-running.txt" + printf 'PacketTunnel was still running after disconnect/quit\n' >&2 + return 1 +} + +run_system_extension_preflight() { + local app_executable="$1" + local output="$ARTIFACT_DIR/system-extension-preflight.jsonl" + + log_step "Running macOS system extension preflight" + set +e + "$app_executable" \ + --smoke-activate-system-extension \ + --timeout-seconds "$EXTENSION_TIMEOUT_SECONDS" \ + >"$output" 2>&1 + local exit_code=$? + set -e + + cat "$output" + case "$exit_code" in + 0) + return 0 + ;; + 20) + printf 'System extension approval is missing on this runner. Approve Lantern in System Settings or install the MDM approval profile, then rerun this smoke test.\n' >&2 + ;; + 21) + printf 'System extension activation requires a reboot before this smoke test can connect.\n' >&2 + ;; + 124) + printf 'System extension preflight timed out after %s seconds.\n' "$EXTENSION_TIMEOUT_SECONDS" >&2 + ;; + *) + printf 'System extension preflight failed with exit code %s.\n' "$exit_code" >&2 + ;; + esac + + return "$exit_code" +} + +run_flutter_connect_smoke() { + local args=( + "test" + "$TEST_PATH" + "-d" + "macos" + "--reporter=expanded" + "--dart-define=DISABLE_SYSTEM_TRAY=true" + ) + + if [[ "$ENABLE_IP_CHECK" == "true" ]]; then + args+=("--dart-define=ENABLE_IP_CHECK=true") + fi + + if [[ "$FORCE_FULL_TUNNEL" == "true" ]]; then + args+=("--dart-define=SMOKE_FORCE_FULL_TUNNEL=true") + fi + + log_step "Running macOS connect smoke: flutter ${args[*]}" + flutter "${args[@]}" +} + +on_exit() { + local status=$? + + quit_lantern + if [[ "$status" -ne 0 ]]; then + capture_diagnostics "failure" + fi + detach_dmg + + exit "$status" +} + +trap on_exit EXIT + +mkdir -p "$ARTIFACT_DIR" +reset_lantern_logs +capture_command "systemextensionsctl-list-initial" systemextensionsctl list + +app_path="$(resolve_app_path)" +app_executable="$app_path/Contents/MacOS/Lantern" +if [[ ! -x "$app_executable" ]]; then + printf 'Lantern executable not found at %s\n' "$app_executable" >&2 + exit 1 +fi + +if [[ "$RUN_CONNECT_SMOKE" == "true" ]]; then + run_system_extension_preflight "$app_executable" + run_flutter_connect_smoke +else + log_step "Skipping macOS connect smoke test." +fi + +quit_lantern +wait_for_packet_tunnel_exit 30 +capture_diagnostics "success" diff --git a/.github/workflows/app-smoke-tests.yml b/.github/workflows/app-smoke-tests.yml index dee28e5025..42c1c2c0b0 100644 --- a/.github/workflows/app-smoke-tests.yml +++ b/.github/workflows/app-smoke-tests.yml @@ -10,6 +10,7 @@ on: options: - all - linux + - macos - windows default: all linux_arch: @@ -26,6 +27,11 @@ on: required: false type: boolean default: true + macos_connect_smoke: + description: "Include macOS connect/disconnect smoke when platforms=all (requires self-hosted runner with approved system extension)" + required: false + type: boolean + default: false windows_split_tunnel_website_smoke: description: "Run Windows split-tunneling website smoke" required: false @@ -105,6 +111,35 @@ jobs: force_full_tunnel_smoke: ${{ inputs.force_full_tunnel_smoke }} run_auth_smoke: ${{ inputs.auth_smoke }} + macos: + needs: prepare + if: ${{ inputs.platforms == 'macos' || (inputs.platforms == 'all' && inputs.macos_connect_smoke) }} + uses: ./.github/workflows/build-macos.yml + secrets: + AC_USERNAME: ${{ secrets.AC_USERNAME }} + AC_PASSWORD: ${{ secrets.AC_PASSWORD }} + APP_ENV: ${{ secrets.APP_ENV }} + MACOS_BNS_CERT: ${{ secrets.MACOS_BNS_CERT }} + MACOS_BNS_CERT_PASS: ${{ secrets.MACOS_BNS_CERT_PASS }} + MACOS_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_PROVISION_PROFILE_BASE64 }} + MACOS_PROVISION_TUNNEL_BASE64: ${{ secrets.MACOS_PROVISION_TUNNEL_BASE64 }} + with: + version: ${{ needs.prepare.outputs.version }} + build_type: nightly + installer_base_name: ${{ needs.prepare.outputs.installer_base_name }} + + macos-connect-smoke: + needs: [prepare, macos] + if: ${{ inputs.platforms == 'macos' || (inputs.platforms == 'all' && inputs.macos_connect_smoke) }} + uses: ./.github/workflows/macos-connect-smoke.yml + secrets: + APP_ENV: ${{ secrets.APP_ENV }} + with: + version: ${{ needs.prepare.outputs.version }} + build_type: nightly + enable_ip_check: ${{ inputs.enable_ip_check }} + force_full_tunnel_smoke: ${{ inputs.force_full_tunnel_smoke }} + windows: needs: prepare if: ${{ inputs.platforms == 'all' || inputs.platforms == 'windows' }} diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index a96aedf129..725b1f9c30 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -2,6 +2,21 @@ name: Build macOS on: workflow_call: + secrets: + AC_USERNAME: + required: true + AC_PASSWORD: + required: true + APP_ENV: + required: true + MACOS_BNS_CERT: + required: true + MACOS_BNS_CERT_PASS: + required: true + MACOS_PROVISION_PROFILE_BASE64: + required: true + MACOS_PROVISION_TUNNEL_BASE64: + required: true inputs: version: required: true @@ -12,7 +27,6 @@ on: installer_base_name: required: true type: string - jobs: build-macos: permissions: @@ -150,6 +164,24 @@ jobs: BUCKET: ${{ vars.S3_RELEASES_BUCKET }} run: bash ./.github/scripts/check_macos_sysext_build.sh + - name: Package macOS app artifact + shell: bash + run: | + APP_PATH="build/macos/Build/Products/Release/Lantern.app" + if [[ ! -d "$APP_PATH" ]]; then + printf 'Lantern.app not found at %s\n' "$APP_PATH" >&2 + exit 1 + fi + + ditto -c -k --keepParent "$APP_PATH" "$RUNNER_TEMP/Lantern.app.zip" + + - name: Upload macOS app + uses: actions/upload-artifact@v4 + with: + name: lantern-macos-app + path: ${{ runner.temp }}/Lantern.app.zip + retention-days: 2 + - name: Upload macOS installer uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/macos-connect-smoke.yml b/.github/workflows/macos-connect-smoke.yml new file mode 100644 index 0000000000..38f4f4aa03 --- /dev/null +++ b/.github/workflows/macos-connect-smoke.yml @@ -0,0 +1,148 @@ +name: macOS Connect Smoke + +on: + workflow_call: + inputs: + version: + required: true + type: string + build_type: + required: true + type: string + app_artifact_name: + required: false + type: string + default: lantern-macos-app + dmg_artifact_name: + required: false + type: string + default: lantern-installer-dmg + pubspec_artifact_name: + required: false + type: string + default: pubspec + enable_ip_check: + description: "Validate public IP change during macOS connect smoke" + required: false + type: boolean + default: false + force_full_tunnel_smoke: + description: "Force full tunnel routing mode during macOS connect smoke" + required: false + type: boolean + default: true + extension_timeout_seconds: + description: "Seconds to wait for system extension activation" + required: false + type: number + default: 120 + secrets: + APP_ENV: + required: true + +jobs: + macos-connect-smoke: + permissions: + contents: read + runs-on: [self-hosted, macOS, ARM64, lantern-macos-smoke] + timeout-minutes: 40 + env: + ARTIFACT_DIR: ${{ runner.temp }}/macos-smoke-artifacts + APP_ARTIFACT_DIR: ${{ runner.temp }}/lantern-macos-app + DMG_ARTIFACT_DIR: ${{ runner.temp }}/lantern-installer-dmg + BUILD_TYPE: ${{ inputs.build_type }} + VERSION: ${{ inputs.version }} + ENABLE_IP_CHECK: ${{ inputs.enable_ip_check }} + FORCE_FULL_TUNNEL: ${{ inputs.force_full_tunnel_smoke }} + EXTENSION_TIMEOUT_SECONDS: ${{ inputs.extension_timeout_seconds }} + RUN_CONNECT_SMOKE: true + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download pubspec.yaml + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.pubspec_artifact_name }} + + - name: Download macOS app artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.app_artifact_name }} + path: ${{ env.APP_ARTIFACT_DIR }} + + - name: Download macOS DMG artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.dmg_artifact_name }} + path: ${{ env.DMG_ARTIFACT_DIR }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + cache: true + + - name: Cache Flutter dependencies + uses: actions/cache@v4 + timeout-minutes: 5 + continue-on-error: true + with: + path: | + ~/.pub-cache + key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-flutter- + + - name: Install Flutter + uses: subosito/flutter-action@v2.22.0 + with: + channel: stable + flutter-version-file: .github/flutter-version.yaml + + - name: Decode APP_ENV + uses: timheuer/base64-to-file@v1.2 + with: + fileName: "app.env" + fileDir: ${{ github.workspace }} + encodedString: ${{ secrets.APP_ENV }} + + - name: Set gomobile cache dir + shell: bash + run: | + set -euo pipefail + echo "GOMOBILECACHE=$HOME/.cache/gomobile" >> "$GITHUB_ENV" + mkdir -p "$HOME/.cache/gomobile" + + - name: Cache gomobile + uses: actions/cache@v4 + timeout-minutes: 10 + continue-on-error: true + with: + path: ~/.cache/gomobile + key: ${{ runner.os }}-gomobile-${{ hashFiles('**/go.mod', '**/go.sum') }} + restore-keys: | + ${{ runner.os }}-gomobile- + + - name: Prepare macOS test project + shell: bash + run: | + set -euo pipefail + flutter --version + flutter config --enable-macos-desktop + make install-gomobile + make macos pubget gen + + - name: macOS connect smoke suite + timeout-minutes: 30 + shell: bash + run: ./.github/scripts/macos_smoke_suite.sh + + - name: Upload macOS smoke diagnostics + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: macos-smoke-diagnostics + path: ${{ env.ARTIFACT_DIR }} + if-no-files-found: ignore + retention-days: 2 diff --git a/integration_test/vpn/connect_smoke_harness.dart b/integration_test/vpn/connect_smoke_harness.dart index 222db06e70..96e26a334d 100644 --- a/integration_test/vpn/connect_smoke_harness.dart +++ b/integration_test/vpn/connect_smoke_harness.dart @@ -142,6 +142,7 @@ Future _disconnectVpn( Future runConnectSmokeHarness( WidgetTester tester, { bool enableIpCheck = false, + bool requireTrafficAfterConnect = false, }) async { final finders = VpnSmokeFinders(); final vpnStateFinders = VpnStateFinders(textLabels: _vpnStateLabels); @@ -175,6 +176,14 @@ Future runConnectSmokeHarness( reason: 'VPN did not reach connected state within 45 seconds', ); + if (requireTrafficAfterConnect) { + debugPrint('IP check: confirming public traffic after connect'); + await _fetchPublicIpWithRetry( + timeout: const Duration(seconds: 45), + reason: 'after connect', + ); + } + if (enableIpCheck && baselinePublicIp != null) { debugPrint('IP check: waiting for IP change after connect'); await Future.delayed(const Duration(seconds: 3)); diff --git a/integration_test/vpn/macos_connect_smoke_test.dart b/integration_test/vpn/macos_connect_smoke_test.dart new file mode 100644 index 0000000000..175b09abfe --- /dev/null +++ b/integration_test/vpn/macos_connect_smoke_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:lantern/core/models/macos_extension_state.dart'; +import 'package:lantern/features/macos_extension/provider/macos_extension_notifier.dart'; +import 'package:lantern/main.dart' as app; + +import '../utils/widget_wait_utils.dart'; +import 'connect_smoke_harness.dart'; +import 'vpn_smoke_helpers.dart'; + +const _enableIpCheck = bool.fromEnvironment( + 'ENABLE_IP_CHECK', + defaultValue: false, +); + +const _extensionBlockingStatuses = { + SystemExtensionStatus.requiresApproval, + SystemExtensionStatus.requiresReboot, + SystemExtensionStatus.timedOut, + SystemExtensionStatus.error, +}; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('macOS VPN connect/disconnect smoke', (tester) async { + await app.main(); + await _requireSystemExtensionReady(tester); + await runConnectSmokeHarness( + tester, + enableIpCheck: _enableIpCheck, + requireTrafficAfterConnect: true, + ); + }); +} + +Future _requireSystemExtensionReady(WidgetTester tester) async { + final finders = VpnSmokeFinders(); + final extensionScreen = find.byKey(const Key('macos_extension.screen')); + + await WidgetWaitUtils.waitForAnyFinder( + tester, + [extensionScreen, finders.homeScreen, finders.onboardingScreen], + timeout: const Duration(seconds: 90), + reason: 'Lantern did not reach a visible app screen after launch', + ); + + final container = _providerContainerForVisibleApp(tester, [ + extensionScreen, + finders.homeScreen, + finders.onboardingScreen, + ]); + var state = container.read(macosExtensionProvider); + final end = DateTime.now().add(const Duration(seconds: 45)); + + while (DateTime.now().isBefore(end)) { + if (state.isReady) { + debugPrint('macOS smoke: system extension ready (${state.status.name})'); + return; + } + + if (_extensionBlockingStatuses.contains(state.status)) { + fail(_systemExtensionDebugMessage(tester, state)); + } + + await tester.pump(const Duration(milliseconds: 300)); + state = container.read(macosExtensionProvider); + } + + fail(_systemExtensionDebugMessage(tester, state)); +} + +ProviderContainer _providerContainerForVisibleApp( + WidgetTester tester, + List finders, +) { + for (final finder in finders) { + final elements = finder.evaluate(); + if (elements.isNotEmpty) { + return ProviderScope.containerOf(elements.first, listen: false); + } + } + + fail('No visible app widget found for provider lookup'); +} + +String _systemExtensionDebugMessage( + WidgetTester tester, + MacOSExtensionState state, +) { + final statusKey = Key('macos_extension.status.${state.status.name}'); + final statusKeyVisible = find.byKey(statusKey).evaluate().isNotEmpty; + final details = state.message == null ? '' : ' Details: ${state.message}.'; + + return 'macOS system extension was not ready before connect: ' + '${state.status.name}.$details ' + 'Status key visible: $statusKeyVisible. ' + 'Visible keyed widgets: ${collectVisibleSmokeDebugKeys(tester)}'; +} diff --git a/integration_test/vpn/vpn_smoke_helpers.dart b/integration_test/vpn/vpn_smoke_helpers.dart index 6f8962cb88..1c3fefedc1 100644 --- a/integration_test/vpn/vpn_smoke_helpers.dart +++ b/integration_test/vpn/vpn_smoke_helpers.dart @@ -114,6 +114,7 @@ List collectVisibleSmokeDebugKeys(WidgetTester tester) { (k) => k.contains('vpn.') || k.contains('home.') || + k.contains('macos_extension.') || k.contains('split_tunneling.') || k.contains('onboarding.') || k.contains('server_selection.') || diff --git a/lib/features/macos_extension/macos_extension_dialog.dart b/lib/features/macos_extension/macos_extension_dialog.dart index 85cf61c24c..cb764397d1 100644 --- a/lib/features/macos_extension/macos_extension_dialog.dart +++ b/lib/features/macos_extension/macos_extension_dialog.dart @@ -50,6 +50,7 @@ class _MacOSExtensionDialogState extends ConsumerState { }, [systemExtensionStatus.status]); return BaseScreen( + key: const Key('macos_extension.screen'), title: '', appBar: CustomAppBar( leading: SizedBox(), @@ -60,6 +61,12 @@ class _MacOSExtensionDialogState extends ConsumerState { body: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + KeyedSubtree( + key: Key( + 'macos_extension.status.${systemExtensionStatus.status.name}', + ), + child: const SizedBox.shrink(), + ), AppImage(path: AppImagePaths.sysDialog, useThemeColor: false), const SizedBox(height: 48.0), Text( @@ -99,6 +106,7 @@ class _MacOSExtensionDialogState extends ConsumerState { ), SizedBox(height: 48.0), PrimaryButton( + buttonKey: const Key('macos_extension.install'), label: systemExtensionStatus.status == SystemExtensionStatus.requiresApproval diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 3107ca9ac3..2c5eb59395 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 5F53D03A2E44112E00740586 /* SystemExtensionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F53D0392E44112E00740586 /* SystemExtensionManager.swift */; }; 5F53D0402E4414A100740586 /* FilePath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F53D03F2E4414A100740586 /* FilePath.swift */; }; 5F53D0452E4414D000740586 /* FilePath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F53D03F2E4414A100740586 /* FilePath.swift */; }; + 5FAE10022F1C000100000001 /* SystemExtensionSmokeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FAE10012F1C000100000001 /* SystemExtensionSmokeCommand.swift */; }; 5F53D0512E44EBB700740586 /* org.getlantern.lantern.PacketTunnel.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 29952B042DE79CB200640E7F /* org.getlantern.lantern.PacketTunnel.systemextension */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5F53D0642E4502A700740586 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29952B052DE79CB200640E7F /* NetworkExtension.framework */; }; 5F53D0692E45078400740586 /* Liblantern.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 293C302F2DEF247F00691115 /* Liblantern.xcframework */; }; @@ -179,6 +180,7 @@ 5F53D0152E42442000740586 /* PrivateServerEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateServerEventHandler.swift; sourceTree = ""; }; 5F53D0392E44112E00740586 /* SystemExtensionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemExtensionManager.swift; sourceTree = ""; }; 5F53D03F2E4414A100740586 /* FilePath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePath.swift; sourceTree = ""; }; + 5FAE10012F1C000100000001 /* SystemExtensionSmokeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemExtensionSmokeCommand.swift; sourceTree = ""; }; 5F7422202E7C1FB000FC72BE /* LogsEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsEventHandler.swift; sourceTree = ""; }; 5F7B9A592F0C13AC0019E3E0 /* AppStreamHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStreamHandler.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; @@ -241,6 +243,7 @@ isa = PBXGroup; children = ( 5F53D0392E44112E00740586 /* SystemExtensionManager.swift */, + 5FAE10012F1C000100000001 /* SystemExtensionSmokeCommand.swift */, 293C305B2DEF67EB00691115 /* VPNBase.swift */, 293C305C2DEF67EB00691115 /* VPNManager.swift */, ); @@ -679,6 +682,7 @@ 5F53D0332E4410F100740586 /* PrivateServerEventHandler.swift in Sources */, 03A255852E71B27A0054C0D7 /* SystemExtensionStatusEventHandler.swift in Sources */, 5F53D03A2E44112E00740586 /* SystemExtensionManager.swift in Sources */, + 5FAE10022F1C000100000001 /* SystemExtensionSmokeCommand.swift in Sources */, 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, 293C305E2DEF67EB00691115 /* VPNManager.swift in Sources */, 293C305F2DEF67EB00691115 /* VPNBase.swift in Sources */, diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 8e6c0f0a0e..31afc77a8e 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,3 +1,4 @@ +import Darwin import FlutterMacOS import Liblantern import OSLog @@ -10,6 +11,7 @@ class AppDelegate: FlutterAppDelegate { private let vpnManager = VPNManager.shared private var methodHandler: MethodHandler? + private var systemExtensionSmokeRunner: SystemExtensionSmokeCommandRunner? override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false @@ -37,15 +39,33 @@ class AppDelegate: FlutterAppDelegate { } override func applicationDidFinishLaunching(_ aNotification: Notification) { + FilePath.setupFileSystem() + + switch SystemExtensionSmokeCommand.parse(arguments: ProcessInfo.processInfo.arguments) { + case .failure(let error): + SystemExtensionSmokeCommand.writeStdout(SystemExtensionSmokeCommand.errorJSON(error.message)) + exit(64) + case .success(let command): + if let command { + NSApp.setActivationPolicy(.accessory) + mainFlutterWindow?.orderOut(nil) + systemExtensionSmokeRunner = SystemExtensionSmokeCommandRunner( + command: command, + manager: systemExtensionManager, + output: SystemExtensionSmokeCommand.writeStdout, + complete: { code in exit(code) } + ) + systemExtensionSmokeRunner?.start() + return + } + } + guard let controller = mainFlutterWindow?.contentViewController as? FlutterViewController else { fatalError("contentViewController is not a FlutterViewController") } registerEventHandlers(controller: controller) - // Initialize directories and working paths - FilePath.setupFileSystem() - setupRadiance() // Setup native method channel diff --git a/macos/Runner/VPN/SystemExtensionManager.swift b/macos/Runner/VPN/SystemExtensionManager.swift index cc941b6019..c3f5d01ed1 100644 --- a/macos/Runner/VPN/SystemExtensionManager.swift +++ b/macos/Runner/VPN/SystemExtensionManager.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import CryptoKit import Foundation import SystemExtensions @@ -40,10 +41,10 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { static let shared = SystemExtensionManager() private let tunnelBundleID = "org.getlantern.lantern.PacketTunnel" - /// All access to requestContexts must go through contextQueue to avoid + /// All access to requestStates must go through contextQueue to avoid /// data races between init (possibly off main thread) and delegate /// callbacks (delivered on main queue). - private var requestContexts: [ObjectIdentifier: RequestContext] = [:] + private var requestStates: [ObjectIdentifier: RequestState] = [:] private let contextQueue = DispatchQueue( label: "org.getlantern.lantern.SystemExtensionManager.contexts" ) @@ -116,7 +117,9 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { _ request: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result ) { - let context = clearRequestContext(for: request) + let state = clearRequestState(for: request) + let context = state?.context + let observer = state?.observer appLogger.info( "System extension request finished: context=\(context?.logDescription ?? "unknown") result=\(result.logDescription)" ) @@ -137,9 +140,10 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { case .deactivateThenActivate(_, let activateAfter): if activateAfter { submitActivationRequest( - reason: "activating bundled extension after removing mismatched version") + reason: "activating bundled extension after removing mismatched version", + observer: observer) } else { - submitPropertiesRequest(context: .inspectStatus) + submitPropertiesRequest(context: .inspectStatus, observer: observer) } case .activate: // Activation succeeded — recovery is either complete or wasn't @@ -151,9 +155,9 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { // post-recovery activation surfaces as a real error rather than // re-entering the loop. didAttemptStaleRegistryRecovery = false - submitPropertiesRequest(context: .inspectStatus) + submitPropertiesRequest(context: .inspectStatus, observer: observer) case .inspectStatus, .reconcile: - submitPropertiesRequest(context: .inspectStatus) + submitPropertiesRequest(context: .inspectStatus, observer: observer) } case .willCompleteAfterReboot: // Even when the deactivation needs a reboot, still attempt activation @@ -165,13 +169,14 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { appLogger.info( "Deactivation needs reboot, but still attempting activation of bundled extension") submitActivationRequest( - reason: "activating bundled extension while old version awaits reboot removal") + reason: "activating bundled extension while old version awaits reboot removal", + observer: observer) return } let details = context?.rebootDetails ?? "system extension change will finish after reboot" - updateStatus(.requiresReboot(details: details)) + publishStatus(.requiresReboot(details: details), observer: observer) @unknown default: - updateStatus(.error("Unknown system extension result")) + publishStatus(.error("Unknown system extension result"), observer: observer) } } @@ -179,7 +184,9 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { _ request: OSSystemExtensionRequest, didFailWithError error: Error ) { - let context = clearRequestContext(for: request) + let state = clearRequestState(for: request) + let context = state?.context + let observer = state?.observer let nsError = error as NSError if nsError.domain == OSSystemExtensionErrorDomain @@ -189,7 +196,7 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { appLogger.info( "System extension request ended without applying changes: context=\(context?.logDescription ?? "unknown") error=\(nsError.localizedDescription)" ) - submitPropertiesRequest(context: .inspectStatus) + submitPropertiesRequest(context: .inspectStatus, observer: observer) return } @@ -204,7 +211,8 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { ) submitDeactivationRequest( reason: "stale-registry recovery after extensionNotFound", - activateAfter: true) + activateAfter: true, + observer: observer) return } @@ -217,29 +225,33 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { "Replacement deactivation failed with extensionNotFound — continuing with bundled activation (context=\(context?.logDescription ?? "unknown"))" ) submitActivationRequest( - reason: "activating bundled extension after missing stale extension during replacement") + reason: "activating bundled extension after missing stale extension during replacement", + observer: observer) return } appLogger.error( "System extension request failed: context=\(context?.logDescription ?? "unknown") error=\(nsError.localizedDescription)" ) - updateStatus(.error(nsError.localizedDescription)) + publishStatus(.error(nsError.localizedDescription), observer: observer) } public func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) { - let context = requestContext(for: request) + let state = requestState(for: request) + let context = state?.context appLogger.info( "System extension requires user approval: context=\(context?.logDescription ?? "unknown")" ) - updateStatus(.requiresApproval) + publishStatus(.requiresApproval, observer: state?.observer) } public func request( _ request: OSSystemExtensionRequest, foundProperties properties: [OSSystemExtensionProperties] ) { - let context = clearRequestContext(for: request) ?? .inspectStatus + let state = clearRequestState(for: request) + let context = state?.context ?? .inspectStatus + let observer = state?.observer reconciliationQueue.async { [self] in let bundled = SystemExtensionDescriptor.bundled(bundleID: self.tunnelBundleID) let installed = properties.map(SystemExtensionDescriptor.init(properties:)) @@ -256,13 +268,13 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { reconciliation: reconciliation ) - self.updateStatus(reconciliation.status) + self.publishStatus(reconciliation.status, observer: observer) guard context == .reconcile else { return } - self.perform(reconciliation.action) + self.perform(reconciliation.action, observer: observer) } } } @@ -275,9 +287,9 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { bundleID: bundleID) } - public func activateExtension() { + public func activateExtension(observer: ((ExtensionStatus) -> Void)? = nil) { appLogger.info("Reconciling bundled system extension for ID: \(tunnelBundleID)") - submitPropertiesRequest(context: .reconcile) + submitPropertiesRequest(context: .reconcile, observer: observer) } public func deactivateExtension() { @@ -285,14 +297,17 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { submitDeactivationRequest(reason: "manual deactivation", activateAfter: false) } - public func checkInstallationStatus() { + public func checkInstallationStatus(observer: ((ExtensionStatus) -> Void)? = nil) { appLogger.info("Checking installation status for ID: \(tunnelBundleID)") - submitPropertiesRequest(context: .inspectStatus) + submitPropertiesRequest(context: .inspectStatus, observer: observer) } - private func submitPropertiesRequest(context: RequestContext) { + private func submitPropertiesRequest( + context: RequestContext, + observer: ((ExtensionStatus) -> Void)? = nil + ) { guard #available(macOS 12.0, *) else { - handleLegacyPropertiesRequest(context: context) + handleLegacyPropertiesRequest(context: context, observer: observer) return } @@ -300,88 +315,111 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { forExtensionWithIdentifier: tunnelBundleID, queue: .main ) - submit(request, context: context) + submit(request, context: context, observer: observer) } - private func submitActivationRequest(reason: String) { + private func submitActivationRequest( + reason: String, + observer: ((ExtensionStatus) -> Void)? = nil + ) { let request = OSSystemExtensionRequest.activationRequest( forExtensionWithIdentifier: tunnelBundleID, queue: .main ) - submit(request, context: .activate(reason: reason)) + submit(request, context: .activate(reason: reason), observer: observer) } private func submitDeactivationRequest( reason: String, activateAfter: Bool, - bundleID: String? = nil + bundleID: String? = nil, + observer: ((ExtensionStatus) -> Void)? = nil ) { let request = OSSystemExtensionRequest.deactivationRequest( forExtensionWithIdentifier: bundleID ?? tunnelBundleID, queue: .main ) - submit(request, context: .deactivateThenActivate(reason: reason, activateAfter: activateAfter)) + submit( + request, + context: .deactivateThenActivate(reason: reason, activateAfter: activateAfter), + observer: observer) } - private func submit(_ request: OSSystemExtensionRequest, context: RequestContext) { + private func submit( + _ request: OSSystemExtensionRequest, + context: RequestContext, + observer: ((ExtensionStatus) -> Void)? = nil + ) { request.delegate = self - contextQueue.sync { requestContexts[ObjectIdentifier(request)] = context } + contextQueue.sync { + requestStates[ObjectIdentifier(request)] = RequestState(context: context, observer: observer) + } appLogger.info("Submitting system extension request: \(context.logDescription)") OSSystemExtensionManager.shared.submitRequest(request) } - private func handleLegacyPropertiesRequest(context: RequestContext) { + private func handleLegacyPropertiesRequest( + context: RequestContext, + observer: ((ExtensionStatus) -> Void)? = nil + ) { appLogger.info( "System extension properties are unavailable before macOS 12.0; using legacy fallback for context=\(context.logDescription)" ) switch context { case .inspectStatus: - updateStatus(.notInstalled) + publishStatus(.notInstalled, observer: observer) case .reconcile: let reason = "activate bundled system extension on macOS 10.15/11" - updateStatus(.updatePending(details: reason)) - submitActivationRequest(reason: reason) + publishStatus(.updatePending(details: reason), observer: observer) + submitActivationRequest(reason: reason, observer: observer) case .activate, .deactivateThenActivate: - completeLegacyRequest(context) + completeLegacyRequest(context, observer: observer) } } - private func completeLegacyRequest(_ context: RequestContext) { + private func completeLegacyRequest( + _ context: RequestContext, + observer: ((ExtensionStatus) -> Void)? = nil + ) { switch context { case .activate, .reconcile: - updateStatus(.activated) + publishStatus(.activated, observer: observer) case .deactivateThenActivate(_, let activateAfter): if activateAfter { submitActivationRequest( - reason: "activating bundled extension after legacy deactivation") + reason: "activating bundled extension after legacy deactivation", + observer: observer) } else { - updateStatus(.deactivated) + publishStatus(.deactivated, observer: observer) } case .inspectStatus: - updateStatus(.notInstalled) + publishStatus(.notInstalled, observer: observer) } } - private func requestContext(for request: OSSystemExtensionRequest) -> RequestContext? { - contextQueue.sync { requestContexts[ObjectIdentifier(request)] } + private func requestState(for request: OSSystemExtensionRequest) -> RequestState? { + contextQueue.sync { requestStates[ObjectIdentifier(request)] } } @discardableResult - private func clearRequestContext(for request: OSSystemExtensionRequest) -> RequestContext? { - contextQueue.sync { requestContexts.removeValue(forKey: ObjectIdentifier(request)) } + private func clearRequestState(for request: OSSystemExtensionRequest) -> RequestState? { + contextQueue.sync { requestStates.removeValue(forKey: ObjectIdentifier(request)) } } - private func perform(_ action: SystemExtensionInstallAction) { + private func perform( + _ action: SystemExtensionInstallAction, + observer: ((ExtensionStatus) -> Void)? = nil + ) { switch action { case .none: return case .activate(let reason): - updateStatus(.updatePending(details: reason)) - submitActivationRequest(reason: reason) + publishStatus(.updatePending(details: reason), observer: observer) + submitActivationRequest(reason: reason, observer: observer) case .deactivateThenActivate(let reason): - updateStatus(.updatePending(details: reason)) - submitDeactivationRequest(reason: reason, activateAfter: true) + publishStatus(.updatePending(details: reason), observer: observer) + submitDeactivationRequest(reason: reason, activateAfter: true, observer: observer) } } @@ -408,6 +446,14 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { appLogger.info("System extension status updated: \(newStatus.logDescription)") } + private func publishStatus( + _ newStatus: ExtensionStatus, + observer: ((ExtensionStatus) -> Void)? + ) { + updateStatus(newStatus) + observer?(newStatus) + } + public func openPrivacyAndSecuritySettings() { appLogger.log("Opening Privacy & Security settings for user approval.") let generalSecurityPaneURL = URL( @@ -440,6 +486,11 @@ class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate { } } +private struct RequestState { + let context: RequestContext + let observer: ((ExtensionStatus) -> Void)? +} + private enum RequestContext: Equatable { case inspectStatus case reconcile diff --git a/macos/Runner/VPN/SystemExtensionSmokeCommand.swift b/macos/Runner/VPN/SystemExtensionSmokeCommand.swift new file mode 100644 index 0000000000..49dead03c0 --- /dev/null +++ b/macos/Runner/VPN/SystemExtensionSmokeCommand.swift @@ -0,0 +1,259 @@ +import Foundation + +internal enum SystemExtensionSmokeAction: String, Equatable { + case status + case activate +} + +internal struct SystemExtensionSmokeParseError: Error, Equatable { + let message: String +} + +/// Command-line entry point for macOS smoke tests. It uses the same extension +/// manager as the UI, so macOS still owns the approval flow. +internal struct SystemExtensionSmokeCommand: Equatable { + static let statusFlag = "--smoke-system-extension-status" + static let activateFlag = "--smoke-activate-system-extension" + static let timeoutFlag = "--timeout-seconds" + static let defaultTimeout: TimeInterval = 120 + + let action: SystemExtensionSmokeAction + let timeout: TimeInterval + + static func parse( + arguments: [String] + ) -> Result { + let hasStatusFlag = arguments.contains(statusFlag) + let hasActivateFlag = arguments.contains(activateFlag) + + guard hasStatusFlag || hasActivateFlag else { + return .success(nil) + } + + guard !(hasStatusFlag && hasActivateFlag) else { + return .failure( + SystemExtensionSmokeParseError( + message: "\(statusFlag) and \(activateFlag) cannot be used together") + ) + } + + switch parseTimeout(arguments: arguments) { + case .failure(let error): + return .failure(error) + case .success(let timeout): + return .success( + SystemExtensionSmokeCommand( + action: hasActivateFlag ? .activate : .status, + timeout: timeout + ) + ) + } + } + + static func writeStdout(_ line: String) { + guard let data = "\(line)\n".data(using: .utf8) else { + return + } + FileHandle.standardOutput.write(data) + } + + static func errorJSON(_ message: String) -> String { + jsonLine([ + "error": message, + "exitCode": 64, + ]) + } + + private static func parseTimeout( + arguments: [String] + ) -> Result { + var timeout = defaultTimeout + var parsedTimeout = false + + let timeoutIndexes = arguments.indices.filter { arguments[$0] == timeoutFlag } + guard timeoutIndexes.count <= 1 else { + return .failure( + SystemExtensionSmokeParseError(message: "\(timeoutFlag) can only be set once") + ) + } + + if let timeoutIndex = timeoutIndexes.first { + parsedTimeout = true + let valueIndex = arguments.index(after: timeoutIndex) + guard valueIndex < arguments.endIndex else { + return .failure( + SystemExtensionSmokeParseError(message: "\(timeoutFlag) needs a value") + ) + } + + let rawValue = arguments[valueIndex] + guard !rawValue.hasPrefix("--") else { + return .failure( + SystemExtensionSmokeParseError(message: "\(timeoutFlag) needs a value") + ) + } + + guard let parsed = TimeInterval(rawValue), parsed > 0 else { + return .failure( + SystemExtensionSmokeParseError(message: "\(timeoutFlag) must be a positive number") + ) + } + timeout = parsed + } + + for argument in arguments where argument.hasPrefix("\(timeoutFlag)=") { + guard !parsedTimeout else { + return .failure( + SystemExtensionSmokeParseError(message: "\(timeoutFlag) can only be set once") + ) + } + + let rawValue = String(argument.dropFirst(timeoutFlag.count + 1)) + guard let parsed = TimeInterval(rawValue), parsed > 0 else { + return .failure( + SystemExtensionSmokeParseError(message: "\(timeoutFlag) must be a positive number") + ) + } + timeout = parsed + parsedTimeout = true + } + + return .success(timeout) + } + + fileprivate static func jsonLine(_ payload: [String: Any]) -> String { + guard + JSONSerialization.isValidJSONObject(payload), + let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) + else { + return #"{"error":"failed to encode smoke command response","exitCode":1}"# + } + return json + } +} + +internal struct SystemExtensionSmokeResult { + let action: SystemExtensionSmokeAction + let status: ExtensionStatus + let timeout: TimeInterval + + var exitCode: Int32 { + status.smokeExitCode(for: action) + } + + var jsonLine: String { + var payload: [String: Any] = [ + "action": action.rawValue, + "exitCode": Int(exitCode), + "status": status.code, + ] + if let details = status.details { + payload["details"] = details + } + if status == .timedOut { + payload["timeoutSeconds"] = timeout + } + return SystemExtensionSmokeCommand.jsonLine(payload) + } +} + +internal final class SystemExtensionSmokeCommandRunner { + private let command: SystemExtensionSmokeCommand + private let manager: SystemExtensionManager + private let output: (String) -> Void + private let complete: (Int32) -> Void + private var finished = false + + init( + command: SystemExtensionSmokeCommand, + manager: SystemExtensionManager, + output: @escaping (String) -> Void, + complete: @escaping (Int32) -> Void + ) { + self.command = command + self.manager = manager + self.output = output + self.complete = complete + } + + func start() { + appLogger.info( + "Starting system extension smoke command: action=\(command.action.rawValue) timeout=\(command.timeout)" + ) + + DispatchQueue.main.asyncAfter(deadline: .now() + command.timeout) { [weak self] in + self?.finish(.timedOut) + } + + switch command.action { + case .status: + manager.checkInstallationStatus { [weak self] status in + self?.handle(status) + } + case .activate: + manager.activateExtension { [weak self] status in + self?.handle(status) + } + } + } + + private func handle(_ status: ExtensionStatus) { + switch command.action { + case .status: + finish(status) + case .activate: + guard status.isSystemExtensionSmokeActivationTerminal else { + return + } + finish(status) + } + } + + private func finish(_ status: ExtensionStatus) { + guard !finished else { + return + } + + finished = true + + let result = SystemExtensionSmokeResult( + action: command.action, + status: status, + timeout: command.timeout + ) + appLogger.info( + "System extension smoke command finished: action=\(command.action.rawValue) status=\(status.logDescription) exitCode=\(result.exitCode)" + ) + output(result.jsonLine) + complete(result.exitCode) + } +} + +private extension ExtensionStatus { + var isSystemExtensionSmokeActivationTerminal: Bool { + switch self { + case .installed, .activated, .requiresApproval, .requiresReboot, .error, .timedOut: + return true + case .notInstalled, .uninstalling, .updatePending, .deactivated: + return false + } + } + + func smokeExitCode(for action: SystemExtensionSmokeAction) -> Int32 { + switch self { + case .installed, .activated: + return 0 + case .requiresApproval: + return action == .activate ? 20 : 0 + case .requiresReboot: + return action == .activate ? 21 : 0 + case .error: + return 1 + case .timedOut: + return 124 + case .notInstalled, .uninstalling, .updatePending, .deactivated: + return action == .activate ? 10 : 0 + } + } +} diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift index 224e49b249..69cf3b3491 100644 --- a/macos/RunnerTests/RunnerTests.swift +++ b/macos/RunnerTests/RunnerTests.swift @@ -557,6 +557,241 @@ final class RunnerTests: XCTestCase { XCTAssertEqual(reconciliation.action, .none) } + func testSystemExtensionSmokeCommandParsesStatus() { + switch SystemExtensionSmokeCommand.parse(arguments: ["Lantern", "--smoke-system-extension-status"]) { + case .success(let command?): + XCTAssertEqual(command.action, .status) + XCTAssertEqual(command.timeout, SystemExtensionSmokeCommand.defaultTimeout) + default: + XCTFail("Expected status smoke command") + } + } + + func testSystemExtensionSmokeCommandParsesActivationTimeout() { + switch SystemExtensionSmokeCommand.parse( + arguments: ["Lantern", "--smoke-activate-system-extension", "--timeout-seconds", "45"] + ) { + case .success(let command?): + XCTAssertEqual(command.action, .activate) + XCTAssertEqual(command.timeout, 45) + default: + XCTFail("Expected activate smoke command") + } + } + + func testSystemExtensionSmokeCommandParsesEqualsStyleTimeout() { + switch SystemExtensionSmokeCommand.parse( + arguments: ["Lantern", "--smoke-activate-system-extension", "--timeout-seconds=30"] + ) { + case .success(let command?): + XCTAssertEqual(command.action, .activate) + XCTAssertEqual(command.timeout, 30) + default: + XCTFail("Expected activate smoke command") + } + } + + func testSystemExtensionSmokeCommandRejectsInvalidArguments() { + switch SystemExtensionSmokeCommand.parse( + arguments: [ + "Lantern", + "--smoke-system-extension-status", + "--smoke-activate-system-extension", + ] + ) { + case .failure(let error): + XCTAssertTrue(error.message.contains("cannot be used together")) + default: + XCTFail("Expected conflicting smoke commands to fail") + } + + switch SystemExtensionSmokeCommand.parse( + arguments: ["Lantern", "--smoke-system-extension-status", "--timeout-seconds", "0"] + ) { + case .failure(let error): + XCTAssertTrue(error.message.contains("positive number")) + default: + XCTFail("Expected invalid timeout to fail") + } + + switch SystemExtensionSmokeCommand.parse( + arguments: [ + "Lantern", + "--smoke-system-extension-status", + "--timeout-seconds", + "30", + "--timeout-seconds=45", + ] + ) { + case .failure(let error): + XCTAssertTrue(error.message.contains("can only be set once")) + default: + XCTFail("Expected duplicate timeout arguments to fail") + } + + switch SystemExtensionSmokeCommand.parse( + arguments: ["Lantern", "--smoke-system-extension-status", "--timeout-seconds"] + ) { + case .failure(let error): + XCTAssertTrue(error.message.contains("needs a value")) + default: + XCTFail("Expected missing timeout value to fail") + } + + switch SystemExtensionSmokeCommand.parse( + arguments: ["Lantern", "--smoke-system-extension-status", "--timeout-seconds", "abc"] + ) { + case .failure(let error): + XCTAssertTrue(error.message.contains("positive number")) + default: + XCTFail("Expected non-numeric timeout to fail") + } + + switch SystemExtensionSmokeCommand.parse( + arguments: [ + "Lantern", + "--smoke-system-extension-status", + "--timeout-seconds", + "30", + "--timeout-seconds", + "45", + ] + ) { + case .failure(let error): + XCTAssertTrue(error.message.contains("can only be set once")) + default: + XCTFail("Expected duplicate space-form timeout arguments to fail") + } + + switch SystemExtensionSmokeCommand.parse( + arguments: [ + "Lantern", + "--smoke-system-extension-status", + "--timeout-seconds=30", + "--timeout-seconds=45", + ] + ) { + case .failure(let error): + XCTAssertTrue(error.message.contains("can only be set once")) + default: + XCTFail("Expected duplicate equals-form timeout arguments to fail") + } + + switch SystemExtensionSmokeCommand.parse( + arguments: [ + "Lantern", + "--smoke-system-extension-status", + "--timeout-seconds", + "--other-flag", + ] + ) { + case .failure(let error): + XCTAssertTrue(error.message.contains("needs a value")) + default: + XCTFail("Expected timeout followed by a flag to fail as a missing value") + } + } + + func testSystemExtensionSmokeResultUsesDistinctActivationExitCodes() { + XCTAssertEqual( + SystemExtensionSmokeResult( + action: .activate, + status: .activated, + timeout: 120 + ).exitCode, + 0 + ) + XCTAssertEqual( + SystemExtensionSmokeResult( + action: .activate, + status: .requiresApproval, + timeout: 120 + ).exitCode, + 20 + ) + XCTAssertEqual( + SystemExtensionSmokeResult( + action: .activate, + status: .requiresReboot(details: "restart required"), + timeout: 120 + ).exitCode, + 21 + ) + XCTAssertEqual( + SystemExtensionSmokeResult( + action: .activate, + status: .timedOut, + timeout: 120 + ).exitCode, + 124 + ) + XCTAssertEqual( + SystemExtensionSmokeResult( + action: .activate, + status: .installed, + timeout: 120 + ).exitCode, + 0 + ) + XCTAssertEqual( + SystemExtensionSmokeResult( + action: .activate, + status: .error("activation failed"), + timeout: 120 + ).exitCode, + 1 + ) + XCTAssertEqual( + SystemExtensionSmokeResult( + action: .status, + status: .notInstalled, + timeout: 120 + ).exitCode, + 0 + ) + XCTAssertEqual( + SystemExtensionSmokeResult( + action: .status, + status: .error("status failed"), + timeout: 120 + ).exitCode, + 1 + ) + } + + func testSystemExtensionSmokeResultWritesStructuredJSON() throws { + let result = SystemExtensionSmokeResult( + action: .activate, + status: .requiresReboot(details: "restart required"), + timeout: 45 + ) + + let data = try XCTUnwrap(result.jsonLine.data(using: .utf8)) + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + XCTAssertEqual(payload["action"] as? String, "activate") + XCTAssertEqual(payload["status"] as? String, "requiresReboot") + XCTAssertEqual(payload["details"] as? String, "restart required") + XCTAssertEqual(payload["exitCode"] as? Int, 21) + } + + func testSystemExtensionSmokeResultIncludesTimeoutSecondsWhenTimedOut() throws { + let result = SystemExtensionSmokeResult( + action: .activate, + status: .timedOut, + timeout: 45 + ) + + let data = try XCTUnwrap(result.jsonLine.data(using: .utf8)) + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + XCTAssertEqual(payload["timeoutSeconds"] as? Double, 45) + } + private func makeDescriptor( build: String, hash: String,