Skip to content

Commit 3b7a491

Browse files
Release 0.4.7 (#222)
* Fix #211: Execute only selected SQL text and strip SQLite comments (#213) * feat(extensions): implement LocalExtensionRegistry (EXT-2) (#214) - Add ExtensionPaths for locating ~/.querya/extensions - Add LocalExtensionRegistry for parsing manifest.json from extensions dir - Add installPath to ExtensionManifest * Test: Unit Tests for Extension Registry (EXT-4) (#215) * test(extensions): add unit tests for LocalExtensionRegistry (EXT-4) - Mock ExtensionPaths.extensionsDirectory for testing - Verify registry parses valid manifests - Verify invalid manifests and plain files are ignored - Verify registry cache logic * fix(extensions): use flutter foundation for @VisibleForTesting instead of meta to satisfy linter * Feat: Migrate Custom Themes to Extension Registry (EXT-3) (#216) * fix(theme): use braces for multiline if statement * fix(theme): fix theme registry migration unit tests and watcher path * fix(theme): remove unused import theme_paths.dart * test(settings): fix preferences appearance section widget test by mocking extensions dir and utilizing pumpAndSettle * Fix TOCTOU, ID collisions, redundant scans, and remove legacy theme code (#221) * chore(release): prepare release 0.4.7
1 parent 9bd7d8b commit 3b7a491

27 files changed

Lines changed: 1270 additions & 401 deletions

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.4.7] - 2026-06-21
11+
12+
Local extension discovery and manifest foundation release. Git tag **`0.4.7`**.
13+
14+
### Added
15+
16+
- **Extension models (EXT-1)** — data models `ExtensionManifest` and `ExtensionType` to parse `manifest.json`.
17+
- **Local scanner (EXT-2)** — `LocalExtensionRegistry` scans `~/.querya/extensions/` to find and load extension manifests.
18+
- **Theme migration (EXT-3)** — migrated legacy custom themes to the new unified extension package format.
19+
- **Unit tests (EXT-4)** — unit tests for manifest parsing, directory scanning, and registry cache logic.
20+
21+
### Fixed
22+
23+
- **Theme importing security** — resolved concurrent import TOCTOU filesystem races and theme ID collisions during migration, and cleaned up deprecated legacy import code.
24+
- **Appearance Settings test** — resolved the preferences appearance section widget test failure by mocking the extensions directory.
25+
1026
## [0.4.6-a] - 2026-06-18
1127

1228
### Changed

‎cleanup.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import re
2+
3+
with open('lib/core/theme/theme_registry_service.dart', 'r') as f:
4+
content = f.read()
5+
6+
# Fix curly_braces_in_flow_control_structures
7+
content = content.replace("if (!await directory.exists()) continue;", "if (!await directory.exists()) { continue; }")
8+
9+
# Fix unnecessary_brace_in_string_interps
10+
content = content.replace("'${slug}-$counter'", "'$slug-$counter'")
11+
content = content.replace("'${preferredBaseName}-$counter'", "'$preferredBaseName-$counter'")
12+
13+
# Fix unused hash
14+
content = content.replace("final hash = _contentHash(raw);\n final json = _decodeRoot(raw);", "final json = _decodeRoot(raw);")
15+
content = content.replace("final hash = _contentHash(raw);\n final json = _decodeRoot(raw);", "final json = _decodeRoot(raw);") # Handle multiple occurrences if any
16+
17+
# Remove unused methods
18+
methods_to_remove = [
19+
r'Future<void> _scanDirectory.*?^\s*\}\s*',
20+
r'Future<_ResolvedImportDestination> _resolveImportDestination.*?^\s*\}\s*',
21+
r'Future<String> _nextRenamedThemeId.*?^\s*\}\s*',
22+
r'Future<bool> _themeIdExists.*?^\s*\}\s*',
23+
r'Future<File> _nextAvailableThemeFile.*?^\s*\}\s*',
24+
r'String _rewriteCustomThemeId.*?^\s*\}\s*',
25+
r'class _ResolvedImportDestination.*?^\}\s*'
26+
]
27+
28+
for pattern in methods_to_remove:
29+
content = re.sub(pattern, '', content, flags=re.DOTALL | re.MULTILINE)
30+
31+
with open('lib/core/theme/theme_registry_service.dart', 'w') as f:
32+
f.write(content)

