Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,267 changes: 1,267 additions & 0 deletions .github/scripts/windows_payment_checkout_smoke.ps1

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions .github/scripts/windows_smoke_suite.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ param(
[switch]$ForceFullTunnel,
[switch]$RunSplitTunnelWebsiteSmoke,
[switch]$RunConfigUrlSmoke,
[switch]$RunPaymentCheckoutSmoke,
[string]$PaymentSmokeArtifactDirectory = "build/windows-payment-checkout-smoke",
[switch]$UseInstaller
)

Expand Down Expand Up @@ -365,6 +367,21 @@ try {
-ForceFullTunnel:$ForceFullTunnel
}
}

if ($RunPaymentCheckoutSmoke) {
if (-not $UseInstaller) {
throw "The payment checkout smoke requires the generated installer"
}
Write-Step "Running installed Windows payment checkout smoke tests"
& "$PSScriptRoot/windows_payment_checkout_smoke.ps1" `
-ServiceName $ServiceName `
-ArtifactDirectory $PaymentSmokeArtifactDirectory
if ($LASTEXITCODE -ne 0) {
throw "Windows payment checkout smoke failed with exit code $LASTEXITCODE"
}
} else {
Write-Step "Skipping installed Windows payment checkout smoke tests."
}
}
finally {
try {
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/app-smoke-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ on:
required: false
type: boolean
default: true
windows_payment_checkout_smoke:
description: "Render staging Stripe and Shepherd checkout in the installed Windows app"
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
Expand Down Expand Up @@ -145,6 +150,7 @@ jobs:
skip_signing: true
enable_ip_check: ${{ inputs.enable_ip_check }}
run_connect_smoke: ${{ inputs.windows_connect_smoke }}
run_payment_checkout_smoke: ${{ inputs.windows_payment_checkout_smoke }}
run_split_tunnel_website_smoke: ${{ inputs.windows_split_tunnel_website_smoke }}
run_config_url_smoke: false
force_full_tunnel_smoke: ${{ inputs.force_full_tunnel_smoke }}
Expand Down
18 changes: 17 additions & 1 deletion .github/workflows/build-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ on:
required: false
type: boolean
default: true
run_payment_checkout_smoke:
description: "Run installed-app staging payment checkout smoke tests"
required: false
type: boolean
default: false
run_split_tunnel_website_smoke:
description: "Run website split tunneling smoke test"
required: false
Expand Down Expand Up @@ -302,16 +307,27 @@ jobs:
$forceFullTunnelSmoke = "${{ inputs.force_full_tunnel_smoke }}" -eq "true"
$enableIpCheck = "${{ inputs.enable_ip_check }}" -eq "true"
$runConnectSmoke = "${{ inputs.run_connect_smoke }}" -eq "true"
Write-Host "Windows smoke options: connect=$runConnectSmoke splitTunnelWebsite=$runSplitTunnelWebsiteSmoke configUrl=$runConfigUrlSmoke forceFullTunnel=$forceFullTunnelSmoke ipCheck=$enableIpCheck"
$runPaymentCheckoutSmoke = "${{ inputs.run_payment_checkout_smoke }}" -eq "true"
Write-Host "Windows smoke options: connect=$runConnectSmoke paymentCheckout=$runPaymentCheckoutSmoke splitTunnelWebsite=$runSplitTunnelWebsiteSmoke configUrl=$runConfigUrlSmoke forceFullTunnel=$forceFullTunnelSmoke ipCheck=$enableIpCheck"
./.github/scripts/windows_smoke_suite.ps1 `
-UseInstaller `
-InstallerPath $installerPath `
-RunConnectSmoke:$runConnectSmoke `
-RunPaymentCheckoutSmoke:$runPaymentCheckoutSmoke `
-RunSplitTunnelWebsiteSmoke:$runSplitTunnelWebsiteSmoke `
-RunConfigUrlSmoke:$runConfigUrlSmoke `
-EnableIpCheck:$enableIpCheck `
-ForceFullTunnel:$forceFullTunnelSmoke

- name: Upload Windows payment checkout smoke artifacts
if: ${{ always() && inputs.run_payment_checkout_smoke }}
uses: actions/upload-artifact@v4
with:
name: windows-payment-checkout-smoke
path: build/windows-payment-checkout-smoke
if-no-files-found: warn
retention-days: 7

# Transitional fallback kept in place while the auth + connect smoke suite stabilizes.
- name: Windows UI auth smoke integration
if: ${{ inputs.run_auth_smoke }}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ replace github.com/quic-go/qpack => github.com/quic-go/qpack v0.5.1
require (
github.com/alecthomas/assert/v2 v2.3.0
github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9
github.com/getlantern/radiance v0.0.0-20260730161730-76fcac0ceebb
github.com/getlantern/radiance v0.0.0-20260724161730-2ef18c71ad81
github.com/sagernet/sing-box v1.12.22
golang.org/x/mobile v0.0.0-20250711185624-d5bb5ecc55c0
golang.org/x/sys v0.45.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,8 @@ github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 h1:rtDmW8YL
github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535/go.mod h1:WKJEdjMOD4IuTRYwjQHjT4bmqDl5J82RShMLxPAvi0Q=
github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b h1:gMYJzEhLrmIqQ+JnjiYNm+UyUDalK3WUmVyecFwmV5g=
github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b/go.mod h1:NpfXdK4ldEKkjQ4P1R+DBF4ua5VFOlxmgHROTnYrApg=
github.com/getlantern/radiance v0.0.0-20260730161730-76fcac0ceebb h1:1VhnolDkNBFI7LmG6IAHgabJKrR7/B8LOYLFEne5wuo=
github.com/getlantern/radiance v0.0.0-20260730161730-76fcac0ceebb/go.mod h1:BUKzQxV+sKuRySBLPTVHDt1XkPqoIdx/G+pB9euO4LQ=
github.com/getlantern/radiance v0.0.0-20260724161730-2ef18c71ad81 h1:goJj8l9quR8bi4A4UGewR0l+euUSUAuEnFEKmLMYtE4=
github.com/getlantern/radiance v0.0.0-20260724161730-2ef18c71ad81/go.mod h1:3ioylshJuvd1dJEUydJvIWjSXllphapzDk8HQgiyPnc=
github.com/getlantern/samizdat v0.0.3-0.20260724223841-a5ee9ab56830 h1:RZd3CELOtQUEzIE5kPdEO9HYg+7JYbR3OTdC+3P/wng=
github.com/getlantern/samizdat v0.0.3-0.20260724223841-a5ee9ab56830/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0=
github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb h1:c5YM7b3a4r2J8Eh89KkI6M/iTFe6Bi+b8AJlfkKdFq4=
Expand Down
2 changes: 1 addition & 1 deletion ios/Tunnel/SingBox/ExtensionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ class ExtensionProvider: NEPacketTunnelProvider {
opts.dataDir = FilePath.dataDirectory.relativePath
opts.logDir = FilePath.logsDirectory.relativePath
// Intentionally left empty. The app and extension don't share a keychain access
// Radiance resolves the device ID from the main app process.
// Radiance resolves the device ID from the main app process.
opts.deviceid = ""
opts.logLevel = "trace"
opts.locale = Locale.current.identifier
Expand Down
67 changes: 67 additions & 0 deletions lib/core/smoke/payment_checkout_smoke.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/// Command-line settings for the Windows checkout smoke.
class PaymentCheckoutSmokeConfig {
static const _providerPrefix = '--payment-checkout-smoke=';
static const _runIDPrefix = '--payment-checkout-run-id=';
static final _runIDPattern = RegExp(
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-'
r'[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$',
);

final String provider;
final String runID;

const PaymentCheckoutSmokeConfig({
required this.provider,
required this.runID,
});

String get email => 'e2e+$runID@getlantern.org';

static PaymentCheckoutSmokeConfig? parse(
List<String> arguments, {
required bool isWindows,
required String buildType,
}) {
final providerArgument = _singleArgument(arguments, _providerPrefix);
final runIDArgument = _singleArgument(arguments, _runIDPrefix);

if (providerArgument == null && runIDArgument == null) {
return null;
}
if (!isWindows || buildType != 'nightly') {
throw const FormatException(
'Payment checkout smoke mode is available only in Windows nightly builds',
);
}
if (providerArgument == null || runIDArgument == null) {
throw const FormatException(
'Payment checkout smoke mode requires a provider and run ID',
);
}

final provider = providerArgument.toLowerCase();
if (provider != 'stripe' && provider != 'shepherd') {
throw FormatException('Unsupported payment checkout provider: $provider');
}
if (!_runIDPattern.hasMatch(runIDArgument)) {
throw const FormatException('Invalid payment checkout smoke run ID');
}

return PaymentCheckoutSmokeConfig(provider: provider, runID: runIDArgument);
}

static String? _singleArgument(List<String> arguments, String prefix) {
final matches = arguments.where((argument) => argument.startsWith(prefix));
if (matches.length > 1) {
throw FormatException('Argument may be specified only once: $prefix');
}
if (matches.isEmpty) {
return null;
}
final value = matches.single.substring(prefix.length).trim();
if (value.isEmpty) {
throw FormatException('Argument requires a value: $prefix');
}
return value;
}
}
86 changes: 76 additions & 10 deletions lib/core/widgets/app_webview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> {
shouldOverrideUrlLoading: shouldOverrideUrlLoading,
initialUrlRequest: _initialRequest,
initialSettings: setting,
onWebViewCreated: (controller) {},
onWebViewCreated: (_) {
final uri = Uri.tryParse(widget.url);
_logSmokeEvent('created', uri);
},
onCreateWindow: (controller, createWindowAction) async {
final req = createWindowAction.request;
if (PlatformUtils.isWindows) {
Expand All @@ -120,29 +123,92 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> {
return false;
},
onLoadStart: (_, webUri) async {
// Handle load start
final loading = ref.read(webViewLoadingProvider.notifier);
loading.start();
_logSmokeEvent(
'load_start',
webUri == null ? null : Uri.tryParse(webUri.toString()),
);
},
onLoadStop: (controller, webUri) async {
// Handle load stop
ref.read(webViewLoadingProvider.notifier).stop();
await _handleCompletionUrl(
webUri == null ? null : Uri.tryParse(webUri.toString()),
final uri = webUri == null ? null : Uri.tryParse(webUri.toString());
var documentLength = -1;
try {
final rawLength = await controller.evaluateJavascript(
source:
"(document.documentElement && document.documentElement.outerHTML || '').length",
);
final parsedLength = rawLength is num
? rawLength.toInt()
: int.tryParse(rawLength.toString());
if (parsedLength == null) {
_logSmokeEvent(
'document_error',
uri,
detail: 'reason=invalid_document_length',
);
} else {
documentLength = parsedLength;
}
} catch (_) {
_logSmokeEvent(
'document_error',
uri,
detail: 'reason=evaluate_javascript_failed',
);
}
_logSmokeEvent(
'load_stop',
uri,
detail: 'document_length=$documentLength',
);
await _handleCompletionUrl(uri);
},
onReceivedError: (_, webResourceRequest, error) async {
// Handle received error
appLogger.error("Received error: $error");
// Handle load stop
ref.read(webViewLoadingProvider.notifier).stop();
await _handleCompletionUrl(
final uri = Uri.tryParse(webResourceRequest.url.toString());
final isMainFrame = webResourceRequest.isForMainFrame == true;
appLogger.error(
'WebView request failed: type=${error.type} '
'main_frame=$isMainFrame host=${uri?.host ?? '<none>'}',
);
_logSmokeEvent(
isMainFrame ? 'navigation_error' : 'resource_error',
uri,
detail: 'error_type=${error.type}',
);
if (isMainFrame) {
ref.read(webViewLoadingProvider.notifier).stop();
await _handleCompletionUrl(uri);
}
},
onReceivedHttpError: (_, webResourceRequest, errorResponse) async {
if (webResourceRequest.isForMainFrame != true) return;
_logSmokeEvent(
'navigation_error',
Uri.tryParse(webResourceRequest.url.toString()),
detail: 'http_status=${errorResponse.statusCode}',
);
},
);
}

void _logSmokeEvent(String event, Uri? uri, {String detail = ''}) {
if (!PlatformUtils.isWindows || AppBuildInfo.buildType != 'nightly') {
return;
}
// Checkout paths and query strings can contain session tokens. The origin
// is enough to prove which provider loaded without putting them in CI logs.
final safeUri = uri == null
? '<none>'
: uri.replace(path: '', query: '', fragment: '').toString();
Comment on lines +202 to +204
final suffix = detail.isEmpty ? '' : ' $detail';
appLogger.info(
'PAYMENT_WEBVIEW_SMOKE event=$event host=${uri?.host ?? '<none>'} '
'url=$safeUri$suffix',
);
}

bool isLanternHost(String host) =>
host == 'lantern.io' || host == 'www.lantern.io';

Expand Down
45 changes: 28 additions & 17 deletions lib/features/auth/choose_payment_method.dart
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ class PaymentCheckoutMethods extends HookConsumerWidget {
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
final method = providers[index];
final providerName = method.providers.name.toTitleCase();
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: ExpansionTile(
Expand All @@ -498,15 +499,20 @@ class PaymentCheckoutMethods extends HookConsumerWidget {
horizontal: defaultSize,
vertical: defaultSize,
),
title: Row(
children: [
Text(
method.method.replaceAll('-', " ").toTitleCase(),
style: theme.titleMedium,
),
SizedBox(width: defaultSize),
LogsPath(logoPaths: method.providers.icons),
],
title: Semantics(
identifier: 'payment-provider-${method.providers.name}',
label: '$providerName payment method',
excludeSemantics: true,
child: Row(
Comment on lines +502 to +506
children: [
Text(
method.method.replaceAll('-', " ").toTitleCase(),
style: theme.titleMedium,
),
SizedBox(width: defaultSize),
LogsPath(logoPaths: method.providers.icons),
],
),
),
children: [
Row(
Expand Down Expand Up @@ -591,14 +597,19 @@ class PaymentCheckoutMethods extends HookConsumerWidget {
style: theme.bodySmall!.copyWith(color: context.textDisabled),
),
SizedBox(height: defaultSize),
PrimaryButton(
label: method.providers.supportSubscription
? 'subscribe'.i18n
: 'checkout'.i18n,
enabled: !isSubmitting,
onPressed: () {
onSubscribe.call(method);
},
Semantics(
identifier: 'payment-checkout-${method.providers.name}',
label: 'Continue with $providerName',
excludeSemantics: true,
child: PrimaryButton(
label: method.providers.supportSubscription
? 'subscribe'.i18n
: 'checkout'.i18n,
enabled: !isSubmitting,
onPressed: () {
onSubscribe.call(method);
},
),
),
],
),
Expand Down
Loading
Loading