diff --git a/docs/theme-import.md b/docs/theme-import.md index d61c36f7..6727d708 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -122,6 +122,23 @@ Built-in preset defaults apply for keys not present in the merged map. API: `ThemeController.setWorkbenchColor(key, color?)`, `ThemeController.clearColorOverrides()` (user layer only). +## Remote install from URL (0.4.3+) + +**Preferences → Appearance → Install from URL…** downloads a theme over **HTTPS only** +and imports it into `{appSupport}/themes/` using the same deduplication rules as +**Import theme…**. + +- Public HTTPS URLs only (no `http://`, no private/loopback hosts in release builds). +- Optional **SHA-256** checksum in the dialog or as `?sha256=` on the URL. +- Invalid JSON or checksum mismatch aborts install; nothing is written to the themes folder. +- No silent background downloads — install runs only after you confirm in the dialog. + +Trust model: treat remote theme URLs like any untrusted file; prefer checksums from a +known publisher. Signature verification is not implemented yet. + +Implementation: `lib/core/theme/theme_remote_install_service.dart`, +`lib/core/market/marketplace_client.dart` (stub for future Explore UI). + ## Sample themes (manual import) - `themes/samples/cyberpunk-neon.json` — cyberpunk dark preset for UI + SQL/JSON tokens diff --git a/lib/core/market/marketplace_client.dart b/lib/core/market/marketplace_client.dart new file mode 100644 index 00000000..247ce9d4 --- /dev/null +++ b/lib/core/market/marketplace_client.dart @@ -0,0 +1,35 @@ +import 'extension_manifest.dart'; + +/// Future marketplace API client (mockable until backend exists). +/// +/// See [docs/market-tech.md](https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/market-tech.md). +abstract class MarketplaceClient { + Future> searchExtensions({ + required String query, + String? type, + }); +} + +/// In-memory placeholder for local development and tests. +class MockMarketplaceClient implements MarketplaceClient { + MockMarketplaceClient({List? seed}) + : _items = List.from(seed ?? const []); + + final List _items; + + @override + Future> searchExtensions({ + required String query, + String? type, + }) async { + final normalized = query.trim().toLowerCase(); + return _items + .where((item) { + if (type != null && item.type != type) return false; + if (normalized.isEmpty) return true; + return item.name.toLowerCase().contains(normalized) || + item.id.toLowerCase().contains(normalized); + }) + .toList(growable: false); + } +} diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index cf4d3c6d..6a4f498a 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -18,6 +18,7 @@ import 'theme_import_service.dart'; import 'theme_load_result.dart'; import 'theme_paths.dart'; import 'theme_registry_service.dart'; +import 'theme_remote_install_service.dart'; /// Active theme state: preset, optional imported colors, user overrides. class ThemeController extends ChangeNotifier { @@ -438,6 +439,27 @@ class ThemeController extends ChangeNotifier { String path, ) async { final result = await _registryService.importThemeFile(path); + return _applyRegistryImportResult(result); + } + + /// Downloads a theme from [url] and activates it when import succeeds. + Future importRegistryThemeFromUrl( + String url, { + String? sha256Checksum, + ThemeRemoteInstallService? remoteInstallService, + }) async { + final installer = remoteInstallService ?? + ThemeRemoteInstallService(_registryService); + final result = await installer.installFromUrl( + url, + sha256Checksum: sha256Checksum, + ); + return _applyRegistryImportResult(result); + } + + Future _applyRegistryImportResult( + ThemeDefinitionImportResult result, + ) async { switch (result) { case ThemeDefinitionImportSuccess(:final definition): _availableThemes = _mergeBuiltinThemes( diff --git a/lib/core/theme/theme_remote_install_policy.dart b/lib/core/theme/theme_remote_install_policy.dart new file mode 100644 index 00000000..f41aa758 --- /dev/null +++ b/lib/core/theme/theme_remote_install_policy.dart @@ -0,0 +1,50 @@ +import 'package:flutter/foundation.dart'; + +/// HTTPS trust rules for remote theme install (TP-F4). +abstract final class ThemeRemoteInstallPolicy { + /// Returns true when [uri] may be used for theme download. + static bool isAllowedUrl(Uri uri, {bool allowLocalhostInDebug = kDebugMode}) { + if (uri.scheme != 'https') return false; + if (!uri.hasAuthority || uri.host.isEmpty) return false; + + final host = uri.host.toLowerCase(); + if (host == 'localhost' || host == '0.0.0.0') { + return allowLocalhostInDebug; + } + if (host == '::1' || host.endsWith('.local')) { + return allowLocalhostInDebug; + } + + final ipv4 = _parseIpv4(host); + if (ipv4 != null) { + if (_isLoopbackIpv4(ipv4) || _isPrivateIpv4(ipv4) || _isLinkLocalIpv4(ipv4)) { + return allowLocalhostInDebug; + } + } + + return true; + } + + static List? _parseIpv4(String host) { + final parts = host.split('.'); + if (parts.length != 4) return null; + final bytes = []; + for (final part in parts) { + final value = int.tryParse(part); + if (value == null || value < 0 || value > 255) return null; + bytes.add(value); + } + return bytes; + } + + static bool _isLoopbackIpv4(List ip) => ip[0] == 127; + + static bool _isLinkLocalIpv4(List ip) => ip[0] == 169 && ip[1] == 254; + + static bool _isPrivateIpv4(List ip) { + if (ip[0] == 10) return true; + if (ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31) return true; + if (ip[0] == 192 && ip[1] == 168) return true; + return false; + } +} diff --git a/lib/core/theme/theme_remote_install_service.dart b/lib/core/theme/theme_remote_install_service.dart new file mode 100644 index 00000000..0939fb22 --- /dev/null +++ b/lib/core/theme/theme_remote_install_service.dart @@ -0,0 +1,137 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; + +import 'theme_import_service.dart'; +import 'theme_registry_service.dart'; +import 'theme_remote_install_policy.dart'; + +/// HTTP response shape used by [ThemeRemoteInstallService] (mockable in tests). +class RemoteThemeHttpResponse { + const RemoteThemeHttpResponse({ + required this.statusCode, + required this.body, + }); + + final int statusCode; + final String body; +} + +/// Downloads a theme from HTTPS and imports it via [ThemeRegistryService]. +class ThemeRemoteInstallService { + ThemeRemoteInstallService( + this._registry, { + Future Function(Uri uri)? httpGet, + Duration timeout = const Duration(seconds: 30), + bool allowLocalhostInDebug = true, + }) : _httpGet = httpGet ?? _defaultHttpGet, + _timeout = timeout, + _allowLocalhostInDebug = allowLocalhostInDebug; + + final ThemeRegistryService _registry; + final Future Function(Uri uri) _httpGet; + final Duration _timeout; + final bool _allowLocalhostInDebug; + + static Future _defaultHttpGet(Uri uri) async { + final response = await http.get(uri).timeout(const Duration(seconds: 30)); + return RemoteThemeHttpResponse( + statusCode: response.statusCode, + body: response.body, + ); + } + + /// Downloads [url] and imports into the user themes directory. + /// + /// [sha256Checksum] may be passed explicitly or via `?sha256=` on the URL. + Future installFromUrl( + String url, { + String? sha256Checksum, + }) async { + final trimmed = url.trim(); + if (trimmed.isEmpty) { + return const ThemeDefinitionImportFailure('Theme URL is required.'); + } + + final uri = Uri.tryParse(trimmed); + if (uri == null) { + return const ThemeDefinitionImportFailure('Invalid theme URL.'); + } + + if (!ThemeRemoteInstallPolicy.isAllowedUrl( + uri, + allowLocalhostInDebug: _allowLocalhostInDebug, + )) { + return const ThemeDefinitionImportFailure( + 'Only public HTTPS theme URLs are allowed.', + ); + } + + final expectedChecksum = _normalizeSha256( + sha256Checksum ?? uri.queryParameters['sha256'], + ); + + File? tempFile; + try { + final response = await _httpGet(uri).timeout(_timeout); + if (response.statusCode != 200) { + return ThemeDefinitionImportFailure( + 'Download failed (HTTP ${response.statusCode}).', + ); + } + + final body = response.body; + if (body.trim().isEmpty) { + return const ThemeDefinitionImportFailure('Downloaded theme file is empty.'); + } + + final actualChecksum = sha256.convert(utf8.encode(body)).toString(); + if (expectedChecksum != null && expectedChecksum != actualChecksum) { + return const ThemeDefinitionImportFailure( + 'Checksum mismatch. Theme was not installed.', + ); + } + + final tempDir = Directory.systemTemp.createTempSync('querya_theme_remote_'); + tempFile = File(p.join(tempDir.path, 'remote-theme.json')); + await tempFile.writeAsString(body); + + return await _registry.importThemeFile(tempFile.path); + } on TimeoutException { + return const ThemeDefinitionImportFailure('Download timed out.'); + } on SocketException catch (error) { + return ThemeDefinitionImportFailure('Network error: ${error.message}'); + } on HttpException catch (error) { + return ThemeDefinitionImportFailure('Network error: ${error.message}'); + } on IOException catch (error) { + return ThemeDefinitionImportFailure(error.toString()); + } on Object catch (error) { + return ThemeDefinitionImportFailure(error.toString()); + } finally { + if (tempFile != null) { + try { + final parent = tempFile.parent; + if (await tempFile.exists()) { + await tempFile.delete(); + } + if (await parent.exists()) { + await parent.delete(recursive: true); + } + } on Object { + // Best-effort temp cleanup. + } + } + } + } + + static String? _normalizeSha256(String? raw) { + if (raw == null) return null; + final trimmed = raw.trim().toLowerCase(); + if (trimmed.isEmpty) return null; + return trimmed.replaceAll(RegExp(r'[^0-9a-f]'), ''); + } +} diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 60c1ec36..0daff22d 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -11,6 +11,7 @@ import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/features/settings/theme_editor_section.dart'; import 'package:querya_desktop/features/settings/theme_picker_button.dart'; import 'package:querya_desktop/features/settings/theme_preview_card.dart'; +import 'package:querya_desktop/features/settings/theme_remote_install_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Appearance / theme controls for [PreferencesDialog]. @@ -28,6 +29,7 @@ class _PreferencesAppearanceSectionState String? _importError; String? _folderOpenError; bool _importing = false; + bool _installingFromUrl = false; bool _openingThemesFolder = false; @override @@ -94,6 +96,33 @@ class _PreferencesAppearanceSectionState } } + Future _installThemeFromUrl() async { + final request = await showThemeRemoteInstallDialog(context); + if (request == null) return; + + setState(() { + _installingFromUrl = true; + _importError = null; + }); + try { + final result = await _controller.importRegistryThemeFromUrl( + request.url, + sha256Checksum: request.sha256Checksum, + ); + if (!mounted) return; + switch (result) { + case ThemeDefinitionImportSuccess(): + setState(() => _importError = null); + case ThemeDefinitionImportFailure(:final message): + setState(() => _importError = message); + } + } finally { + if (mounted) { + setState(() => _installingFromUrl = false); + } + } + } + Future _resetAppearance() async { await _controller.resetToDefaults(); if (mounted) setState(() => _importError = null); @@ -233,12 +262,21 @@ class _PreferencesAppearanceSectionState runSpacing: 8, children: [ OutlineButton( - onPressed: - _importing ? null : () => unawaited(_pickAndImportTheme()), + onPressed: (_importing || _installingFromUrl) + ? null + : () => unawaited(_pickAndImportTheme()), child: material.Text(_importing ? 'Importing…' : 'Import theme…'), ), OutlineButton( - onPressed: (_importing || refreshingThemes) + onPressed: (_importing || _installingFromUrl || refreshingThemes) + ? null + : () => unawaited(_installThemeFromUrl()), + child: material.Text( + _installingFromUrl ? 'Installing…' : 'Install from URL…', + ), + ), + OutlineButton( + onPressed: (_importing || _installingFromUrl || refreshingThemes) ? null : () => unawaited(_refreshThemes()), child: material.Text( @@ -246,7 +284,7 @@ class _PreferencesAppearanceSectionState ), ), OutlineButton( - onPressed: (_importing || _openingThemesFolder) + onPressed: (_importing || _installingFromUrl || _openingThemesFolder) ? null : () => unawaited(_openThemesFolder()), child: material.Text( @@ -282,6 +320,7 @@ class _PreferencesAppearanceSectionState const material.SizedBox(height: 4), const PreferencesHint( 'Import copies a theme into the themes folder. ' + 'Install from URL requires HTTPS and optional SHA-256 verification. ' 'VS Code JSON/JSONC (.colors subset) and Querya custom JSON are supported.', ), ], diff --git a/lib/features/settings/theme_remote_install_dialog.dart b/lib/features/settings/theme_remote_install_dialog.dart new file mode 100644 index 00000000..e8d294ef --- /dev/null +++ b/lib/features/settings/theme_remote_install_dialog.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Dialog for installing a theme from a public HTTPS URL. +Future showThemeRemoteInstallDialog( + material.BuildContext context, +) async { + return material.showDialog( + context: context, + builder: (dialogContext) => const _ThemeRemoteInstallDialog(), + ); +} + +class ThemeRemoteInstallRequest { + const ThemeRemoteInstallRequest({ + required this.url, + this.sha256Checksum, + }); + + final String url; + final String? sha256Checksum; +} + +class _ThemeRemoteInstallDialog extends material.StatefulWidget { + const _ThemeRemoteInstallDialog(); + + @override + material.State<_ThemeRemoteInstallDialog> createState() => + _ThemeRemoteInstallDialogState(); +} + +class _ThemeRemoteInstallDialogState + extends material.State<_ThemeRemoteInstallDialog> { + final _urlController = material.TextEditingController(); + final _checksumController = material.TextEditingController(); + String? _validationError; + + @override + void dispose() { + _urlController.dispose(); + _checksumController.dispose(); + super.dispose(); + } + + void _submit() { + final url = _urlController.text.trim(); + if (url.isEmpty) { + setState(() => _validationError = 'Enter a theme URL.'); + return; + } + + final uri = Uri.tryParse(url); + if (uri == null || uri.host.isEmpty) { + setState(() => _validationError = 'Enter a valid HTTPS URL.'); + return; + } + if (uri.scheme != 'https') { + setState(() => _validationError = 'Only HTTPS URLs are allowed.'); + return; + } + + final checksum = _checksumController.text.trim(); + material.Navigator.pop( + context, + ThemeRemoteInstallRequest( + url: url, + sha256Checksum: checksum.isEmpty ? null : checksum, + ), + ); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final host = Uri.tryParse(_urlController.text.trim())?.host; + + return material.AlertDialog( + title: const material.Text('Install theme from URL'), + content: material.SizedBox( + width: 420, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const material.Text( + 'Download runs only after you confirm. Use public HTTPS links.', + ), + if (host != null && host.isNotEmpty) ...[ + const material.SizedBox(height: 8), + material.Text( + 'Host: $host', + style: material.TextStyle( + fontSize: 12, + color: cs.mutedForeground, + ), + ), + ], + const material.SizedBox(height: 12), + material.TextField( + controller: _urlController, + decoration: const material.InputDecoration( + labelText: 'Theme URL', + hintText: 'https://example.com/themes/my-theme.json', + ), + keyboardType: material.TextInputType.url, + autocorrect: false, + onChanged: (_) => setState(() => _validationError = null), + ), + const material.SizedBox(height: 12), + material.TextField( + controller: _checksumController, + decoration: const material.InputDecoration( + labelText: 'SHA-256 checksum (optional)', + hintText: 'hex digest or ?sha256= on URL', + ), + autocorrect: false, + ), + if (_validationError != null) ...[ + const material.SizedBox(height: 8), + material.Text( + _validationError!, + style: material.TextStyle( + fontSize: 12, + color: cs.destructive, + ), + ), + ], + ], + ), + ), + actions: [ + OutlineButton( + onPressed: () => material.Navigator.pop(context), + child: const material.Text('Cancel'), + ), + PrimaryButton( + onPressed: _submit, + child: const material.Text('Install'), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 83da6093..5cb98878 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: flutter: sdk: flutter http: ^1.2.2 + crypto: ^3.0.6 shadcn_flutter: ^0.0.52 bitsdojo_window: ^0.1.6 path: ^1.9.0 diff --git a/test/core/theme/theme_remote_install_policy_test.dart b/test/core/theme/theme_remote_install_policy_test.dart new file mode 100644 index 00000000..3114256b --- /dev/null +++ b/test/core/theme/theme_remote_install_policy_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/theme_remote_install_policy.dart'; + +void main() { + group('ThemeRemoteInstallPolicy', () { + test('allows public https URLs', () { + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://cdn.example.com/themes/neon.json'), + allowLocalhostInDebug: false, + ), + isTrue, + ); + }); + + test('rejects http URLs', () { + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('http://example.com/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('rejects localhost unless debug override', () { + final localhost = Uri.parse('https://localhost/theme.json'); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + localhost, + allowLocalhostInDebug: false, + ), + isFalse, + ); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + localhost, + allowLocalhostInDebug: true, + ), + isTrue, + ); + }); + + test('rejects private IPv4 addresses', () { + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://192.168.1.10/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://10.0.0.5/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + }); +} diff --git a/test/core/theme/theme_remote_install_service_test.dart b/test/core/theme/theme_remote_install_service_test.dart new file mode 100644 index 00000000..8836ed7e --- /dev/null +++ b/test/core/theme/theme_remote_install_service_test.dart @@ -0,0 +1,202 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:querya_desktop/core/theme/theme_import_service.dart'; +import 'package:querya_desktop/core/theme/theme_registry_service.dart'; +import 'package:querya_desktop/core/theme/theme_remote_install_service.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; +} + +Future _fixtureAssetLoader(String assetPath) async { + final fileName = p.basename(assetPath); + return File(p.join('test/fixtures/themes', fileName)).readAsString(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late Directory themesDir; + late ThemeRegistryService registry; + + setUpAll(() async { + tempDir = + await Directory.systemTemp.createTemp('querya_theme_remote_install_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + setUp(() async { + themesDir = Directory(p.join(tempDir.path, 'themes')); + await Directory(p.join(themesDir.path, 'imported')).create(recursive: true); + registry = ThemeRegistryService( + userThemesDirectory: () async => themesDir, + importedThemesDirectory: () async => Directory( + p.join(themesDir.path, 'imported'), + ), + assetLoader: _fixtureAssetLoader, + ); + ThemeController.instance.setRegistryServiceForTest(registry); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + await ThemeController.instance.stopThemeFolderWatcher(); + await ThemeController.instance.endEditorPreview(); + await AppSettings.instance.clearThemeSettings(); + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + ThemeController.instance.setRegistryServiceForTest(ThemeRegistryService()); + await ThemeController.instance.load(); + }); + + group('ThemeRemoteInstallService', () { + test('installs valid HTTPS theme content', () async { + final raw = + await File('test/fixtures/themes/querya_custom_dark.json').readAsString(); + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + httpGet: (_) async => RemoteThemeHttpResponse( + statusCode: 200, + body: raw, + ), + ); + + final result = await service.installFromUrl( + 'https://cdn.example.com/themes/querya_custom_dark.json', + ); + + expect(result, isA()); + final success = result as ThemeDefinitionImportSuccess; + expect(success.definition.id, 'fixture-custom-dark'); + expect( + await File(p.join(themesDir.path, 'fixture-custom-dark.json')).exists(), + isTrue, + ); + }); + + test('rejects checksum mismatch and does not write theme file', () async { + final raw = + await File('test/fixtures/themes/querya_custom_dark.json').readAsString(); + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + httpGet: (_) async => RemoteThemeHttpResponse( + statusCode: 200, + body: raw, + ), + ); + + final result = await service.installFromUrl( + 'https://cdn.example.com/themes/querya_custom_dark.json', + sha256Checksum: 'deadbeef', + ); + + expect(result, isA()); + expect( + (result as ThemeDefinitionImportFailure).message, + contains('Checksum mismatch'), + ); + expect(await themesDir.list().length, 1); + }); + + test('rejects invalid JSON without writing to themes folder', () async { + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + httpGet: (_) async => const RemoteThemeHttpResponse( + statusCode: 200, + body: '{ not valid json', + ), + ); + + final result = await service.installFromUrl( + 'https://cdn.example.com/themes/broken.json', + ); + + expect(result, isA()); + final jsonFiles = await themesDir + .list() + .where((entity) => entity.path.endsWith('.json')) + .toList(); + expect(jsonFiles, isEmpty); + }); + + test('rejects non-https URLs', () async { + final service = ThemeRemoteInstallService(registry); + final result = await service.installFromUrl('http://example.com/a.json'); + expect(result, isA()); + }); + + test('reuses existing file when remote content hash matches', () async { + final raw = + await File('test/fixtures/themes/querya_custom_dark.json').readAsString(); + await File(p.join(themesDir.path, 'fixture-custom-dark.json')) + .writeAsString(raw); + + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + httpGet: (_) async => RemoteThemeHttpResponse( + statusCode: 200, + body: raw, + ), + ); + + final result = await service.installFromUrl( + 'https://cdn.example.com/themes/querya_custom_dark.json', + ); + + expect(result, isA()); + expect((result as ThemeDefinitionImportSuccess).reusedExisting, isTrue); + }); + }); + + group('ThemeController remote install', () { + test('importRegistryThemeFromUrl activates imported theme', () async { + final raw = + await File('test/fixtures/themes/querya_custom_dark.json').readAsString(); + final controller = ThemeController.instance; + await controller.load(); + + final result = await controller.importRegistryThemeFromUrl( + 'https://cdn.example.com/themes/querya_custom_dark.json', + remoteInstallService: ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + httpGet: (_) async => RemoteThemeHttpResponse( + statusCode: 200, + body: raw, + ), + ), + ); + + expect(result, isA()); + expect(controller.selectedThemeId, 'fixture-custom-dark'); + expect( + controller.activeTheme.colorScheme.primary, + parseQueryaThemeColor('#38BDF8'), + ); + }); + }); +} diff --git a/test/features/settings/preferences_appearance_section_test.dart b/test/features/settings/preferences_appearance_section_test.dart index 15ad192e..11661ee9 100644 --- a/test/features/settings/preferences_appearance_section_test.dart +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -119,6 +119,7 @@ void main() { await pumpSection(tester); expect(find.text('Import theme…'), findsOneWidget); + expect(find.text('Install from URL…'), findsOneWidget); expect(find.text('Refresh themes'), findsOneWidget); expect(find.text('Open themes folder'), findsOneWidget); expect(find.text('Reset appearance'), findsOneWidget);