From b28ebd5a39d842ef028472d7058af6c649fc8b75 Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Tue, 24 Feb 2026 17:36:54 -0300 Subject: [PATCH] Add "Always download from CDN" option to hide IP from GitHub Global setting so users can always download from cdn.zapstore.dev instead of the original URL (e.g. GitHub) to avoid exposing their IP to the host. - SecureStorageService: getAlwaysUseCdn() / setAlwaysUseCdn() and alwaysUseCdnProvider - PackageManager: _resolveDownloadUrl() reads the setting and returns CDN or original URL - InstallAlertDialog: switch "Download from CDN (hides IP)"; relay-signed apps show dynamic text (CDN vs original URL) based on switch state - InstallButton: saves alwaysUseCdn from dialog result when user confirms - ProfileScreen: "Always download from CDN" toggle in Settings Co-authored-by: Cursor --- lib/screens/profile_screen.dart | 39 +++++++-- .../package_manager/package_manager.dart | 31 +++++-- lib/services/secure_storage_service.dart | 23 ++++++ lib/widgets/install_alert_dialog.dart | 80 ++++++++++++++++--- lib/widgets/install_button.dart | 10 ++- 5 files changed, 154 insertions(+), 29 deletions(-) diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 3baf8a4..90ff864 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -21,6 +21,7 @@ import 'package:zapstore/widgets/common/profile_identity_row.dart'; import 'package:zapstore/widgets/app_card.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/widgets/common/note_parser.dart'; import 'package:zapstore/widgets/nwc_widgets.dart'; import 'package:zapstore/widgets/relay_management_card.dart'; @@ -67,6 +68,11 @@ class ProfileScreen extends ConsumerWidget { const SizedBox(height: 16), + // CDN Download Setting + const _CdnDownloadCard(), + + const SizedBox(height: 16), + // Data Management Section const _DataManagementSection(), @@ -185,17 +191,11 @@ class _AuthenticationSection extends ConsumerWidget { FilledButton.icon( onPressed: () => _signOut(context, ref), icon: const Icon(Icons.logout, size: 14), - label: const Text( - 'Sign Out', - style: TextStyle(fontSize: 12), - ), + label: const Text('Sign Out', style: TextStyle(fontSize: 12)), style: FilledButton.styleFrom( backgroundColor: Colors.red.shade900, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), minimumSize: const Size(0, 0), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), @@ -1334,6 +1334,29 @@ class _EmptyState extends StatelessWidget { } } +class _CdnDownloadCard extends ConsumerWidget { + const _CdnDownloadCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final alwaysUseCdnAsync = ref.watch(alwaysUseCdnProvider); + final alwaysUseCdn = alwaysUseCdnAsync.valueOrNull ?? false; + final secureStorage = ref.read(secureStorageServiceProvider); + + return Card( + child: SwitchListTile( + title: const Text('Always download from CDN'), + subtitle: const Text('Hides your IP from GitHub/Microsoft'), + value: alwaysUseCdn, + onChanged: (value) async { + await secureStorage.setAlwaysUseCdn(value); + ref.invalidate(alwaysUseCdnProvider); + }, + ), + ); + } +} + class _DataManagementSection extends ConsumerWidget { const _DataManagementSection(); diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index a853719..31487ad 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -9,6 +9,7 @@ import 'package:models/models.dart'; import 'package:zapstore/services/package_manager/device_capabilities.dart'; import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; import 'package:zapstore/services/package_manager/install_operation.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; export 'device_capabilities.dart'; export 'install_operation.dart'; @@ -355,8 +356,8 @@ abstract class PackageManager extends StateNotifier { } } - final downloadUrl = target.urls.firstOrNull; - if (downloadUrl == null || downloadUrl.isEmpty) { + final resolved = await _resolveDownloadUrl(target); + if (resolved == null) { setOperation( appId, OperationFailed( @@ -394,9 +395,9 @@ abstract class PackageManager extends StateNotifier { // Queue items with staggered delays to prevent UI flood for (var i = 0; i < toQueue.length; i++) { final item = toQueue[i]; - final downloadUrl = item.target.urls.firstOrNull; + final resolved = await _resolveDownloadUrl(item.target); - if (downloadUrl == null || downloadUrl.isEmpty) { + if (resolved == null) { setOperation( item.appId, OperationFailed( @@ -709,6 +710,20 @@ abstract class PackageManager extends StateNotifier { } } + /// Resolve download URL: CDN if setting enabled, else original URL. + Future<({String url, bool isCdn})?> _resolveDownloadUrl(FileMetadata target) async { + final secureStorage = ref.read(secureStorageServiceProvider); + final alwaysUseCdn = await secureStorage.getAlwaysUseCdn(); + if (alwaysUseCdn) { + final hash = target.hash; + if (hash.isEmpty) return null; + return (url: 'https://cdn.zapstore.dev/$hash', isCdn: true); + } + final url = target.urls.firstOrNull; + if (url == null || url.isEmpty) return null; + return (url: url, isCdn: false); + } + /// Re-download from CDN after a hash mismatch on the original source. @protected Future retryDownloadFromCdn(String appId, FileMetadata target) { @@ -994,8 +1009,8 @@ abstract class PackageManager extends StateNotifier { continue; } - final downloadUrl = op.target.urls.firstOrNull; - if (downloadUrl == null || downloadUrl.isEmpty) { + final resolved = await _resolveDownloadUrl(op.target); + if (resolved == null) { setOperation( appId, OperationFailed( @@ -1011,9 +1026,9 @@ abstract class PackageManager extends StateNotifier { await _startDownloadTask( appId, op.target, - downloadUrl, + resolved.url, displayName: op.displayName, - isCdnRetry: false, + isCdnRetry: resolved.isCdn, ); } diff --git a/lib/services/secure_storage_service.dart b/lib/services/secure_storage_service.dart index 4496c51..9c5e335 100644 --- a/lib/services/secure_storage_service.dart +++ b/lib/services/secure_storage_service.dart @@ -126,6 +126,21 @@ class SecureStorageService { value: jsonEncode(relays.toList()), ); } + + // ========================================================================= + // Always Use CDN (privacy: hide IP from GitHub) + // ========================================================================= + + static const _alwaysUseCdnKey = 'always_use_cdn'; + + Future getAlwaysUseCdn() async { + final value = await _storage.read(key: _alwaysUseCdnKey); + return value == 'true'; + } + + Future setAlwaysUseCdn(bool value) async { + await _storage.write(key: _alwaysUseCdnKey, value: value.toString()); + } } /// Persists the AmberSigner pubkey in flutter_secure_storage. @@ -161,3 +176,11 @@ final hasNwcStringProvider = FutureProvider.autoDispose((ref) async { final secureStorage = ref.watch(secureStorageServiceProvider); return secureStorage.hasNWCString(); }); + +/// Whether to always download from CDN instead of original URL. +/// +/// Use `ref.invalidate(alwaysUseCdnProvider)` after updating to refresh UI. +final alwaysUseCdnProvider = FutureProvider.autoDispose((ref) async { + final secureStorage = ref.watch(secureStorageServiceProvider); + return secureStorage.getAlwaysUseCdn(); +}); diff --git a/lib/widgets/install_alert_dialog.dart b/lib/widgets/install_alert_dialog.dart index e1d03ab..6ab6933 100644 --- a/lib/widgets/install_alert_dialog.dart +++ b/lib/widgets/install_alert_dialog.dart @@ -7,6 +7,7 @@ import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/author_container.dart'; import 'package:zapstore/widgets/common/base_dialog.dart'; import 'package:zapstore/widgets/download_text_container.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/widgets/relevant_who_follow_container.dart'; import 'package:zapstore/widgets/sign_in_button.dart'; @@ -32,9 +33,35 @@ class InstallAlertDialog extends HookConsumerWidget { _ => null, }; if (publisher == null) { - return const SizedBox.shrink(); + return BaseDialog( + title: const BaseDialogTitle('Trust this app?'), + content: const BaseDialogContent( + children: [ + Center( + child: Padding( + padding: EdgeInsets.all(24), + child: CircularProgressIndicator(), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: null, // Disabled while loading + child: const Text('Trust and install app'), + ), + ], + ); } final trustedSignerNotifier = useState(false); + final alwaysUseCdnAsync = ref.watch(alwaysUseCdnProvider); + final alwaysUseCdnLocal = useState(null); + final alwaysUseCdn = + alwaysUseCdnLocal.value ?? alwaysUseCdnAsync.valueOrNull ?? false; final signedInPubkey = ref.watch(Signer.activePubkeyProvider); final activeSigner = ref.watch(Signer.activeSignerProvider); final canPersistTrust = signedInPubkey != null && activeSigner != null; @@ -55,14 +82,21 @@ class InstallAlertDialog extends HookConsumerWidget { size: baseTextSize, ), Gap(10), - DownloadTextContainer( - beforeText: - '${app.name ?? app.identifier} will be installed from its original release location:', - oneLine: false, - showFullUrl: true, - url: app.latestFileMetadata!.urls.first, - size: baseTextSize, - ), + alwaysUseCdn + ? Text( + '${app.name ?? app.identifier} will be installed from cdn.zapstore.dev.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ) + : DownloadTextContainer( + beforeText: + '${app.name ?? app.identifier} will be installed from its original release location:', + oneLine: false, + showFullUrl: true, + url: app.latestFileMetadata!.urls.first, + size: baseTextSize, + ), ] else ...[ canPersistTrust ? RelevantWhoFollowContainer(app: app, size: baseTextSize) @@ -121,6 +155,27 @@ class InstallAlertDialog extends HookConsumerWidget { ), ], ], + const Gap(14), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Switch( + value: alwaysUseCdn, + onChanged: (value) { + alwaysUseCdnLocal.value = value; + }, + ), + Gap(4), + Expanded( + child: Text( + 'Download from CDN (hides IP)', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), ], ), actions: [ @@ -133,9 +188,10 @@ class InstallAlertDialog extends HookConsumerWidget { ), FilledButton( onPressed: () { - Navigator.of( - context, - ).pop((trustPermanently: trustedSignerNotifier.value)); + Navigator.of(context).pop(( + trustPermanently: trustedSignerNotifier.value, + alwaysUseCdn: alwaysUseCdn, + )); }, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index a1154d6..a1de483 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/services/trusted_signers_service.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/author_container.dart'; @@ -544,7 +545,8 @@ class InstallButton extends ConsumerWidget { if (shouldShowDialog) { if (!context.mounted) return false; - final result = await showBaseDialog<({bool trustPermanently})>( + final result = await showBaseDialog< + ({bool trustPermanently, bool alwaysUseCdn})>( context: context, dialog: InstallAlertDialog(app: app), ); @@ -554,6 +556,12 @@ class InstallButton extends ConsumerWidget { await ref.read(trustServiceProvider).addTrustedSigner(signerPubkey); } catch (_) {} } + try { + await ref + .read(secureStorageServiceProvider) + .setAlwaysUseCdn(result.alwaysUseCdn); + ref.invalidate(alwaysUseCdnProvider); + } catch (_) {} } return true; }