Skip to content
Merged
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.7] - 2026-06-21

Local extension discovery and manifest foundation release. Git tag **`0.4.7`**.

### Added

- **Extension models (EXT-1)** — data models `ExtensionManifest` and `ExtensionType` to parse `manifest.json`.
- **Local scanner (EXT-2)** — `LocalExtensionRegistry` scans `~/.querya/extensions/` to find and load extension manifests.
- **Theme migration (EXT-3)** — migrated legacy custom themes to the new unified extension package format.
- **Unit tests (EXT-4)** — unit tests for manifest parsing, directory scanning, and registry cache logic.

### Fixed

- **Theme importing security** — resolved concurrent import TOCTOU filesystem races and theme ID collisions during migration, and cleaned up deprecated legacy import code.
- **Appearance Settings test** — resolved the preferences appearance section widget test failure by mocking the extensions directory.

## [0.4.6-a] - 2026-06-18

### Changed
Expand Down
32 changes: 32 additions & 0 deletions cleanup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import re

with open('lib/core/theme/theme_registry_service.dart', 'r') as f:
content = f.read()

# Fix curly_braces_in_flow_control_structures
content = content.replace("if (!await directory.exists()) continue;", "if (!await directory.exists()) { continue; }")

# Fix unnecessary_brace_in_string_interps
content = content.replace("'${slug}-$counter'", "'$slug-$counter'")
content = content.replace("'${preferredBaseName}-$counter'", "'$preferredBaseName-$counter'")

# Fix unused hash
content = content.replace("final hash = _contentHash(raw);\n final json = _decodeRoot(raw);", "final json = _decodeRoot(raw);")
content = content.replace("final hash = _contentHash(raw);\n final json = _decodeRoot(raw);", "final json = _decodeRoot(raw);") # Handle multiple occurrences if any

# Remove unused methods
methods_to_remove = [
r'Future<void> _scanDirectory.*?^\s*\}\s*',
r'Future<_ResolvedImportDestination> _resolveImportDestination.*?^\s*\}\s*',
r'Future<String> _nextRenamedThemeId.*?^\s*\}\s*',
r'Future<bool> _themeIdExists.*?^\s*\}\s*',
r'Future<File> _nextAvailableThemeFile.*?^\s*\}\s*',
r'String _rewriteCustomThemeId.*?^\s*\}\s*',
r'class _ResolvedImportDestination.*?^\}\s*'
]

for pattern in methods_to_remove:
content = re.sub(pattern, '', content, flags=re.DOTALL | re.MULTILINE)

with open('lib/core/theme/theme_registry_service.dart', 'w') as f:
f.write(content)
6 changes: 5 additions & 1 deletion lib/core/database/sqlite_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ class SqliteConnection {
if (!isConnected || _db == null) {
throw StateError('Not connected to SQLite');
}
final sqlLower = sql.trim().toLowerCase();
final sqlLower = sql
.replaceAll(RegExp(r'--.*$', multiLine: true), '')
.replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), '')
.trim()
.toLowerCase();

// SQLite can execute PRAGMA, SELECT, EXPLAIN statements, which return data
final isQuery = sqlLower.startsWith('select') ||
Expand Down
36 changes: 36 additions & 0 deletions lib/core/extensions/extension_paths.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

