diff --git a/lib/core/extensions/extension_support.dart b/lib/core/extensions/extension_support.dart new file mode 100644 index 00000000..ceb26dc6 --- /dev/null +++ b/lib/core/extensions/extension_support.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; + +/// Support matrix for locally installed / marketplace extensions. +/// +/// Themes install and apply fully. Database drivers are catalog preview until +/// the dynamic plugin runtime ships (see Marketplace API roadmap). +class ExtensionSupport { + ExtensionSupport._(); + + static const databaseDriverPreviewNotice = + 'Database drivers in the Marketplace are preview listings only. ' + 'Querya connects using built-in Dart drivers (PostgreSQL, MySQL, SQLite, ' + 'Redis, MongoDB). External driver plugins will load via the Marketplace ' + 'plugin runtime in a future release.'; + + static const databaseDriverMissingEntryMessage = + 'Driver package is missing its main entry file. Installation aborted.'; + + static bool isPreviewOnly(ExtensionType type) => + type == ExtensionType.databaseDriver; + + static bool isPreviewOnlyManifest(ExtensionManifest manifest) => + isPreviewOnly(manifest.type); + + /// Ensures a database driver archive contains the declared [ExtensionManifest.main]. + static void validateDriverPackage({ + required ExtensionManifest manifest, + required Directory installDir, + }) { + if (manifest.type != ExtensionType.databaseDriver) return; + + final main = manifest.main?.trim(); + if (main == null || main.isEmpty) { + throw MarketplaceException( + 'Driver "${manifest.id}" is missing a main entry in manifest.json.', + ); + } + + final entry = File(p.join(installDir.path, main)); + if (!entry.existsSync()) { + throw MarketplaceException( + 'Driver package "${manifest.id}" is missing entry file "$main". ' + '$databaseDriverMissingEntryMessage', + ); + } + } +} diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 03d7a7e9..068c85ef 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -5,19 +5,13 @@ import 'package:archive/archive.dart'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; import 'marketplace_repository.dart'; -class MarketplaceException implements Exception { - MarketplaceException(this.message); - final String message; - @override - String toString() => 'MarketplaceException: $message'; -} - /// HTTP implementation of [MarketplaceRepository] connecting to MarketApi backend. /// /// Implements secure downloading with SHA256 checksum verification and safe @@ -104,6 +98,10 @@ class HttpMarketplaceRepository implements MarketplaceRepository { ExtensionManifest manifest, { void Function(double)? onProgress, }) async { + if (ExtensionSupport.isPreviewOnly(manifest.type)) { + throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); + } + final downloadUrl = manifest.downloadUrl; if (downloadUrl == null || downloadUrl.trim().isEmpty) { throw MarketplaceException('Extension manifest is missing downloadUrl'); @@ -165,6 +163,11 @@ class HttpMarketplaceRepository implements MarketplaceRepository { onProgress?.call(0.95); + ExtensionSupport.validateDriverPackage( + manifest: manifest, + installDir: extDir, + ); + // Step 4: Write/Update manifest.json in the extension directory final manifestFile = File(p.join(extDir.path, 'manifest.json')); const encoder = JsonEncoder.withIndent(' '); diff --git a/lib/core/market/marketplace_repository.dart b/lib/core/market/marketplace_repository.dart index 421ac9b6..65dd2015 100644 --- a/lib/core/market/marketplace_repository.dart +++ b/lib/core/market/marketplace_repository.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; @@ -9,6 +10,14 @@ import 'package:querya_desktop/core/extensions/models/extension_type.dart'; export 'http_marketplace_repository.dart'; +/// Thrown when marketplace install, download, or validation fails. +class MarketplaceException implements Exception { + MarketplaceException(this.message); + final String message; + @override + String toString() => 'MarketplaceException: $message'; +} + /// Abstract repository contract for Marketplace operations (Block B). /// /// See [docs/market-tech.md] and Block B specification. @@ -175,6 +184,10 @@ class MockMarketplaceRepository implements MarketplaceRepository { ExtensionManifest manifest, { void Function(double)? onProgress, }) async { + if (ExtensionSupport.isPreviewOnly(manifest.type)) { + throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); + } + // Simulate download & verification progress for (int i = 1; i <= 10; i++) { await Future.delayed(const Duration(milliseconds: 100)); diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index e54f2e46..61d8c0ce 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; @@ -82,6 +83,12 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont } catch (e) { if (mounted) { setState(() => _installingProgress.remove(manifest.id)); + final message = e is MarketplaceException + ? e.message + : 'Failed to install "${manifest.name}".'; + material.ScaffoldMessenger.of(context).showSnackBar( + material.SnackBar(content: material.Text(message)), + ); } } } @@ -234,8 +241,37 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont child: material.CircularProgressIndicator(), ); } + final theme = Theme.of(context).colorScheme; return material.Column( children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 16, 24, 0), + child: material.Container( + width: double.infinity, + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.35), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: theme.border), + ), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Icon( + material.Icons.info_outline_rounded, + size: 18, + color: theme.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: const Text(ExtensionSupport.databaseDriverPreviewNotice) + .muted() + .small(), + ), + ], + ), + ), + ), material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 16, 24, 8), child: TextField( diff --git a/lib/features/extensions/presentation/widgets/extension_card.dart b/lib/features/extensions/presentation/widgets/extension_card.dart index 2d25b488..0419ce34 100644 --- a/lib/features/extensions/presentation/widgets/extension_card.dart +++ b/lib/features/extensions/presentation/widgets/extension_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -29,6 +30,7 @@ class ExtensionCard extends material.StatelessWidget { material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusMd; + final isPreview = ExtensionSupport.isPreviewOnlyManifest(manifest); return material.Container( padding: const material.EdgeInsets.all(16), @@ -51,6 +53,10 @@ class ExtensionCard extends material.StatelessWidget { material.Expanded( child: Text(manifest.name).large().semiBold(), ), + if (isPreview) ...[ + const material.SizedBox(width: 8), + _buildPreviewBadge(theme), + ], ], ), const material.SizedBox(height: 4), @@ -130,6 +136,14 @@ class ExtensionCard extends material.StatelessWidget { onPressed: onUninstall, child: const Text('Uninstall'), ) + else if (isPreview) + const material.Tooltip( + message: ExtensionSupport.databaseDriverPreviewNotice, + child: OutlineButton( + onPressed: null, + child: Text('Preview'), + ), + ) else PrimaryButton( onPressed: onInstall, @@ -160,6 +174,25 @@ class ExtensionCard extends material.StatelessWidget { ); } + material.Widget _buildPreviewBadge(ColorScheme theme) { + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: material.BoxDecoration( + color: theme.muted, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all(color: theme.border), + ), + child: material.Text( + 'Preview', + style: material.TextStyle( + fontSize: 11, + fontWeight: material.FontWeight.w600, + color: theme.mutedForeground, + ), + ), + ); + } + material.Widget _buildTagBadge(ColorScheme theme, String tag) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), diff --git a/test/core/extensions/extension_support_test.dart b/test/core/extensions/extension_support_test.dart new file mode 100644 index 00000000..0491d0ad --- /dev/null +++ b/test/core/extensions/extension_support_test.dart @@ -0,0 +1,56 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_support.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; + +void main() { + group('ExtensionSupport', () { + test('marks database drivers as preview-only', () { + expect( + ExtensionSupport.isPreviewOnly(ExtensionType.databaseDriver), + isTrue, + ); + expect(ExtensionSupport.isPreviewOnly(ExtensionType.theme), isFalse); + }); + + test('validateDriverPackage requires main entry file', () async { + final dir = await Directory.systemTemp.createTemp('querya_driver_test_'); + addTearDown(() async { + if (await dir.exists()) { + await dir.delete(recursive: true); + } + }); + + const manifest = ExtensionManifest( + id: 'test.driver', + name: 'Test Driver', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + ); + + expect( + () => ExtensionSupport.validateDriverPackage( + manifest: manifest, + installDir: dir, + ), + throwsA(isA()), + ); + + await File(p.join(dir.path, 'index.js')).writeAsString('// stub'); + expect( + () => ExtensionSupport.validateDriverPackage( + manifest: manifest, + installDir: dir, + ), + returnsNormally, + ); + }); + }); +} diff --git a/test/core/market/marketplace_repository_test.dart b/test/core/market/marketplace_repository_test.dart index 32e9292a..d2ab9185 100644 --- a/test/core/market/marketplace_repository_test.dart +++ b/test/core/market/marketplace_repository_test.dart @@ -52,35 +52,24 @@ void main() { expect(empty, isEmpty); }); - test('install writes manifest to disk and reloads LocalExtensionRegistry', () async { + test('install rejects preview database drivers', () async { final repo = MockMarketplaceRepository(); final trending = await repo.getTrending(); final target = trending.firstWhere((e) => e.id == 'queryahub.clickhouse-driver'); - final progressValues = []; - await repo.install(target, onProgress: (p) => progressValues.add(p)); - - expect(progressValues, isNotEmpty); - expect(progressValues.last, 1.0); - - expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isTrue); - - final extDir = Directory(p.join(tempDir.path, target.id)); - expect(await extDir.exists(), isTrue); - expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue); - }); - - test('uninstall removes directory and updates LocalExtensionRegistry', () async { - final repo = MockMarketplaceRepository(); - final trending = await repo.getTrending(); - final target = trending.firstWhere((e) => e.id == 'queryahub.clickhouse-driver'); - - await repo.install(target); - expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isTrue); + expect( + () => repo.install(target), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('preview listings only'), + )), + ); - await repo.uninstall(target.id); - expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isFalse); - expect(await Directory(p.join(tempDir.path, target.id)).exists(), isFalse); + expect( + LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), + isFalse, + ); }); test('install theme creates theme.json in extension directory', () async { @@ -218,6 +207,38 @@ void main() { )), ); }); + test('install rejects preview database drivers', () async { + final archive = Archive(); + archive.addFile(ArchiveFile('index.js', 4, utf8.encode('stub'))); + final zipBytes = ZipEncoder().encode(archive); + final expectedSha256 = sha256.convert(zipBytes).toString(); + + final mockClient = MockClient((request) async { + return http.Response.bytes(zipBytes, 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + final manifest = ExtensionManifest( + id: 'test.driver', + name: 'Driver Test', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: const {'querya_desktop': '*'}, + main: 'index.js', + downloadUrl: 'http://localhost:8000/driver.zip', + sha256Checksum: expectedSha256, + ); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('preview listings only'), + )), + ); + }); }); } diff --git a/test/features/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart index 48ce2bf2..53097ad6 100644 --- a/test/features/extensions/extension_manager_test.dart +++ b/test/features/extensions/extension_manager_test.dart @@ -42,7 +42,8 @@ void main() { expect(find.text('v1.0.0'), findsOneWidget); expect(find.text('Full support for ClickHouse databases.'), findsOneWidget); expect(find.text('clickhouse'), findsOneWidget); - expect(find.text('Install'), findsOneWidget); + expect(find.text('Preview'), findsNWidgets(2)); + expect(find.text('Install'), findsNothing); }); testWidgets('renders progress bar when isInstalling is true', (tester) async { @@ -118,17 +119,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('ClickHouse Driver'), findsOneWidget); - expect(find.text('Redis Driver'), findsOneWidget); - - // Search filtering - final finder = find.byType(TextField); - expect(finder, findsOneWidget); - - await tester.enterText(finder, 'Nord'); - await tester.pumpAndSettle(); - - expect(find.text('Nord Theme'), findsOneWidget); - expect(find.text('ClickHouse Driver'), findsNothing); + expect(find.textContaining('preview listings only'), findsOneWidget); + expect(find.text('Preview'), findsWidgets); }); }); }