diff --git a/.github/scripts/macos_payment_checkout_smoke.sh b/.github/scripts/macos_payment_checkout_smoke.sh index 2d78afff21..a9d18eccb7 100644 --- a/.github/scripts/macos_payment_checkout_smoke.sh +++ b/.github/scripts/macos_payment_checkout_smoke.sh @@ -17,6 +17,12 @@ cleanup() { exit "$status" } +# Refuse outside CI: this wipes real Lantern app data. +if [[ -z "${CI:-}" ]]; then + echo "Refusing to run outside CI: this wipes $LANTERN_DATA_DIR" >&2 + exit 1 +fi + trap cleanup EXIT pkill -x Lantern 2>/dev/null || true @@ -24,13 +30,15 @@ rm -rf "$LANTERN_DATA_DIR" mkdir -p "$LANTERN_LOG_DIR" mkdir -p "$ARTIFACT_DIR" +# Marker makes the native Radiance setup pick staging (FilePath.isRadianceEnv). +touch "$LANTERN_DATA_DIR/.radiance_env" + flutter test \ "$TEST_PATH" \ -d macos \ --reporter=expanded \ --dart-define=DISABLE_SYSTEM_TRAY=true \ - --dart-define=PAYMENT_SMOKE_SCREENSHOT_PATH="$SCREENSHOT_PATH" \ - --dart-define=RADIANCE_ENV=staging + --dart-define=PAYMENT_SMOKE_SCREENSHOT_PATH="$SCREENSHOT_PATH" if [[ ! -s "$SCREENSHOT_PATH" ]]; then echo "Stripe Checkout screenshot was not created" >&2 diff --git a/.github/scripts/macos_smoke_suite.sh b/.github/scripts/macos_smoke_suite.sh index 636363149a..07f5c731ba 100755 --- a/.github/scripts/macos_smoke_suite.sh +++ b/.github/scripts/macos_smoke_suite.sh @@ -203,6 +203,8 @@ capture_diagnostics() { } >"$ARTIFACT_DIR/diagnostics.txt" capture_command "systemextensionsctl-list" systemextensionsctl list + capture_command "csrutil-status" csrutil status + capture_command "systemextensionsctl-developer" systemextensionsctl developer capture_command "process-list" ps aux capture_command "packet-tunnel-processes" pgrep -fl "org.getlantern.lantern.PacketTunnel" capture_lantern_logs @@ -263,6 +265,25 @@ run_with_timeout() { return "$exit_code" } +enable_system_extension_developer_mode() { + # The Flutter connect smoke runs from the build directory, and macOS only + # activates system extensions from /Applications unless developer mode is + # on. Idempotent; needs root. + log_step "SIP status: $(csrutil status 2>&1 || true)" + local dev_state + dev_state="$(systemextensionsctl developer 2>&1 || true)" + log_step "System extension developer mode state: $dev_state" + if echo "$dev_state" | grep -qiE 'mode is on|mode: on|enabled'; then + log_step "System extension developer mode already enabled" + return 0 + fi + if sudo -n systemextensionsctl developer on 2>/dev/null; then + log_step "Enabled system extension developer mode" + else + printf 'WARNING: could not enable system extension developer mode (needs passwordless sudo). The Flutter connect smoke cannot activate its bundled extension without it. Run once on this runner: sudo systemextensionsctl developer on\n' >&2 + fi +} + run_system_extension_preflight() { local app_executable="$1" local output="$ARTIFACT_DIR/system-extension-preflight.jsonl" @@ -348,6 +369,7 @@ if [[ ! -x "$app_executable" ]]; then fi if [[ "$RUN_CONNECT_SMOKE" == "true" ]]; then + enable_system_extension_developer_mode run_system_extension_preflight "$app_executable" run_flutter_connect_smoke else diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 79d541e0db..77a1f82e87 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -195,6 +195,15 @@ jobs: -RunConnectSmoke:$false ` -RunPaymentCheckoutSmoke + - name: Upload Windows payment smoke diagnostics + if: ${{ always() && inputs.run_payment_checkout_smoke }} + uses: actions/upload-artifact@v4 + with: + name: windows-payment-checkout-smoke + path: C:\Users\Public\Lantern\logs + if-no-files-found: ignore + retention-days: 2 + - name: Sign embedded binaries if: ${{ !inputs.skip_signing }} shell: pwsh diff --git a/integration_test/payment/desktop_stripe_checkout_smoke_test.dart b/integration_test/payment/desktop_stripe_checkout_smoke_test.dart index d8afaeaff3..12db4da92b 100644 --- a/integration_test/payment/desktop_stripe_checkout_smoke_test.dart +++ b/integration_test/payment/desktop_stripe_checkout_smoke_test.dart @@ -6,14 +6,14 @@ import 'dart:ui' as ui; 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/common/common.dart'; import 'package:lantern/core/widgets/app_webview.dart'; -import 'package:lantern/features/plans/provider/plans_notifier.dart'; -import 'package:lantern/lantern_app.dart'; import 'package:lantern/main.dart' as app; +import '../utils/app_robot.dart'; +import '../utils/payment_robot.dart'; + const _stripeHost = 'checkout.stripe.com'; const _screenshotPath = String.fromEnvironment('PAYMENT_SMOKE_SCREENSHOT_PATH'); const _screenshotRenderTimeout = Duration(seconds: 30); @@ -34,21 +34,11 @@ void main() { ); await app.main(); - await _waitFor( - tester, - () => find.byType(LanternApp).evaluate().isNotEmpty, - timeout: const Duration(seconds: 30), - failure: () => 'Lantern did not render its Flutter UI', - ); - - final appElement = find.byType(LanternApp).evaluate().first; - final container = ProviderScope.containerOf(appElement, listen: false); - final plansSubscription = container.listen(plansProvider, (_, _) {}); - addTearDown(plansSubscription.close); + final robot = AppRobot(tester); + final payment = PaymentRobot(tester, robot); + await robot.waitForHomeReady(); - final plans = await container - .read(plansProvider.future) - .timeout(const Duration(seconds: 60)); + final plans = await payment.loadPlans(); final stripe = plans.providers.desktop.where( (provider) => provider.providers.name == 'stripe', ); @@ -59,60 +49,33 @@ void main() { reason: 'Staging Stripe must support subscriptions', ); expect(plans.plans, isNotEmpty, reason: 'Staging returned no plans'); - - final selectedPlan = plans.plans.firstWhere( - (plan) => plan.bestValue, - orElse: () => plans.plans.first, + payment.selectBestValuePlan(plans); + + final checkout = _StripeCheckoutTracker(); + final pageEventSubscription = payment.container.listen( + webViewPageEventProvider, + (_, event) { + if (event != null) unawaited(checkout.add(event)); + }, ); - container.read(plansProvider.notifier).setSelectedPlan(selectedPlan); - - final observer = _StripeCheckoutObserver(); - await appRouter.replaceAll([ - ChoosePaymentMethod( - email: 'e2e+${_newUuid()}@getlantern.org', - authFlow: AuthFlow.renewSubscription, - checkoutObserver: observer, - ), - ]); + addTearDown(pageEventSubscription.close); - final stripeProvider = find.byKey(const Key('payment.provider.stripe')); - await _waitFor( - tester, - () => stripeProvider.evaluate().isNotEmpty, - timeout: const Duration(seconds: 30), - failure: () => 'Stripe was not shown on the payment-method screen', + await payment.openPaymentMethods( + email: e2eEmail(), + authFlow: AuthFlow.renewSubscription, ); + await payment.startStripeCheckout(); - final checkoutButton = find.byKey(const Key('payment.checkout.stripe')); - if (checkoutButton.evaluate().isEmpty) { - await tester.tap(stripeProvider); - await tester.pump(const Duration(milliseconds: 300)); - } - await _waitFor( - tester, - () => checkoutButton.evaluate().isNotEmpty, - timeout: const Duration(seconds: 10), - failure: () => 'Stripe checkout button was not available', - ); + await _waitForCheckout(tester, checkout); - await tester.ensureVisible(checkoutButton); - await tester.tap(checkoutButton); - - await _waitFor( - tester, - () => observer.finished, - timeout: const Duration(minutes: 3), - failure: () => observer.failureMessage, - ); - - if (observer.checkoutFailure != null) { - fail('Stripe Checkout failed to load: ${observer.checkoutFailure}'); + if (checkout.failure != null) { + fail('Stripe Checkout failed to load: ${checkout.failure}'); } expect(find.byKey(const ValueKey('app-webview')), findsOneWidget); - expect(observer.uri?.host, _stripeHost); - expect(observer.documentLength, greaterThan(0)); + expect(checkout.uri?.host, _stripeHost); + expect(checkout.documentLength, greaterThan(0)); if (Platform.isMacOS) { - final screenshot = observer.screenshot; + final screenshot = checkout.screenshot; if (screenshot == null) { fail('The Stripe WebView did not return a screenshot'); } @@ -120,18 +83,32 @@ void main() { final file = File(_screenshotPath); await file.parent.create(recursive: true); await file.writeAsBytes(screenshot, flush: true); - debugPrint('Stripe Checkout screenshot saved to ${file.path}'); + e2eLog('Stripe Checkout screenshot saved to ${file.path}'); } } - debugPrint( + e2eLog( 'Stripe Checkout rendered from $_stripeHost ' - '(${observer.documentLength} document characters)', + '(${checkout.documentLength} document characters)', ); }, timeout: const Timeout(Duration(minutes: 5)), ); } +/// Waits until the tracker sees Stripe load or fail — a state wait, not a +/// widget wait, so the robot's finder-based helpers don't apply. +Future _waitForCheckout( + WidgetTester tester, + _StripeCheckoutTracker checkout, +) async { + final deadline = DateTime.now().add(const Duration(minutes: 3)); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 200)); + if (checkout.finished) return; + } + fail(checkout.failureMessage); +} + Future _waitForRenderedScreenshot( Future Function() captureScreenshot, ) async { @@ -197,69 +174,49 @@ Future _hasVisibleContent(Uint8List screenshot) async { } } -Future _waitFor( - WidgetTester tester, - bool Function() condition, { - required Duration timeout, - required String Function() failure, -}) async { - final deadline = DateTime.now().add(timeout); - while (DateTime.now().isBefore(deadline)) { - await tester.pump(const Duration(milliseconds: 200)); - if (condition()) return; - } - fail(failure()); -} - -String _newUuid() { - final random = Random.secure(); - final bytes = List.generate(16, (_) => random.nextInt(256)); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - final hex = bytes - .map((byte) => byte.toRadixString(16).padLeft(2, '0')) - .join(); - return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' - '${hex.substring(12, 16)}-${hex.substring(16, 20)}-' - '${hex.substring(20)}'; -} - -class _StripeCheckoutObserver implements AppWebViewObserver { +/// Folds [webViewPageEventProvider] events into a checkout verdict. +class _StripeCheckoutTracker { Uri? uri; int documentLength = 0; Uint8List? screenshot; String? lastFailure; - String? checkoutFailure; + String? _terminalFailure; - bool get finished => uri?.host == _stripeHost || checkoutFailure != null; + bool get loaded => uri?.host == _stripeHost; + + bool get finished => loaded || _terminalFailure != null; + + /// Non-null once checkout can no longer succeed. + String? get failure => _terminalFailure; String get failureMessage => lastFailure == null ? 'Stripe Checkout did not load a non-empty document' : 'Stripe Checkout did not load: $lastFailure'; - @override - Future onPageLoaded( - Uri uri, { - required int documentLength, - required Future Function() captureScreenshot, - }) async { - if (uri.host != _stripeHost) return; - if (Platform.isMacOS) { - try { - screenshot = await _waitForRenderedScreenshot(captureScreenshot); - } catch (error) { - checkoutFailure = 'Unable to capture WebView screenshot: $error'; - } - } - this.uri = uri; - this.documentLength = documentLength; - } - - @override - void onPageLoadFailed(Uri? uri, String reason) { - lastFailure = '${uri?.host ?? 'unknown host'}: $reason'; - if (uri?.host == _stripeHost) { - checkoutFailure = reason; + Future add(WebViewPageEvent event) async { + switch (event) { + case WebViewPageLoaded(): + if (event.uri?.host != _stripeHost) return; + if (Platform.isMacOS && screenshot == null) { + try { + screenshot = await _waitForRenderedScreenshot( + event.captureScreenshot, + ); + } catch (error) { + _terminalFailure = 'Unable to capture WebView screenshot: $error'; + return; + } + } + uri = event.uri; + documentLength = event.documentLength; + case WebViewPageLoadFailed(): + lastFailure = '${event.uri?.host ?? 'unknown host'}: ${event.reason}'; + // Only a main-frame failure on the Stripe host itself is terminal — + // intermediate redirect hosts can fail benignly before Checkout + // loads. Other failures are kept as context for the timeout message. + if (event.uri?.host == _stripeHost) { + _terminalFailure = lastFailure; + } } } } diff --git a/integration_test/utils/app_robot.dart b/integration_test/utils/app_robot.dart index cefb6a238a..1944ca2728 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'; @@ -10,7 +15,8 @@ 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`. +/// Used on Android and desktop; the VPN smokes still use +/// `vpn/vpn_smoke_helpers.dart`. class AppRobot { AppRobot(this.tester); @@ -20,6 +26,46 @@ 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'), + ); + + /// Saves a PNG of the Flutter view as `.png` under + /// `/screenshots` — the log dir already lands in CI + /// diagnostics artifacts on desktop. Name captures by scenario and step + /// (e.g. `connect-smoke-baseline`) so the file says where in the test it + /// was taken; a repeated name overwrites, keeping the latest state. + /// Diagnostics only: never fails the test, and captures the Flutter layer + /// tree (native views like the WebView render as blanks). + 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 dir = Directory( + '${await AppStorageUtils.getAppLogDirectory()}/screenshots', + ); + await dir.create(recursive: true); + final file = File('${dir.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'); + } + } /// 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 +98,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 +107,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 +124,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 +146,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 +161,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 +226,10 @@ 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], taps it, and lets the navigation settle. Don't use + /// this for taps that start an unbounded animation (e.g. a loading spinner): + /// pumpAndSettle would hang until its timeout. + Future tap(Finder target, {required String name}) async { e2eLog('Tapping $name'); await WidgetWaitUtils.waitForFinder( tester, @@ -244,6 +292,44 @@ class AppRobot { return true; } + /// Dismisses the macOS system-extension screen if shown. On a fresh data + /// dir it covers home until the extension is installed/activated; tests + /// that don't exercise the VPN can safely close it. + 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 tapDeadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(tapDeadline)) { + 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 +354,10 @@ class AppRobot { await dismissOnboardingIfShown(); continue; } + if (macosExtensionScreen.evaluate().isNotEmpty) { + await dismissMacOSExtensionScreenIfShown(); + continue; + } if (control.hitTestable().evaluate().isNotEmpty) { return; diff --git a/integration_test/utils/payment_robot.dart b/integration_test/utils/payment_robot.dart new file mode 100644 index 0000000000..a3e4eb0f3c --- /dev/null +++ b/integration_test/utils/payment_robot.dart @@ -0,0 +1,115 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/plan_data.dart'; +import 'package:lantern/features/plans/provider/plans_notifier.dart'; + +import 'app_robot.dart'; + +/// Payment feature robot. Selects a plan through [plansProvider] — the +/// source of truth — and drives the payment-method screen through the UI. +class PaymentRobot { + PaymentRobot(this.tester, this.app); + + final WidgetTester tester; + final AppRobot app; + + final Finder stripeProvider = find.byKey( + const Key('payment.provider.stripe'), + ); + final Finder stripeCheckoutButton = find.byKey( + const Key('payment.checkout.stripe'), + ); + + ProviderContainer? _cachedContainer; + + /// Root [ProviderContainer], resolved once from a mounted home element and + /// cached; it outlives route changes so later reads are route-independent. + ProviderContainer get container { + final cached = _cachedContainer; + if (cached != null) { + return cached; + } + final elements = app.homeScreen.evaluate(); + if (elements.isEmpty) { + fail('Cannot resolve providers: home screen is not mounted'); + } + return _cachedContainer = ProviderScope.containerOf( + elements.first, + listen: false, + ); + } + + /// Loads plans from the backend, keeping the provider alive for the rest + /// of the test. + Future loadPlans({ + Duration timeout = const Duration(seconds: 60), + }) { + final subscription = container.listen(plansProvider, (_, _) {}); + addTearDown(subscription.close); + return container.read(plansProvider.future).timeout(timeout); + } + + /// Selects the best-value plan (falling back to the first) and returns it. + Plan selectBestValuePlan(PlansData plans) { + final plan = plans.plans.firstWhere( + (plan) => plan.bestValue, + orElse: () => plans.plans.first, + ); + container.read(plansProvider.notifier).setSelectedPlan(plan); + e2eLog('Selected plan ${plan.id}'); + return plan; + } + + /// Jumps straight to the payment-method screen for [email], replacing the + /// whole stack — checkout smokes verify rendering, not navigation. + Future openPaymentMethods({ + required String email, + required AuthFlow authFlow, + }) { + return appRouter.replaceAll([ + ChoosePaymentMethod(email: email, authFlow: authFlow), + ]); + } + + /// Expands the Stripe method if collapsed and taps its checkout button. + /// The tap is not settled: it opens a WebView with a loading spinner and + /// pumpAndSettle would hang on the animation. + Future startStripeCheckout() async { + await app.waitForControlReady( + stripeProvider, + controlName: 'Stripe payment method', + ); + if (stripeCheckoutButton.evaluate().isEmpty) { + e2eLog('Expanding the Stripe payment method'); + await tester.tap(stripeProvider); + await tester.pump(const Duration(milliseconds: 300)); + } + await app.waitForControlReady( + stripeCheckoutButton, + controlName: 'Stripe checkout button', + ); + e2eLog('Tapping the Stripe checkout button'); + await tester.ensureVisible(stripeCheckoutButton); + await tester.tap(stripeCheckoutButton); + } +} + +/// Unique throwaway address for checkout smokes: `e2e+@getlantern.org`. +String e2eEmail() => 'e2e+${_newUuid()}@getlantern.org'; + +String _newUuid() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-${hex.substring(16, 20)}-' + '${hex.substring(20)}'; +} diff --git a/integration_test/vpn/connect_smoke_harness.dart b/integration_test/vpn/connect_smoke_harness.dart index 96e26a334d..f928ccc514 100644 --- a/integration_test/vpn/connect_smoke_harness.dart +++ b/integration_test/vpn/connect_smoke_harness.dart @@ -2,20 +2,14 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/app_eum.dart'; -import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; -import 'vpn_smoke_helpers.dart'; +import '../utils/app_robot.dart'; +import '../utils/vpn_robot.dart'; -const _vpnStateLabels = { - VPNStatus.connected: 'Connected', - VPNStatus.disconnected: 'Disconnected', - VPNStatus.connecting: 'Connecting', - VPNStatus.disconnecting: 'Disconnecting', - VPNStatus.missingPermission: 'MissingPermission', - VPNStatus.error: 'Error', -}; +/// Desktop connect/disconnect smoke harness. App shell via [AppRobot], VPN +/// state via [VpnRobot] (which listens to vpnProvider) — same structure as +/// the Android harness in android_connect_smoke_harness.dart. const _ipCheckEndpoint = 'https://api64.ipify.org'; const _forceFullTunnelForSmoke = bool.fromEnvironment( @@ -23,47 +17,6 @@ const _forceFullTunnelForSmoke = bool.fromEnvironment( defaultValue: false, ); -Future _setRoutingModeToFullTunnelForSmoke( - WidgetTester tester, { - required VpnSmokeFinders finders, -}) async { - if (!_forceFullTunnelForSmoke) { - return; - } - - final homeElements = finders.homeScreen.evaluate(); - if (homeElements.isEmpty) { - fail('Home screen not found while applying smoke routing mode override'); - } - - final container = ProviderScope.containerOf( - homeElements.first, - listen: false, - ); - debugPrint('SMOKE_FORCE_FULL_TUNNEL enabled; switching to full tunnel mode'); - final result = await container - .read(radianceSettingsProvider.notifier) - .setRoutingMode(RoutingMode.full); - - result.fold( - (failure) => fail( - 'Failed to switch routing mode to full tunnel for smoke: $failure', - ), - (_) {}, - ); - - final deadline = DateTime.now().add(const Duration(seconds: 20)); - while (DateTime.now().isBefore(deadline)) { - await tester.pump(const Duration(milliseconds: 200)); - final updatedMode = container.read(radianceSettingsProvider).routingMode; - if (updatedMode == RoutingMode.full) { - return; - } - } - - fail('Routing mode did not settle to full tunnel for smoke'); -} - Future _fetchPublicIpOnce() async { final client = HttpClient()..connectionTimeout = const Duration(seconds: 6); try { @@ -117,45 +70,34 @@ Future _didPublicIpChangeFromBaseline(String baselineIp) async { return false; } -Future _disconnectVpn( - WidgetTester tester, { - required Finder vpnToggle, - required VpnStateFinders vpnStateFinders, -}) async { - final currentState = vpnStateFinders.current(); - if (currentState != VPNStatus.connected && - currentState != VPNStatus.connecting) { - return; - } - - await tester.tap(vpnToggle); - await tester.pump(const Duration(milliseconds: 200)); - - await vpnStateFinders.waitFor( - tester, - expected: const [VPNStatus.disconnected], - timeout: const Duration(seconds: 45), - reason: 'VPN did not return to disconnected state within 45 seconds', - ); -} - Future runConnectSmokeHarness( WidgetTester tester, { bool enableIpCheck = false, bool requireTrafficAfterConnect = false, }) async { - final finders = VpnSmokeFinders(); - final vpnStateFinders = VpnStateFinders(textLabels: _vpnStateLabels); - String? baselinePublicIp; + final app = AppRobot(tester); + final vpn = VpnRobot(tester, app); + + try { + await app.waitForControlReady(vpn.vpnToggle, controlName: 'VPN toggle'); + await vpn.ensureDisconnected(); + } catch (_) { + await app.captureScreenshot('connect-smoke-setup-failure'); + rethrow; + } + await app.captureScreenshot('connect-smoke-baseline'); - await prepareVpnStartsDisconnectedForSmoke( - tester, - finders: finders, - vpnStateFinders: vpnStateFinders, - scenario: 'connect/disconnect smoke', - ); - await _setRoutingModeToFullTunnelForSmoke(tester, finders: finders); + vpn.beginErrorWatch(); + addTearDown(vpn.assertNoErrorsSeen); + if (_forceFullTunnelForSmoke) { + debugPrint( + 'SMOKE_FORCE_FULL_TUNNEL enabled; switching to full tunnel mode', + ); + await vpn.setRoutingMode(RoutingMode.full); + } + + String? baselinePublicIp; if (enableIpCheck) { debugPrint('IP check: enabled; fetching baseline before connect'); baselinePublicIp = await _fetchPublicIpWithRetry( @@ -166,15 +108,8 @@ Future runConnectSmokeHarness( var ipChanged = true; try { - await tester.tap(finders.vpnToggle); - await tester.pump(const Duration(milliseconds: 200)); - - await vpnStateFinders.waitFor( - tester, - expected: const [VPNStatus.connected], - timeout: const Duration(seconds: 45), - reason: 'VPN did not reach connected state within 45 seconds', - ); + await vpn.connect(); + await app.captureScreenshot('connect-smoke-connected'); if (requireTrafficAfterConnect) { debugPrint('IP check: confirming public traffic after connect'); @@ -192,12 +127,13 @@ Future runConnectSmokeHarness( debugPrint('IP check: passed'); } } + } catch (_) { + // Capture the failing screen before the disconnect cleanup changes it. + await app.captureScreenshot('connect-smoke-failure'); + rethrow; } finally { - await _disconnectVpn( - tester, - vpnToggle: finders.vpnToggle, - vpnStateFinders: vpnStateFinders, - ); + await vpn.disconnectIfNeeded(); + await app.captureScreenshot('connect-smoke-disconnected'); } if (enableIpCheck && baselinePublicIp != null && !ipChanged) { diff --git a/integration_test/vpn/macos_connect_smoke_test.dart b/integration_test/vpn/macos_connect_smoke_test.dart index 175b09abfe..873bc07ea1 100644 --- a/integration_test/vpn/macos_connect_smoke_test.dart +++ b/integration_test/vpn/macos_connect_smoke_test.dart @@ -53,7 +53,8 @@ Future _requireSystemExtensionReady(WidgetTester tester) async { finders.onboardingScreen, ]); var state = container.read(macosExtensionProvider); - final end = DateTime.now().add(const Duration(seconds: 45)); + final end = DateTime.now().add(const Duration(seconds: 120)); + var activationRequested = false; while (DateTime.now().isBefore(end)) { if (state.isReady) { @@ -65,6 +66,32 @@ Future _requireSystemExtensionReady(WidgetTester tester) async { fail(_systemExtensionDebugMessage(tester, state)); } + // The test bundle's extension never hash-matches the installed app's, + // so a fresh run reports updatePending. Request activation — the same + // call as the dialog's Install button. This only succeeds when + // system-extension developer mode is enabled on the runner (the bundle + // runs from the build dir, not /Applications); the smoke suite enables + // it before launching this test. + if (!activationRequested && + (state.status == SystemExtensionStatus.updatePending || + state.status == SystemExtensionStatus.notInstalled)) { + activationRequested = true; + debugPrint( + 'macOS smoke: requesting system extension activation ' + '(${state.status.name})', + ); + final result = await container + .read(macosExtensionProvider.notifier) + .triggerSystemExtensionInstallation(); + result.fold( + (failure) => debugPrint( + 'macOS smoke: extension activation request failed: ' + '${failure.error}', + ), + (message) => debugPrint('macOS smoke: activation requested: $message'), + ); + } + await tester.pump(const Duration(milliseconds: 300)); state = container.read(macosExtensionProvider); } @@ -97,5 +124,8 @@ String _systemExtensionDebugMessage( return 'macOS system extension was not ready before connect: ' '${state.status.name}.$details ' 'Status key visible: $statusKeyVisible. ' - 'Visible keyed widgets: ${collectVisibleSmokeDebugKeys(tester)}'; + 'Visible keyed widgets: ${collectVisibleSmokeDebugKeys(tester)}. ' + 'If activation failed with "must be in /Applications", enable ' + 'system-extension developer mode on this runner: ' + 'sudo systemextensionsctl developer on'; } diff --git a/integration_test/vpn/vpn_smoke_helpers.dart b/integration_test/vpn/vpn_smoke_helpers.dart index 1c3fefedc1..7998b4c652 100644 --- a/integration_test/vpn/vpn_smoke_helpers.dart +++ b/integration_test/vpn/vpn_smoke_helpers.dart @@ -27,6 +27,9 @@ class VpnSmokeFinders { 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'), + ); final Finder vpnToggle = find.byKey(const Key('vpn.toggle')); } @@ -131,6 +134,7 @@ Future waitForVpnToggleWithOnboardingHandling( required Finder onboardingScreen, required Finder onboardingSkip, required Finder onboardingPrimary, + required Finder macosExtensionScreen, required Duration timeout, }) async { final end = DateTime.now().add(timeout); @@ -145,6 +149,24 @@ Future waitForVpnToggleWithOnboardingHandling( continue; } + // On a fresh data dir, macOS pushes the system-extension screen after + // onboarding; it never auto-closes while the debug bundle reports + // updatePending, so close it — the preflight-activated extension still + // serves the tunnel. + if (macosExtensionScreen.evaluate().isNotEmpty) { + final close = find + .descendant( + of: macosExtensionScreen, + matching: find.byType(CloseButton), + ) + .hitTestable(); + if (close.evaluate().isNotEmpty) { + await tester.tap(close); + } + await tester.pump(const Duration(milliseconds: 400)); + continue; + } + if (vpnToggle.hitTestable().evaluate().isNotEmpty) { return; } @@ -165,20 +187,18 @@ Future waitForHomeReadyForVpnSmoke( reason: 'Home or onboarding did not appear after launch', ); - await WidgetWaitUtils.waitForFinder( - tester, - finders.homeScreen, - timeout: const Duration(seconds: 45), - reason: 'Home screen did not load', - ); - + // No strict wait for home here: find.byKey skips offstage widgets, and + // home is offstage while onboarding (or the macOS extension screen) + // covers it on a fresh data dir. The toggle wait below clears those + // overlays and is the real ready gate. await waitForVpnToggleWithOnboardingHandling( tester, vpnToggle: finders.vpnToggle, onboardingScreen: finders.onboardingScreen, onboardingSkip: finders.onboardingSkip, onboardingPrimary: finders.onboardingPrimary, - timeout: const Duration(seconds: 30), + macosExtensionScreen: finders.macosExtensionScreen, + timeout: const Duration(seconds: 45), ); await WidgetWaitUtils.waitForFinderToDisappear( @@ -221,6 +241,7 @@ Future resolveInitialStableVpnStateForSmoke( onboardingScreen: finders.onboardingScreen, onboardingSkip: finders.onboardingSkip, onboardingPrimary: finders.onboardingPrimary, + macosExtensionScreen: finders.macosExtensionScreen, timeout: const Duration(seconds: 15), ); diff --git a/lib/core/router/router.gr.dart b/lib/core/router/router.gr.dart index ebdc081aa4..b7edcc14fb 100644 --- a/lib/core/router/router.gr.dart +++ b/lib/core/router/router.gr.dart @@ -155,16 +155,10 @@ class AppWebview extends _i44.PageRouteInfo { _i45.Key? key, required String title, required String url, - _i3.AppWebViewObserver? observer, List<_i44.PageRouteInfo>? children, }) : super( AppWebview.name, - args: AppWebviewArgs( - key: key, - title: title, - url: url, - observer: observer, - ), + args: AppWebviewArgs(key: key, title: title, url: url), initialChildren: children, ); @@ -174,23 +168,13 @@ class AppWebview extends _i44.PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return _i3.AppWebView( - key: args.key, - title: args.title, - url: args.url, - observer: args.observer, - ); + return _i3.AppWebView(key: args.key, title: args.title, url: args.url); }, ); } class AppWebviewArgs { - const AppWebviewArgs({ - this.key, - required this.title, - required this.url, - this.observer, - }); + const AppWebviewArgs({this.key, required this.title, required this.url}); final _i45.Key? key; @@ -198,26 +182,20 @@ class AppWebviewArgs { final String url; - final _i3.AppWebViewObserver? observer; - @override String toString() { - return 'AppWebviewArgs{key: $key, title: $title, url: $url, observer: $observer}'; + return 'AppWebviewArgs{key: $key, title: $title, url: $url}'; } @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! AppWebviewArgs) return false; - return key == other.key && - title == other.title && - url == other.url && - observer == other.observer; + return key == other.key && title == other.title && url == other.url; } @override - int get hashCode => - key.hashCode ^ title.hashCode ^ url.hashCode ^ observer.hashCode; + int get hashCode => key.hashCode ^ title.hashCode ^ url.hashCode; } /// generated route for @@ -260,7 +238,6 @@ class ChoosePaymentMethod extends _i44.PageRouteInfo { required String email, String? code, required _i46.AuthFlow authFlow, - _i3.AppWebViewObserver? checkoutObserver, List<_i44.PageRouteInfo>? children, }) : super( ChoosePaymentMethod.name, @@ -269,7 +246,6 @@ class ChoosePaymentMethod extends _i44.PageRouteInfo { email: email, code: code, authFlow: authFlow, - checkoutObserver: checkoutObserver, ), initialChildren: children, ); @@ -285,7 +261,6 @@ class ChoosePaymentMethod extends _i44.PageRouteInfo { email: args.email, code: args.code, authFlow: args.authFlow, - checkoutObserver: args.checkoutObserver, ); }, ); @@ -297,7 +272,6 @@ class ChoosePaymentMethodArgs { required this.email, this.code, required this.authFlow, - this.checkoutObserver, }); final _i45.Key? key; @@ -308,11 +282,9 @@ class ChoosePaymentMethodArgs { final _i46.AuthFlow authFlow; - final _i3.AppWebViewObserver? checkoutObserver; - @override String toString() { - return 'ChoosePaymentMethodArgs{key: $key, email: $email, code: $code, authFlow: $authFlow, checkoutObserver: $checkoutObserver}'; + return 'ChoosePaymentMethodArgs{key: $key, email: $email, code: $code, authFlow: $authFlow}'; } @override @@ -322,17 +294,12 @@ class ChoosePaymentMethodArgs { return key == other.key && email == other.email && code == other.code && - authFlow == other.authFlow && - checkoutObserver == other.checkoutObserver; + authFlow == other.authFlow; } @override int get hashCode => - key.hashCode ^ - email.hashCode ^ - code.hashCode ^ - authFlow.hashCode ^ - checkoutObserver.hashCode; + key.hashCode ^ email.hashCode ^ code.hashCode ^ authFlow.hashCode; } /// generated route for diff --git a/lib/core/utils/url_utils.dart b/lib/core/utils/url_utils.dart index 0ab3be5f8d..45cf91210d 100644 --- a/lib/core/utils/url_utils.dart +++ b/lib/core/utils/url_utils.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:lantern/core/common/common.dart'; -import 'package:lantern/core/widgets/app_webview.dart'; import 'package:url_launcher/url_launcher.dart'; class UrlUtils { @@ -126,7 +125,6 @@ class UrlUtils { String url, { String? title, Function(T)? onWebviewResult, - AppWebViewObserver? observer, }) async { try { final normalizedUrl = normalizeWebviewUrl(url); @@ -141,11 +139,7 @@ class UrlUtils { case 'macos': case 'windows': final result = await appRouter.push( - AppWebview( - title: title ?? '', - url: normalizedUrl, - observer: observer, - ), + AppWebview(title: title ?? '', url: normalizedUrl), ); if (result != null) { onWebviewResult?.call(result); diff --git a/lib/core/widgets/app_webview.dart b/lib/core/widgets/app_webview.dart index 398699b034..be5734b1e5 100644 --- a/lib/core/widgets/app_webview.dart +++ b/lib/core/widgets/app_webview.dart @@ -12,15 +12,44 @@ final webViewLoadingProvider = NotifierProvider( WebViewLoading.new, ); -/// Receives main-frame load events from an in-app WebView. -abstract interface class AppWebViewObserver { - Future onPageLoaded( - Uri uri, { - required int documentLength, - required Future Function() captureScreenshot, +/// Last main-frame load event from an in-app WebView. Smoke tests (and any +/// future load-error UI) watch this to observe page loads inside the native +/// view, which widget finders cannot see into. +final webViewPageEventProvider = + NotifierProvider( + WebViewPageEvents.new, + ); + +sealed class WebViewPageEvent { + const WebViewPageEvent(this.uri); + + final Uri? uri; +} + +class WebViewPageLoaded extends WebViewPageEvent { + const WebViewPageLoaded( + Uri super.uri, { + required this.documentLength, + required this.captureScreenshot, }); - void onPageLoadFailed(Uri? uri, String reason); + final int documentLength; + + /// Captures the live WebView content; only valid while the page is up. + final Future Function() captureScreenshot; +} + +class WebViewPageLoadFailed extends WebViewPageEvent { + const WebViewPageLoadFailed(super.uri, this.reason); + + final String reason; +} + +class WebViewPageEvents extends Notifier { + @override + WebViewPageEvent? build() => null; + + void report(WebViewPageEvent event) => state = event; } class WebViewLoading extends Notifier { @@ -36,14 +65,8 @@ class WebViewLoading extends Notifier { class AppWebView extends HookConsumerWidget { final String title; final String url; - final AppWebViewObserver? observer; - const AppWebView({ - super.key, - required this.title, - required this.url, - this.observer, - }); + const AppWebView({super.key, required this.title, required this.url}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -68,7 +91,7 @@ class AppWebView extends HookConsumerWidget { ), body: Stack( children: [ - _InnerWebView(url: url, observer: observer), + _InnerWebView(url: url), if (isLoading) Center(child: LoadingIndicator()), ], ), @@ -78,9 +101,8 @@ class AppWebView extends HookConsumerWidget { class _InnerWebView extends StatefulHookConsumerWidget { final String url; - final AppWebViewObserver? observer; - const _InnerWebView({required this.url, this.observer}); + const _InnerWebView({required this.url}); @override ConsumerState<_InnerWebView> createState() => _InnerWebViewState(); @@ -159,10 +181,14 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> { ref.read(webViewLoadingProvider.notifier).stop(); final uri = Uri.tryParse(webResourceRequest.url.toString()); if (webResourceRequest.isForMainFrame == true) { - widget.observer?.onPageLoadFailed( - uri, - '${error.type}: ${error.description}', - ); + ref + .read(webViewPageEventProvider.notifier) + .report( + WebViewPageLoadFailed( + uri, + '${error.type}: ${error.description}', + ), + ); } await _handleCompletionUrl(uri); }, @@ -170,7 +196,9 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> { if (request.isForMainFrame != true) return; ref.read(webViewLoadingProvider.notifier).stop(); final uri = Uri.tryParse(request.url.toString()); - widget.observer?.onPageLoadFailed(uri, 'HTTP ${response.statusCode}'); + ref + .read(webViewPageEventProvider.notifier) + .report(WebViewPageLoadFailed(uri, 'HTTP ${response.statusCode}')); }, ); } @@ -179,8 +207,7 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> { InAppWebViewController controller, Uri? uri, ) async { - final observer = widget.observer; - if (observer == null || uri == null) return; + if (uri == null) return; try { final value = await controller.evaluateJavascript( @@ -189,28 +216,22 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> { final documentLength = value is num ? value.toInt() : int.tryParse(value?.toString() ?? '') ?? 0; - unawaited(_notifyPageLoaded(observer, controller, uri, documentLength)); + if (!mounted) return; + ref + .read(webViewPageEventProvider.notifier) + .report( + WebViewPageLoaded( + uri, + documentLength: documentLength, + captureScreenshot: () => controller.takeScreenshot(), + ), + ); } catch (error, stackTrace) { appLogger.error('Unable to inspect WebView document', error, stackTrace); - observer.onPageLoadFailed(uri, error.toString()); - } - } - - Future _notifyPageLoaded( - AppWebViewObserver observer, - InAppWebViewController controller, - Uri uri, - int documentLength, - ) async { - try { - await observer.onPageLoaded( - uri, - documentLength: documentLength, - captureScreenshot: () => controller.takeScreenshot(), - ); - } catch (error, stackTrace) { - appLogger.error('Unable to notify WebView observer', error, stackTrace); - observer.onPageLoadFailed(uri, error.toString()); + if (!mounted) return; + ref + .read(webViewPageEventProvider.notifier) + .report(WebViewPageLoadFailed(uri, error.toString())); } } diff --git a/lib/features/auth/choose_payment_method.dart b/lib/features/auth/choose_payment_method.dart index ff5acac6d1..49351f33d2 100644 --- a/lib/features/auth/choose_payment_method.dart +++ b/lib/features/auth/choose_payment_method.dart @@ -10,7 +10,6 @@ import 'package:lantern/core/models/plan_data.dart'; import 'package:lantern/core/models/referral_attach_response.dart'; import 'package:lantern/core/services/injection_container.dart'; import 'package:lantern/core/services/stripe_service.dart'; -import 'package:lantern/core/widgets/app_webview.dart'; import 'package:lantern/core/widgets/logs_path.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/plans/provider/payment_notifier.dart'; @@ -22,14 +21,12 @@ class ChoosePaymentMethod extends HookConsumerWidget { final String email; final String? code; final AuthFlow authFlow; - final AppWebViewObserver? checkoutObserver; const ChoosePaymentMethod({ super.key, required this.email, this.code, required this.authFlow, - this.checkoutObserver, }); @override @@ -332,7 +329,6 @@ class ChoosePaymentMethod extends HookConsumerWidget { final purchaseResult = await UrlUtils.openWebview( normalizedStripeUrl, title: 'stripe_payment'.i18n, - observer: checkoutObserver, ); if (!context.mounted || purchaseResult == null) return; await onPurchaseResult(purchaseResult, context, ref); diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 40661a06d3..c5d46650f3 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -21,7 +21,6 @@ import 'package:lantern/core/services/app_purchase.dart'; import 'package:lantern/core/services/injection_container.dart'; import 'package:lantern/core/utils/app_data_utils.dart'; import 'package:lantern/core/utils/enabled_apps.dart'; -import 'package:lantern/core/utils/radiance_environment.dart'; import 'package:lantern/features/report_issue/models/report_issue_attachment.dart'; import 'package:lantern/lantern/lantern_core_service.dart'; import 'package:lantern/lantern/lantern_ffi_service.dart'; @@ -84,10 +83,6 @@ class LanternPlatformService implements LanternCoreService { _systemExtensionStatus = systemExtensionStatusChannel .receiveBroadcastStream() .map(MacOSExtensionState.fromEvent); - await _methodChannel.invokeMethod( - 'setupRadiance', - await radianceEnvironment(), - ); } // _enabledApps starts as EnabledAppsSnapshot.empty() and is refreshed // lazily inside installedAppsStream / addSplitTunnelItem / etc. diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index f3042270f9..1f22ebb626 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,4 +1,5 @@ import FlutterMacOS +import Liblantern import OSLog import app_links @@ -43,6 +44,8 @@ class AppDelegate: FlutterAppDelegate { registerEventHandlers(controller: controller) + setupRadiance() + // Setup native method channel setupMethodHandler(controller: controller) @@ -103,4 +106,29 @@ class AppDelegate: FlutterAppDelegate { methodHandler = MethodHandler(channel: nativeChannel, vpnManager: vpnManager) } + /// Calls API handler setup + private func setupRadiance() { + let startupTime = Date() + let opts = UtilsOpts() + opts.dataDir = FilePath.dataDirectory.relativePath + opts.logDir = FilePath.logsDirectory.relativePath + opts.deviceid = "" + opts.logLevel = "trace" + opts.telemetryConsent = FilePath.isTelemetryEnabled() + opts.env = FilePath.isRadianceEnv() + opts.locale = Locale.current.identifier + appLogger.info( + "Setting up Radiance in \(opts.env), logging to \(opts.logDir), dataDir: \(opts.dataDir), telemetryConsent: \(opts.telemetryConsent), locale: \(opts.locale)" + ) + var error: NSError? + let success = MobileSetupRadiance(opts, FlutterEventListener.shared, &error) + if let error { + appLogger.error("Error while setting up radiance: \(error)") + } else if !success { + appLogger.error("Radiance setup did not complete") + } else { + appLogger.info("Radiance setup took \(Date().timeIntervalSince(startupTime)) seconds") + } + } + } diff --git a/macos/Runner/Handlers/MethodHandler.swift b/macos/Runner/Handlers/MethodHandler.swift index 7ea4cedd68..54d9a517c3 100644 --- a/macos/Runner/Handlers/MethodHandler.swift +++ b/macos/Runner/Handlers/MethodHandler.swift @@ -15,7 +15,6 @@ class MethodHandler { private var channel: FlutterMethodChannel private var vpnManager: VPNManager - private var isRadianceReady = false init(channel: FlutterMethodChannel, vpnManager: VPNManager = VPNManager.shared) { self.channel = channel @@ -29,15 +28,6 @@ class MethodHandler { guard let self = self else { return } switch call.method { - case "setupRadiance": - guard - let environment: String = self.decodeValue( - from: call.arguments, - result: result - ) - else { return } - self.setupRadiance(environment: environment, result: result) - case "startVPN": self.startVPN(result: result) @@ -393,63 +383,6 @@ class MethodHandler { } } - private func setupRadiance(environment: String, result: @escaping FlutterResult) { - guard environment == "prod" || environment == "stage" else { - result( - FlutterError( - code: "INVALID_ENVIRONMENT", - message: "Radiance environment must be prod or stage", - details: environment - ) - ) - return - } - guard !isRadianceReady else { - result(nil) - return - } - - let startupTime = Date() - let opts = UtilsOpts() - opts.dataDir = FilePath.dataDirectory.relativePath - opts.logDir = FilePath.logsDirectory.relativePath - opts.deviceid = "" - opts.logLevel = "trace" - opts.telemetryConsent = FilePath.isTelemetryEnabled() - opts.env = environment - opts.locale = Locale.current.identifier - appLogger.info( - "Setting up Radiance in \(environment), logging to \(opts.logDir), dataDir: \(opts.dataDir), telemetryConsent: \(opts.telemetryConsent), locale: \(opts.locale)" - ) - - var error: NSError? - let success = MobileSetupRadiance(opts, FlutterEventListener.shared, &error) - if let error { - result( - FlutterError( - code: "RADIANCE_SETUP_FAILED", - message: error.localizedDescription, - details: error.debugDescription - ) - ) - return - } - guard success else { - result( - FlutterError( - code: "RADIANCE_SETUP_FAILED", - message: "Radiance setup did not complete", - details: nil - ) - ) - return - } - - isRadianceReady = true - appLogger.info("Radiance setup took \(Date().timeIntervalSince(startupTime)) seconds") - result(nil) - } - private func startVPN(result: @escaping FlutterResult) { Task { do { diff --git a/macos/Shared/FilePath.swift b/macos/Shared/FilePath.swift index c4c14c7579..fbfb0c9a74 100644 --- a/macos/Shared/FilePath.swift +++ b/macos/Shared/FilePath.swift @@ -64,6 +64,16 @@ public enum FilePath { return FileManager.default.fileExists(atPath: marker.path) } + public static func isRadianceEnv() -> String { + let marker = appSupportDir() + .appendingPathComponent(".radiance_env") + + if FileManager.default.fileExists(atPath: marker.path) { + return "stage" + } + return "prod" + } + } extension FilePath {