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; }