‎lib/core/database/sqlite_connection.dart‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,11 @@ class SqliteConnection {
9090
if (!isConnected || _db == null) {
9191
throw StateError('Not connected to SQLite');
9292
}
93-
final sqlLower = sql.trim().toLowerCase();
93+
final sqlLower = sql
94+
.replaceAll(RegExp(r'--.*$', multiLine: true), '')
95+
.replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), '')
96+
.trim()
97+
.toLowerCase();
9498

9599
// SQLite can execute PRAGMA, SELECT, EXPLAIN statements, which return data
96100
final isQuery = sqlLower.startsWith('select') ||
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import 'dart:io';
2+
3+
import 'package:flutter/foundation.dart';
4+
import 'package:path/path.dart' as p;
5+
import 'package:path_provider/path_provider.dart';
6+
7+
/// Centralizes extension file locations.
8+
abstract final class ExtensionPaths {
9+
static const _extensionsSegment = 'extensions';
10+
11+
@visibleForTesting
12+
static Directory? mockExtensionsDirectory;
13+
14+
/// Returns `~/.querya/extensions` on Linux/Mac, or equivalent `USERPROFILE\.querya\extensions` on Windows.
15+
/// Falls back to application support directory if HOME is unavailable.
16+
static Future<Directory> extensionsDirectory() async {
17+
if (mockExtensionsDirectory != null) {
18+
return mockExtensionsDirectory!;
19+
}
20+
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
21+
if (home == null || home.isEmpty) {
22+
final support = await getApplicationSupportDirectory();
23+
return Directory(p.join(support.path, _extensionsSegment));
24+
}
25+
return Directory(p.join(home, '.querya', _extensionsSegment));
26+
}
27+
28+
/// Creates the extensions directory if it doesn't exist.
29+
static Future<Directory> ensureExtensionsDirectory() async {
30+
final dir = await extensionsDirectory();
31+
if (!await dir.exists()) {
32+
await dir.create(recursive: true);
33+
}
34+
return dir;
35+
}
36+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:path/path.dart' as p;
5+
6+
import 'extension_paths.dart';
7+
import 'models/extension_manifest.dart';
8+
9+
/// Scans the local filesystem for extensions and loads their manifests.
10+
class LocalExtensionRegistry {
11+
LocalExtensionRegistry._();
12+
static final LocalExtensionRegistry instance = LocalExtensionRegistry._();
13+
14+
List<ExtensionManifest> _manifests = [];
15+
bool _loaded = false;
16+
Future<List<ExtensionManifest>>? _loadFuture;
17+
18+
/// Returns an unmodifiable list of loaded manifests.
19+
List<ExtensionManifest> get manifests => List.unmodifiable(_manifests);
20+
21+
/// Reloads manifests from the disk.
22+
Future<void> reload() async {
23+
_loaded = false;
24+
_loadFuture = null;
25+
await load();
26+
}
27+
28+
/// Loads manifests from the extensions directory if not already loaded.
29+
Future<List<ExtensionManifest>> load() async {
30+
if (_loaded) return manifests;
31+
if (_loadFuture != null) return _loadFuture!;
32+
33+
_loadFuture = _doLoad();
34+
try {
35+
return await _loadFuture!;
36+
} finally {
37+
_loadFuture = null;
38+
}
39+
}
40+
41+
Future<List<ExtensionManifest>> _doLoad() async {
42+
final dir = await ExtensionPaths.extensionsDirectory();
43+
final loadedManifests = <ExtensionManifest>[];
44+
45+
if (await dir.exists()) {
46+
// Use list() rather than listSync() to prevent blocking the UI
47+
final entities = await dir.list().toList();
48+
for (final entity in entities) {
49+
if (entity is Directory) {
50+
final manifestFile = File(p.join(entity.path, 'manifest.json'));
51+
if (await manifestFile.exists()) {
52+
try {
53+
final content = await manifestFile.readAsString();
54+
final json = jsonDecode(content) as Map<String, dynamic>;
55+
final manifest = ExtensionManifest.fromJson(
56+
json,
57+
installPath: entity.path,
58+
);
59+
loadedManifests.add(manifest);
60+
} catch (e) {
61+
// Log or ignore invalid manifests
62+
// In the future, we could report these to an error logging service
63+
}
64+
}
65+
}
66+
}
67+
}
68+
69+
_manifests = loadedManifests;
70+
_loaded = true;
71+
return manifests;
72+
}
73+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import 'extension_type.dart';
2+
3+
class ExtensionManifest {
4+
final String id;
5+
final String name;
6+
final String version;
7+
final String publisher;
8+
final ExtensionType type;
9+
final Map<String, String> engines;
10+
final String? main;
11+
final String? icon;
12+
final String? description;
13+
final String? installPath;
14+
15+
const ExtensionManifest({
16+
required this.id,
17+
required this.name,
18+
required this.version,
19+
required this.publisher,
20+
required this.type,
21+
required this.engines,
22+
this.main,
23+
this.icon,
24+
this.description,
25+
this.installPath,
26+
});
27+
28+
factory ExtensionManifest.fromJson(Map<String, dynamic> json, {String? installPath}) {
29+
return ExtensionManifest(
30+
id: json['id'] as String,
31+
name: json['name'] as String,
32+
version: json['version'] as String,
33+
publisher: json['publisher'] as String,
34+
type: ExtensionType.fromString(json['type'] as String),
35+
engines: Map<String, String>.from(json['engines'] as Map? ?? {}),
36+
main: json['main'] as String?,
37+
icon: json['icon'] as String?,
38+
description: json['description'] as String?,
39+
installPath: installPath,
40+
);
41+
}
42+
43+
Map<String, dynamic> toJson() {
44+
return {
45+
'id': id,
46+
'name': name,
47+
'version': version,
48+
'publisher': publisher,
49+
'type': type.value,
50+
'engines': engines,
51+
if (main != null) 'main': main,
52+
if (icon != null) 'icon': icon,
53+
if (description != null) 'description': description,
54+
};
55+
}
56+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
enum ExtensionType {
2+
databaseDriver('database_driver'),
3+
theme('theme'),
4+
unknown('unknown');
5+
6+
final String value;
7+
const ExtensionType(this.value);
8+
9+
static ExtensionType fromString(String value) {
10+
return ExtensionType.values.firstWhere(
11+
(e) => e.value == value,
12+
orElse: () => ExtensionType.unknown,
13+
);
14+
}
15+
}

‎lib/core/theme/theme_controller.dart‎

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ import 'theme_definition.dart';
1616
import 'theme_folder_watcher.dart';
1717
import 'theme_import_service.dart';
1818
import 'theme_load_result.dart';
19-
import 'theme_paths.dart';
2019
import 'theme_registry_service.dart';
2120
import 'theme_remote_install_service.dart';
21+
import '../extensions/extension_paths.dart';
2222

2323
/// Active theme state: preset, optional imported colors, user overrides.
2424
class ThemeController extends ChangeNotifier {
@@ -214,10 +214,10 @@ class ThemeController extends ChangeNotifier {
214214
}
215215
}
216216

217-
/// Watches `{appSupport}/themes/` and debounces [loadAvailableThemes].
217+
/// Watches extensions directory and debounces [loadAvailableThemes].
218218
Future<void> startThemeFolderWatcher() async {
219219
_themeFolderWatcher ??= ThemeFolderWatcher(
220-
themesDirectory: ThemePaths.userThemesDirectory,
220+
themesDirectory: ExtensionPaths.extensionsDirectory,
221221
onThemesChanged: loadAvailableThemes,
222222
);
223223
await _themeFolderWatcher!.start();
@@ -473,37 +473,7 @@ class ThemeController extends ChangeNotifier {
473473
return result;
474474
}
475475

476-
/// Parses a VS Code theme file, persists it, and activates the imported preset.
477-
Future<ThemeImportResult> importThemeFromFile(String path) async {
478-
final result = await ThemeImportService.importFromPath(path);
479-
switch (result) {
480-
case ThemeImportSuccess(
481-
:final name,
482-
:final isDark,
483-
:final colors,
484-
:final tokenColors,
485-
:final storedPath,
486-
):
487-
await _clearRegistrySelection();
488-
_importedColors = Map.unmodifiable(colors);
489-
_importedTokenColors = List.unmodifiable(tokenColors);
490-
_importedThemeName = name;
491-
_preset = QueryaThemePreset.imported;
492-
_themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
493-
await AppSettings.instance.setThemeImportedColors(colors);
494-
await AppSettings.instance.setThemeImportName(name);
495-
await AppSettings.instance.setThemeImportPath(storedPath);
496-
await AppSettings.instance.setThemePreset(QueryaThemePreset.imported);
497-
await AppSettings.instance.setThemeMode(_themeMode);
498-
_availableThemes = _mergeBuiltinThemes(
499-
await _registryService.loadThemeDefinitions(),
500-
);
501-
_notifyThemeChanged();
502-
return result;
503-
case ThemeImportFailure():
504-
return result;
505-
}
506-
}
476+
507477

508478
/// Sets or clears a user override for a VS Code `colors` key.
509479
Future<void> setWorkbenchColor(String vscodeKey, Color? value) async {

‎lib/core/theme/theme_import_service.dart‎

Lines changed: 0 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,6 @@ import 'package:path_provider/path_provider.dart';
66
import 'parser/vscode_theme_manifest.dart';
77
import 'theme_definition.dart';
88

9-
/// Result of importing a VS Code theme file.
10-
sealed class ThemeImportResult {
11-
const ThemeImportResult();
12-
}
13-
14-
class ThemeImportSuccess extends ThemeImportResult {
15-
const ThemeImportSuccess({
16-
required this.name,
17-
required this.isDark,
18-
required this.colors,
19-
required this.tokenColors,
20-
required this.storedPath,
21-
});
22-
23-
final String name;
24-
final bool isDark;
25-
final Map<String, String> colors;
26-
final List<TokenColorRule> tokenColors;
27-
final String storedPath;
28-
}
29-
30-
class ThemeImportFailure extends ThemeImportResult {
31-
const ThemeImportFailure(this.message);
32-
final String message;
33-
}
349

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

83-
/// Reads [sourcePath], parses JSON/JSONC, copies to app data, returns colors.
84-
static Future<ThemeImportResult> importFromPath(String sourcePath) async {
85-
try {
86-
final source = File(sourcePath);
87-
if (!await source.exists()) {
88-
return const ThemeImportFailure('Theme file not found.');
89-
}
90-
final raw = await source.readAsString();
91-
final manifest = VsCodeThemeManifest.fromJsonString(raw);
92-
if (manifest.colors.isEmpty) {
93-
return const ThemeImportFailure(
94-
'Theme file has no "colors" section to import.',
95-
);
96-
}
97-
98-
final storedFile = await _storedThemeFile();
99-
await storedFile.parent.create(recursive: true);
100-
await storedFile.writeAsString(raw);
101-
102-
final name = manifest.name?.trim().isNotEmpty == true
103-
? manifest.name!.trim()
104-
: p.basenameWithoutExtension(sourcePath);
105-
106-
return ThemeImportSuccess(
107-
name: name,
108-
isDark: manifest.isDark || !manifest.isLight,
109-
colors: Map.unmodifiable(manifest.colors),
110-
tokenColors: List.unmodifiable(manifest.tokenColors),
111-
storedPath: storedFile.path,
112-
);
113-
} on VsCodeThemeParseException catch (e) {
114-
return ThemeImportFailure(e.message);
115-
} on FormatException catch (e) {
116-
return ThemeImportFailure(e.message);
117-
} on IOException catch (e) {
118-
return ThemeImportFailure(e.toString());
119-
} on Object catch (e) {
120-
return ThemeImportFailure(e.toString());
121-
}
122-
}
12358

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

0 commit comments

Comments
 (0)