/// Centralizes extension file locations.
abstract final class ExtensionPaths {
static const _extensionsSegment = 'extensions';

@visibleForTesting
static Directory? mockExtensionsDirectory;

/// Returns `~/.querya/extensions` on Linux/Mac, or equivalent `USERPROFILE\.querya\extensions` on Windows.
/// Falls back to application support directory if HOME is unavailable.
static Future<Directory> extensionsDirectory() async {
if (mockExtensionsDirectory != null) {
return mockExtensionsDirectory!;
}
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
if (home == null || home.isEmpty) {
final support = await getApplicationSupportDirectory();
return Directory(p.join(support.path, _extensionsSegment));
}
return Directory(p.join(home, '.querya', _extensionsSegment));
}

/// Creates the extensions directory if it doesn't exist.
static Future<Directory> ensureExtensionsDirectory() async {
final dir = await extensionsDirectory();
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
}
73 changes: 73 additions & 0 deletions lib/core/extensions/local_extension_registry.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'dart:io';

import 'package:path/path.dart' as p;

import 'extension_paths.dart';
import 'models/extension_manifest.dart';

/// Scans the local filesystem for extensions and loads their manifests.
class LocalExtensionRegistry {
LocalExtensionRegistry._();
static final LocalExtensionRegistry instance = LocalExtensionRegistry._();

List<ExtensionManifest> _manifests = [];
bool _loaded = false;
Future<List<ExtensionManifest>>? _loadFuture;

/// Returns an unmodifiable list of loaded manifests.
List<ExtensionManifest> get manifests => List.unmodifiable(_manifests);

/// Reloads manifests from the disk.
Future<void> reload() async {
_loaded = false;
_loadFuture = null;
await load();
}

/// Loads manifests from the extensions directory if not already loaded.
Future<List<ExtensionManifest>> load() async {
if (_loaded) return manifests;
if (_loadFuture != null) return _loadFuture!;

_loadFuture = _doLoad();
try {
return await _loadFuture!;
} finally {
_loadFuture = null;
}
}

Future<List<ExtensionManifest>> _doLoad() async {
final dir = await ExtensionPaths.extensionsDirectory();
final loadedManifests = <ExtensionManifest>[];

if (await dir.exists()) {
// Use list() rather than listSync() to prevent blocking the UI
final entities = await dir.list().toList();
for (final entity in entities) {
if (entity is Directory) {
final manifestFile = File(p.join(entity.path, 'manifest.json'));
if (await manifestFile.exists()) {
try {
final content = await manifestFile.readAsString();
final json = jsonDecode(content) as Map<String, dynamic>;
final manifest = ExtensionManifest.fromJson(
json,
installPath: entity.path,
);
loadedManifests.add(manifest);
} catch (e) {
// Log or ignore invalid manifests
// In the future, we could report these to an error logging service
}
}
}
}
}

_manifests = loadedManifests;
_loaded = true;
return manifests;
}
}
56 changes: 56 additions & 0 deletions lib/core/extensions/models/extension_manifest.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'extension_type.dart';

class ExtensionManifest {
final String id;
final String name;
final String version;
final String publisher;
final ExtensionType type;
final Map<String, String> engines;
final String? main;
final String? icon;
final String? description;
final String? installPath;

const ExtensionManifest({
required this.id,
required this.name,
required this.version,
required this.publisher,
required this.type,
required this.engines,
this.main,
this.icon,
this.description,
this.installPath,
});

factory ExtensionManifest.fromJson(Map<String, dynamic> json, {String? installPath}) {
return ExtensionManifest(
id: json['id'] as String,
name: json['name'] as String,
version: json['version'] as String,
publisher: json['publisher'] as String,
type: ExtensionType.fromString(json['type'] as String),
engines: Map<String, String>.from(json['engines'] as Map? ?? {}),
main: json['main'] as String?,
icon: json['icon'] as String?,
description: json['description'] as String?,
installPath: installPath,
);
}

Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'version': version,
'publisher': publisher,
'type': type.value,
'engines': engines,
if (main != null) 'main': main,
if (icon != null) 'icon': icon,
if (description != null) 'description': description,
};
}
}
15 changes: 15 additions & 0 deletions lib/core/extensions/models/extension_type.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
enum ExtensionType {
databaseDriver('database_driver'),
theme('theme'),
unknown('unknown');

final String value;
const ExtensionType(this.value);

static ExtensionType fromString(String value) {
return ExtensionType.values.firstWhere(
(e) => e.value == value,
orElse: () => ExtensionType.unknown,
);
}
}
38 changes: 4 additions & 34 deletions lib/core/theme/theme_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import 'theme_definition.dart';
import 'theme_folder_watcher.dart';
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';
import '../extensions/extension_paths.dart';

/// Active theme state: preset, optional imported colors, user overrides.
class ThemeController extends ChangeNotifier {
Expand Down Expand Up @@ -214,10 +214,10 @@ class ThemeController extends ChangeNotifier {
}
}

