diff --git a/docs/packaging.md b/docs/packaging.md new file mode 100644 index 00000000..6a4912af --- /dev/null +++ b/docs/packaging.md @@ -0,0 +1,45 @@ +# Packaging: portable vs installable + +Querya Desktop ships (and will ship) two download channels. See epic +[#379](https://github.com/QueryaHub/Querya-Desktop/issues/379). + +| Channel | Typical artifact | Profile data | +|---------|------------------|--------------| +| **Portable** | `Querya-Desktop-{ver}-{os}.zip` (Flutter bundle) | OS app-support by default; optional sidecar — see below | +| **Installable** | AppImage, Windows setup, deb/rpm/Flatpak (planned) | Normal OS locations | + +## Portable profile (`QueryaData`) + +By default the zip is a **relocatable binary** only: settings DB, themes, and +extensions still use OS paths (`getApplicationSupportDirectory`, +`~/.querya/extensions`, …). + +To keep profile data next to the app (USB-style): + +1. Set environment variable **`QUERYA_PORTABLE=1`** (also `true` / `yes` / `on`), + **or** +2. Create a folder named **`QueryaData`** next to `querya_desktop` / + `querya_desktop.exe` / the `.AppImage` file. + +Then local data is stored under that folder: + +| Kind | Path under `QueryaData/` | +|------|--------------------------| +| SQLite DB | `querya_desktop/querya.db` | +| Themes | `themes/` | +| Extensions | `extensions/` | +| Sandbox / audit logs | `logs/` | + +On Linux AppImage, the install directory is the parent of `$APPIMAGE`. + +### Secrets + +Connection passwords remain in the **OS keyring** (`flutter_secure_storage` / +libsecret / Credential Manager / Keychain). Portable mode does **not** move +secrets into `QueryaData` in v1. + +## Related code + +- `lib/core/storage/app_data_root.dart` — detection and support-dir redirect +- Updater packaging context: `lib/core/updater/installers/update_install_context.dart` +- Release workflow: `.github/workflows/release.yml` diff --git a/docs/theme-import.md b/docs/theme-import.md index b01e1189..cd8c8b99 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -76,8 +76,9 @@ Querya loads themes from the **application support** directory (see | `{appSupport}/themes/imported/` | Legacy import subdirectory (still scanned) | | `assets/themes/` (bundled) | Built-in themes shipped with the app (e.g. Cyberpunk Neon) | -`{appSupport}` is the OS-specific support folder for Querya Desktop -(`com.example.querya_desktop`). Typical examples: +`{appSupport}` is normally the OS-specific support folder for Querya Desktop +(`com.example.querya_desktop`). In portable mode it is the `QueryaData/` +sidecar next to the binary — see [packaging.md](packaging.md). Typical OS examples: | OS | Example path | |----|----------------| diff --git a/lib/core/extensions/extension_paths.dart b/lib/core/extensions/extension_paths.dart index d2ef4ef4..a06ba1c4 100644 --- a/lib/core/extensions/extension_paths.dart +++ b/lib/core/extensions/extension_paths.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; +import 'package:querya_desktop/core/storage/app_data_root.dart'; /// Centralizes extension file locations. abstract final class ExtensionPaths { @@ -11,15 +11,20 @@ abstract final class ExtensionPaths { @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. + /// Portable: `{QueryaData}/extensions`. + /// Otherwise: `~/.querya/extensions` (or app-support fallback if HOME is missing). static Future extensionsDirectory() async { if (mockExtensionsDirectory != null) { return mockExtensionsDirectory!; } - final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + final portable = await AppDataRoot.resolvePortableRoot(); + if (portable != null) { + return Directory(p.join(portable.path, _extensionsSegment)); + } + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null || home.isEmpty) { - final support = await getApplicationSupportDirectory(); + final support = await AppDataRoot.applicationSupportDirectory(); return Directory(p.join(support.path, _extensionsSegment)); } return Directory(p.join(home, '.querya', _extensionsSegment)); diff --git a/lib/core/extensions/sandbox/sandbox_log_paths.dart b/lib/core/extensions/sandbox/sandbox_log_paths.dart index 344d0bba..c7bbe4a6 100644 --- a/lib/core/extensions/sandbox/sandbox_log_paths.dart +++ b/lib/core/extensions/sandbox/sandbox_log_paths.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; +import 'package:querya_desktop/core/storage/app_data_root.dart'; /// Resolves sandbox log directories (Block E §6). abstract final class SandboxLogPaths { @@ -13,11 +13,18 @@ abstract final class SandboxLogPaths { @visibleForTesting static Directory? mockLogsDirectory; - /// `~/.local/share/Querya/logs` (or application support fallback / test mock). + /// Portable: `{QueryaData}/logs`. + /// Otherwise: `~/.local/share/Querya/logs` (or OS equivalents / app-support fallback). static Future logsDirectory() async { if (mockLogsDirectory != null) return mockLogsDirectory!; - final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + final portable = await AppDataRoot.resolvePortableRoot(); + if (portable != null) { + return Directory(p.join(portable.path, logsSegment)); + } + + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home != null && home.isNotEmpty) { if (Platform.isLinux) { final xdg = Platform.environment['XDG_DATA_HOME']; @@ -32,12 +39,13 @@ abstract final class SandboxLogPaths { ); } if (Platform.isWindows) { - final appData = Platform.environment['APPDATA'] ?? p.join(home, 'AppData', 'Roaming'); + final appData = + Platform.environment['APPDATA'] ?? p.join(home, 'AppData', 'Roaming'); return Directory(p.join(appData, 'Querya', logsSegment)); } } - final support = await getApplicationSupportDirectory(); + final support = await AppDataRoot.applicationSupportDirectory(); return Directory(p.join(support.path, logsSegment)); } diff --git a/lib/core/storage/app_data_root.dart b/lib/core/storage/app_data_root.dart new file mode 100644 index 00000000..a43d5e80 --- /dev/null +++ b/lib/core/storage/app_data_root.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Resolves where Querya stores local profile data (DB, themes, extensions, logs). +/// +/// Default: OS application-support paths. +/// Portable: when [envPortable] is truthy and/or a [sidecarDirName] folder exists +/// next to the binary (or `$APPIMAGE`), data goes under that folder. +/// +/// Secrets still use the OS keyring via `flutter_secure_storage` (not redirected). +abstract final class AppDataRoot { + static const envPortable = 'QUERYA_PORTABLE'; + static const sidecarDirName = 'QueryaData'; + + @visibleForTesting + static String? mockPortableRootPath; + + @visibleForTesting + static String? mockInstallDirectory; + + @visibleForTesting + static Map? mockEnvironment; + + @visibleForTesting + static void resetMocks() { + mockPortableRootPath = null; + mockInstallDirectory = null; + mockEnvironment = null; + } + + static Map get _env => + mockEnvironment ?? Platform.environment; + + /// Directory containing the running binary, or the AppImage file's parent. + static String? installDirectoryPath() { + if (mockInstallDirectory != null) return mockInstallDirectory; + final appImage = _env['APPIMAGE']; + if (appImage != null && appImage.trim().isNotEmpty) { + return p.dirname(appImage); + } + final exe = Platform.resolvedExecutable; + if (exe.isEmpty) return null; + return p.dirname(exe); + } + + static bool envRequestsPortable() { + final raw = _env[envPortable]; + if (raw == null) return false; + final v = raw.trim().toLowerCase(); + return v == '1' || v == 'true' || v == 'yes' || v == 'on'; + } + + /// Portable data root, or `null` when using normal OS support paths. + /// + /// When [envPortable] is set, creates [sidecarDirName] next to the install + /// directory if it does not exist yet. + static Future resolvePortableRoot() async { + if (mockPortableRootPath != null) { + return Directory(mockPortableRootPath!); + } + + final installDir = installDirectoryPath(); + if (installDir == null || installDir.isEmpty) return null; + + final sidecar = Directory(p.join(installDir, sidecarDirName)); + final forced = envRequestsPortable(); + + if (forced) { + if (!await sidecar.exists()) { + await sidecar.create(recursive: true); + } + return sidecar; + } + + if (await sidecar.exists()) { + return sidecar; + } + return null; + } + + static Future isPortableMode() async => + (await resolvePortableRoot()) != null; + + /// Application-support equivalent: portable root or [getApplicationSupportDirectory]. + static Future applicationSupportDirectory() async { + final portable = await resolvePortableRoot(); + if (portable != null) return portable; + return getApplicationSupportDirectory(); + } +} diff --git a/lib/core/storage/folders_storage.dart b/lib/core/storage/folders_storage.dart index cc064309..9f6fd44e 100644 --- a/lib/core/storage/folders_storage.dart +++ b/lib/core/storage/folders_storage.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:querya_desktop/core/storage/app_data_root.dart'; import 'local_db.dart'; @@ -38,7 +38,7 @@ class FoldersStorage { if (_migrationChecked) return; _migrationChecked = true; try { - final dir = await getApplicationSupportDirectory(); + final dir = await AppDataRoot.applicationSupportDirectory(); final sub = Directory('${dir.path}/querya_desktop'); final file = File('${sub.path}/$_legacyFileName'); if (!await file.exists()) return; diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 235fe714..0580c7ca 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; +import 'package:querya_desktop/core/storage/app_data_root.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; @@ -44,7 +44,7 @@ class LocalDb { if (_db != null && _db!.isOpen) return _db!; await initFfi(); if (_cachedDbPath == null) { - final dir = await getApplicationSupportDirectory(); + final dir = await AppDataRoot.applicationSupportDirectory(); final sub = Directory(p.join(dir.path, 'querya_desktop')); if (!await sub.exists()) await sub.create(recursive: true); _cachedDbPath = p.join(sub.path, _dbName); diff --git a/lib/core/theme/theme_import_service.dart b/lib/core/theme/theme_import_service.dart index 2073bc48..6165a8c6 100644 --- a/lib/core/theme/theme_import_service.dart +++ b/lib/core/theme/theme_import_service.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; +import 'package:querya_desktop/core/storage/app_data_root.dart'; import 'parser/vscode_theme_manifest.dart'; import 'theme_definition.dart'; @@ -88,7 +88,7 @@ abstract final class ThemeImportService { } static Future _storedThemeFile() async { - final support = await getApplicationSupportDirectory(); + final support = await AppDataRoot.applicationSupportDirectory(); return File(p.join(support.path, 'themes', storedFileName)); } } diff --git a/lib/core/theme/theme_paths.dart b/lib/core/theme/theme_paths.dart index 7a0f6811..dedb0cfc 100644 --- a/lib/core/theme/theme_paths.dart +++ b/lib/core/theme/theme_paths.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; +import 'package:querya_desktop/core/storage/app_data_root.dart'; /// Centralizes theme file locations under app support and legacy paths. abstract final class ThemePaths { @@ -10,7 +10,7 @@ abstract final class ThemePaths { /// App support `themes/` directory. Does not create the directory. static Future userThemesDirectory() async { - final support = await getApplicationSupportDirectory(); + final support = await AppDataRoot.applicationSupportDirectory(); return Directory(p.join(support.path, _themesSegment)); } diff --git a/test/core/storage/app_data_root_test.dart b/test/core/storage/app_data_root_test.dart new file mode 100644 index 00000000..8b2ec252 --- /dev/null +++ b/test/core/storage/app_data_root_test.dart @@ -0,0 +1,105 @@ +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/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_log_paths.dart'; +import 'package:querya_desktop/core/storage/app_data_root.dart'; +import 'package:querya_desktop/core/theme/theme_paths.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late Directory osSupport; + late Directory installDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_app_data_root_'); + osSupport = Directory(p.join(tempDir.path, 'os_support')); + await osSupport.create(recursive: true); + installDir = Directory(p.join(tempDir.path, 'install')); + await installDir.create(recursive: true); + + PathProviderPlatform.instance = _FakePathProvider(osSupport.path); + AppDataRoot.resetMocks(); + AppDataRoot.mockInstallDirectory = installDir.path; + ExtensionPaths.mockExtensionsDirectory = null; + SandboxLogPaths.mockLogsDirectory = null; + }); + + tearDown(() async { + AppDataRoot.resetMocks(); + ExtensionPaths.mockExtensionsDirectory = null; + SandboxLogPaths.mockLogsDirectory = null; + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('AppDataRoot', () { + test('defaults to OS application support when not portable', () async { + expect(await AppDataRoot.resolvePortableRoot(), isNull); + expect(await AppDataRoot.isPortableMode(), isFalse); + + final support = await AppDataRoot.applicationSupportDirectory(); + expect(support.path, osSupport.path); + }); + + test('QUERYA_PORTABLE creates QueryaData next to install dir', () async { + AppDataRoot.mockEnvironment = {'QUERYA_PORTABLE': '1'}; + + final portable = await AppDataRoot.resolvePortableRoot(); + expect(portable, isNotNull); + expect(portable!.path, p.join(installDir.path, 'QueryaData')); + expect(await portable.exists(), isTrue); + + final support = await AppDataRoot.applicationSupportDirectory(); + expect(support.path, portable.path); + }); + + test('existing QueryaData sidecar enables portable without env', () async { + final sidecar = Directory(p.join(installDir.path, 'QueryaData')); + await sidecar.create(recursive: true); + + final portable = await AppDataRoot.resolvePortableRoot(); + expect(portable!.path, sidecar.path); + expect(await AppDataRoot.isPortableMode(), isTrue); + }); + + test('APPIMAGE parent is used as install directory', () async { + final appImage = p.join(installDir.path, 'Querya.AppImage'); + AppDataRoot.mockInstallDirectory = null; + AppDataRoot.mockEnvironment = { + 'APPIMAGE': appImage, + 'QUERYA_PORTABLE': 'true', + }; + + final portable = await AppDataRoot.resolvePortableRoot(); + expect(portable!.path, p.join(installDir.path, 'QueryaData')); + }); + + test('themes and extensions redirect under portable root', () async { + AppDataRoot.mockEnvironment = {'QUERYA_PORTABLE': 'yes'}; + final portable = await AppDataRoot.resolvePortableRoot(); + + final themes = await ThemePaths.userThemesDirectory(); + expect(themes.path, p.join(portable!.path, 'themes')); + + final extensions = await ExtensionPaths.extensionsDirectory(); + expect(extensions.path, p.join(portable.path, 'extensions')); + + final logs = await SandboxLogPaths.logsDirectory(); + expect(logs.path, p.join(portable.path, 'logs')); + }); + }); +}