Skip to content
Merged
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
17 changes: 17 additions & 0 deletions docs/theme-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions lib/core/market/marketplace_client.dart
Original file line number Diff line number Diff line change
@@ -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<List<ExtensionManifest>> searchExtensions({
required String query,
String? type,
});
}

/// In-memory placeholder for local development and tests.
class MockMarketplaceClient implements MarketplaceClient {
MockMarketplaceClient({List<ExtensionManifest>? seed})
: _items = List<ExtensionManifest>.from(seed ?? const []);

final List<ExtensionManifest> _items;

@override
Future<List<ExtensionManifest>> 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);
}
}
22 changes: 22 additions & 0 deletions lib/core/theme/theme_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ThemeDefinitionImportResult> 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<ThemeDefinitionImportResult> _applyRegistryImportResult(
ThemeDefinitionImportResult result,
) async {
switch (result) {
case ThemeDefinitionImportSuccess(:final definition):
_availableThemes = _mergeBuiltinThemes(
Expand Down
50 changes: 50 additions & 0 deletions lib/core/theme/theme_remote_install_policy.dart
Original file line number Diff line number Diff line change
@@ -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<int>? _parseIpv4(String host) {
final parts = host.split('.');
if (parts.length != 4) return null;
final bytes = <int>[];
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<int> ip) => ip[0] == 127;

static bool _isLinkLocalIpv4(List<int> ip) => ip[0] == 169 && ip[1] == 254;

static bool _isPrivateIpv4(List<int> 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;
}
}
137 changes: 137 additions & 0 deletions lib/core/theme/theme_remote_install_service.dart
Original file line number Diff line number Diff line change
@@ -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<RemoteThemeHttpResponse> 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<RemoteThemeHttpResponse> Function(Uri uri) _httpGet;
final Duration _timeout;
final bool _allowLocalhostInDebug;

static Future<RemoteThemeHttpResponse> _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<ThemeDefinitionImportResult> 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]'), '');
}
}
47 changes: 43 additions & 4 deletions lib/features/settings/preferences_appearance_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand All @@ -28,6 +29,7 @@ class _PreferencesAppearanceSectionState
String? _importError;
String? _folderOpenError;
bool _importing = false;
bool _installingFromUrl = false;
bool _openingThemesFolder = false;

@override
Expand Down Expand Up @@ -94,6 +96,33 @@ class _PreferencesAppearanceSectionState
}
}

Future<void> _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<void> _resetAppearance() async {
await _controller.resetToDefaults();
if (mounted) setState(() => _importError = null);
Expand Down Expand Up @@ -233,20 +262,29 @@ 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(
refreshingThemes ? 'Refreshing…' : 'Refresh themes',
),
),
OutlineButton(
onPressed: (_importing || _openingThemesFolder)
onPressed: (_importing || _installingFromUrl || _openingThemesFolder)
? null
: () => unawaited(_openThemesFolder()),
child: material.Text(
Expand Down Expand Up @@ -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.',
),
],
Expand Down
Loading
Loading