/// Watches `{appSupport}/themes/` and debounces [loadAvailableThemes].
/// Watches extensions directory and debounces [loadAvailableThemes].
Future<void> startThemeFolderWatcher() async {
_themeFolderWatcher ??= ThemeFolderWatcher(
themesDirectory: ThemePaths.userThemesDirectory,
themesDirectory: ExtensionPaths.extensionsDirectory,
onThemesChanged: loadAvailableThemes,
);
await _themeFolderWatcher!.start();
Expand Down Expand Up @@ -473,37 +473,7 @@ class ThemeController extends ChangeNotifier {
return result;
}

/// Parses a VS Code theme file, persists it, and activates the imported preset.
Future<ThemeImportResult> importThemeFromFile(String path) async {
final result = await ThemeImportService.importFromPath(path);
switch (result) {
case ThemeImportSuccess(
:final name,
:final isDark,
:final colors,
:final tokenColors,
:final storedPath,
):
await _clearRegistrySelection();
_importedColors = Map.unmodifiable(colors);
_importedTokenColors = List.unmodifiable(tokenColors);
_importedThemeName = name;
_preset = QueryaThemePreset.imported;
_themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
await AppSettings.instance.setThemeImportedColors(colors);
await AppSettings.instance.setThemeImportName(name);
await AppSettings.instance.setThemeImportPath(storedPath);
await AppSettings.instance.setThemePreset(QueryaThemePreset.imported);
await AppSettings.instance.setThemeMode(_themeMode);
_availableThemes = _mergeBuiltinThemes(
await _registryService.loadThemeDefinitions(),
);
_notifyThemeChanged();
return result;
case ThemeImportFailure():
return result;
}
}


/// Sets or clears a user override for a VS Code `colors` key.
Future<void> setWorkbenchColor(String vscodeKey, Color? value) async {
Expand Down
65 changes: 0 additions & 65 deletions lib/core/theme/theme_import_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,6 @@ import 'package:path_provider/path_provider.dart';
import 'parser/vscode_theme_manifest.dart';
import 'theme_definition.dart';

/// Result of importing a VS Code theme file.
sealed class ThemeImportResult {
const ThemeImportResult();
}

class ThemeImportSuccess extends ThemeImportResult {
const ThemeImportSuccess({
required this.name,
required this.isDark,
required this.colors,
required this.tokenColors,
required this.storedPath,
});

final String name;
final bool isDark;
final Map<String, String> colors;
final List<TokenColorRule> tokenColors;
final String storedPath;
}

class ThemeImportFailure extends ThemeImportResult {
const ThemeImportFailure(this.message);
final String message;
}

/// Result of copying a theme file into the user themes directory.
sealed class ThemeDefinitionImportResult {
Expand Down Expand Up @@ -80,46 +55,6 @@ abstract final class ThemeImportService {
/// Path to the persisted legacy import copy under app support.
static Future<File> persistedImportFile() => _storedThemeFile();

/// Reads [sourcePath], parses JSON/JSONC, copies to app data, returns colors.
static Future<ThemeImportResult> importFromPath(String sourcePath) async {
try {
final source = File(sourcePath);
if (!await source.exists()) {
return const ThemeImportFailure('Theme file not found.');
}
final raw = await source.readAsString();
final manifest = VsCodeThemeManifest.fromJsonString(raw);
if (manifest.colors.isEmpty) {
return const ThemeImportFailure(
'Theme file has no "colors" section to import.',
);
}

final storedFile = await _storedThemeFile();
await storedFile.parent.create(recursive: true);
await storedFile.writeAsString(raw);

final name = manifest.name?.trim().isNotEmpty == true
? manifest.name!.trim()
: p.basenameWithoutExtension(sourcePath);

return ThemeImportSuccess(
name: name,
isDark: manifest.isDark || !manifest.isLight,
colors: Map.unmodifiable(manifest.colors),
tokenColors: List.unmodifiable(manifest.tokenColors),
storedPath: storedFile.path,
);
} on VsCodeThemeParseException catch (e) {
return ThemeImportFailure(e.message);
} on FormatException catch (e) {
return ThemeImportFailure(e.message);
} on IOException catch (e) {
return ThemeImportFailure(e.toString());
} on Object catch (e) {
return ThemeImportFailure(e.toString());
}
}

/// Reloads colors from the persisted import file, if present.
static Future<Map<String, String>?> loadPersistedColors() async {
Expand Down
Loading
Loading