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 <cursoragent@cursor.com>
This commit is contained in:
Henrique Velloso
2026-02-24 17:36:54 -03:00
co-authored by Cursor
parent 288cb8ef16
commit b28ebd5a39
5 changed files with 154 additions and 29 deletions
+31 -8
View File
@@ -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();
@@ -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<PackageManagerState> {
}
}
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<PackageManagerState> {
// 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<PackageManagerState> {
}
}
/// 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<void> retryDownloadFromCdn(String appId, FileMetadata target) {
@@ -994,8 +1009,8 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
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<PackageManagerState> {
await _startDownloadTask(
appId,
op.target,
downloadUrl,
resolved.url,
displayName: op.displayName,
isCdnRetry: false,
isCdnRetry: resolved.isCdn,
);
}
+23
View File
@@ -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<bool> getAlwaysUseCdn() async {
final value = await _storage.read(key: _alwaysUseCdnKey);
return value == 'true';
}
Future<void> 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<bool>((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<bool>((ref) async {
final secureStorage = ref.watch(secureStorageServiceProvider);
return secureStorage.getAlwaysUseCdn();
});
+68 -12
View File
@@ -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<bool?>(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),
+9 -1
View File
@@ -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;
}