Skip to content

Commit 1ebb8d2

Browse files
Merge pull request #387 from QueryaHub/issue/380-portable-data-root
feat(storage): portable profile root (QUERYA_PORTABLE / QueryaData)
2 parents 6a0b0cb + 795f1bf commit 1ebb8d2

10 files changed

Lines changed: 277 additions & 20 deletions

File tree

docs/packaging.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Packaging: portable vs installable
2+
3+
Querya Desktop ships (and will ship) two download channels. See epic
4+
[#379](https://github.com/QueryaHub/Querya-Desktop/issues/379).
5+
6+
| Channel | Typical artifact | Profile data |
7+
|---------|------------------|--------------|
8+
| **Portable** | `Querya-Desktop-{ver}-{os}.zip` (Flutter bundle) | OS app-support by default; optional sidecar — see below |
9+
| **Installable** | AppImage, Windows setup, deb/rpm/Flatpak (planned) | Normal OS locations |
10+
11+
## Portable profile (`QueryaData`)
12+
13+
By default the zip is a **relocatable binary** only: settings DB, themes, and
14+
extensions still use OS paths (`getApplicationSupportDirectory`,
15+
`~/.querya/extensions`, …).
16+
17+
To keep profile data next to the app (USB-style):
18+
19+
1. Set environment variable **`QUERYA_PORTABLE=1`** (also `true` / `yes` / `on`),
20+
**or**
21+
2. Create a folder named **`QueryaData`** next to `querya_desktop` /
22+
`querya_desktop.exe` / the `.AppImage` file.
23+
24+
Then local data is stored under that folder:
25+
26+
| Kind | Path under `QueryaData/` |
27+
|------|--------------------------|
28+
| SQLite DB | `querya_desktop/querya.db` |
29+
| Themes | `themes/` |
30+
| Extensions | `extensions/` |
31+
| Sandbox / audit logs | `logs/` |
32+
33+
On Linux AppImage, the install directory is the parent of `$APPIMAGE`.
34+
35+
### Secrets
36+
37+
Connection passwords remain in the **OS keyring** (`flutter_secure_storage` /
38+
libsecret / Credential Manager / Keychain). Portable mode does **not** move
39+
secrets into `QueryaData` in v1.
40+
41+
## Related code
42+
43+
- `lib/core/storage/app_data_root.dart` — detection and support-dir redirect
44+
- Updater packaging context: `lib/core/updater/installers/update_install_context.dart`
45+
- Release workflow: `.github/workflows/release.yml`

docs/theme-import.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ Querya loads themes from the **application support** directory (see
7676
| `{appSupport}/themes/imported/` | Legacy import subdirectory (still scanned) |
7777
| `assets/themes/` (bundled) | Built-in themes shipped with the app (e.g. Cyberpunk Neon) |
7878

79-
`{appSupport}` is the OS-specific support folder for Querya Desktop
80-
(`com.example.querya_desktop`). Typical examples:
79+
`{appSupport}` is normally the OS-specific support folder for Querya Desktop
80+
(`com.example.querya_desktop`). In portable mode it is the `QueryaData/`
81+
sidecar next to the binary — see [packaging.md](packaging.md). Typical OS examples:
8182

8283
| OS | Example path |
8384
|----|----------------|

lib/core/extensions/extension_paths.dart

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'dart:io';
22

33
import 'package:flutter/foundation.dart';
44
import 'package:path/path.dart' as p;
5-
import 'package:path_provider/path_provider.dart';
5+
import 'package:querya_desktop/core/storage/app_data_root.dart';
66

77
/// Centralizes extension file locations.
88
abstract final class ExtensionPaths {
@@ -11,15 +11,20 @@ abstract final class ExtensionPaths {
1111
@visibleForTesting
1212
static Directory? mockExtensionsDirectory;
1313

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.
14+
/// Portable: `{QueryaData}/extensions`.
15+
/// Otherwise: `~/.querya/extensions` (or app-support fallback if HOME is missing).
1616
static Future<Directory> extensionsDirectory() async {
1717
if (mockExtensionsDirectory != null) {
1818
return mockExtensionsDirectory!;
1919
}
20-
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
20+
final portable = await AppDataRoot.resolvePortableRoot();
21+
if (portable != null) {
22+
return Directory(p.join(portable.path, _extensionsSegment));
23+
}
24+
final home =
25+
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
2126
if (home == null || home.isEmpty) {
22-
final support = await getApplicationSupportDirectory();
27+
final support = await AppDataRoot.applicationSupportDirectory();
2328
return Directory(p.join(support.path, _extensionsSegment));
2429
}
2530
return Directory(p.join(home, '.querya', _extensionsSegment));

lib/core/extensions/sandbox/sandbox_log_paths.dart

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'dart:io';
22

33
import 'package:flutter/foundation.dart';
44
import 'package:path/path.dart' as p;
5-
import 'package:path_provider/path_provider.dart';
5+
import 'package:querya_desktop/core/storage/app_data_root.dart';
66

77
/// Resolves sandbox log directories (Block E §6).
88
abstract final class SandboxLogPaths {
@@ -13,11 +13,18 @@ abstract final class SandboxLogPaths {
1313
@visibleForTesting
1414
static Directory? mockLogsDirectory;
1515

16-
/// `~/.local/share/Querya/logs` (or application support fallback / test mock).
16+
/// Portable: `{QueryaData}/logs`.
17+
/// Otherwise: `~/.local/share/Querya/logs` (or OS equivalents / app-support fallback).
1718
static Future<Directory> logsDirectory() async {
1819
if (mockLogsDirectory != null) return mockLogsDirectory!;
1920

20-
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
21+
final portable = await AppDataRoot.resolvePortableRoot();
22+
if (portable != null) {
23+
return Directory(p.join(portable.path, logsSegment));
24+
}
25+
26+
final home =
27+
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
2128
if (home != null && home.isNotEmpty) {
2229
if (Platform.isLinux) {
2330
final xdg = Platform.environment['XDG_DATA_HOME'];
@@ -32,12 +39,13 @@ abstract final class SandboxLogPaths {
3239
);
3340
}
3441
if (Platform.isWindows) {
35-
final appData = Platform.environment['APPDATA'] ?? p.join(home, 'AppData', 'Roaming');
42+
final appData =
43+
Platform.environment['APPDATA'] ?? p.join(home, 'AppData', 'Roaming');
3644
return Directory(p.join(appData, 'Querya', logsSegment));
3745
}
3846
}
3947

40-
final support = await getApplicationSupportDirectory();
48+
final support = await AppDataRoot.applicationSupportDirectory();
4149
return Directory(p.join(support.path, logsSegment));
4250
}
4351

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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+
/// Resolves where Querya stores local profile data (DB, themes, extensions, logs).
8+
///
9+
/// Default: OS application-support paths.
10+
/// Portable: when [envPortable] is truthy and/or a [sidecarDirName] folder exists
11+
/// next to the binary (or `$APPIMAGE`), data goes under that folder.
12+
///
13+
/// Secrets still use the OS keyring via `flutter_secure_storage` (not redirected).
14+
abstract final class AppDataRoot {
15+
static const envPortable = 'QUERYA_PORTABLE';
16+
static const sidecarDirName = 'QueryaData';
17+
18+
@visibleForTesting
19+
static String? mockPortableRootPath;
20+
21+
@visibleForTesting
22+
static String? mockInstallDirectory;
23+
24+
@visibleForTesting
25+
static Map<String, String>? mockEnvironment;
26+
27+
@visibleForTesting
28+
static void resetMocks() {
29+
mockPortableRootPath = null;
30+
mockInstallDirectory = null;
31+
mockEnvironment = null;
32+
}
33+
34+
static Map<String, String> get _env =>
35+
mockEnvironment ?? Platform.environment;
36+
37+
/// Directory containing the running binary, or the AppImage file's parent.
38+
static String? installDirectoryPath() {
39+
if (mockInstallDirectory != null) return mockInstallDirectory;
40+
final appImage = _env['APPIMAGE'];
41+
if (appImage != null && appImage.trim().isNotEmpty) {
42+
return p.dirname(appImage);
43+
}
44+
final exe = Platform.resolvedExecutable;
45+
if (exe.isEmpty) return null;
46+
return p.dirname(exe);
47+
}
48+
49+
static bool envRequestsPortable() {
50+
final raw = _env[envPortable];
51+
if (raw == null) return false;
52+
final v = raw.trim().toLowerCase();
53+
return v == '1' || v == 'true' || v == 'yes' || v == 'on';
54+
}
55+
56+
/// Portable data root, or `null` when using normal OS support paths.
57+
///
58+
/// When [envPortable] is set, creates [sidecarDirName] next to the install
59+
/// directory if it does not exist yet.
60+
static Future<Directory?> resolvePortableRoot() async {
61+
if (mockPortableRootPath != null) {
62+
return Directory(mockPortableRootPath!);
63+
}
64+
65+
final installDir = installDirectoryPath();
66+
if (installDir == null || installDir.isEmpty) return null;
67+
68+
final sidecar = Directory(p.join(installDir, sidecarDirName));
69+
final forced = envRequestsPortable();
70+
71+
if (forced) {
72+
if (!await sidecar.exists()) {
73+
await sidecar.create(recursive: true);
74+
}
75+
return sidecar;
76+
}
77+
78+
if (await sidecar.exists()) {
79+
return sidecar;
80+
}
81+
return null;
82+
}
83+
84+
static Future<bool> isPortableMode() async =>
85+
(await resolvePortableRoot()) != null;
86+
87+
/// Application-support equivalent: portable root or [getApplicationSupportDirectory].
88+
static Future<Directory> applicationSupportDirectory() async {
89+
final portable = await resolvePortableRoot();
90+
if (portable != null) return portable;
91+
return getApplicationSupportDirectory();
92+
}
93+
}

lib/core/storage/folders_storage.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'dart:convert';
22
import 'dart:io';
33

44
import 'package:flutter/foundation.dart';
5-
import 'package:path_provider/path_provider.dart';
5+
import 'package:querya_desktop/core/storage/app_data_root.dart';
66

77
import 'local_db.dart';
88

@@ -38,7 +38,7 @@ class FoldersStorage {
3838
if (_migrationChecked) return;
3939
_migrationChecked = true;
4040
try {
41-
final dir = await getApplicationSupportDirectory();
41+
final dir = await AppDataRoot.applicationSupportDirectory();
4242
final sub = Directory('${dir.path}/querya_desktop');
4343
final file = File('${sub.path}/$_legacyFileName');
4444
if (!await file.exists()) return;

lib/core/storage/local_db.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'dart:io';
22

33
import 'package:path/path.dart' as p;
4-
import 'package:path_provider/path_provider.dart';
4+
import 'package:querya_desktop/core/storage/app_data_root.dart';
55
import 'package:querya_desktop/core/storage/connection_secrets_store.dart';
66
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
77

@@ -44,7 +44,7 @@ class LocalDb {
4444
if (_db != null && _db!.isOpen) return _db!;
4545
await initFfi();
4646
if (_cachedDbPath == null) {
47-
final dir = await getApplicationSupportDirectory();
47+
final dir = await AppDataRoot.applicationSupportDirectory();
4848
final sub = Directory(p.join(dir.path, 'querya_desktop'));
4949
if (!await sub.exists()) await sub.create(recursive: true);
5050
_cachedDbPath = p.join(sub.path, _dbName);

lib/core/theme/theme_import_service.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'dart:io';
22

33
import 'package:path/path.dart' as p;
4-
import 'package:path_provider/path_provider.dart';
4+
import 'package:querya_desktop/core/storage/app_data_root.dart';
55

66
import 'parser/vscode_theme_manifest.dart';
77
import 'theme_definition.dart';
@@ -88,7 +88,7 @@ abstract final class ThemeImportService {
8888
}
8989

9090
static Future<File> _storedThemeFile() async {
91-
final support = await getApplicationSupportDirectory();
91+
final support = await AppDataRoot.applicationSupportDirectory();
9292
return File(p.join(support.path, 'themes', storedFileName));
9393
}
9494
}

lib/core/theme/theme_paths.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'dart:io';
22

33
import 'package:path/path.dart' as p;
4-
import 'package:path_provider/path_provider.dart';
4+
import 'package:querya_desktop/core/storage/app_data_root.dart';
55

66
/// Centralizes theme file locations under app support and legacy paths.
77
abstract final class ThemePaths {
@@ -10,7 +10,7 @@ abstract final class ThemePaths {
1010

1111
/// App support `themes/` directory. Does not create the directory.
1212
static Future<Directory> userThemesDirectory() async {
13-
final support = await getApplicationSupportDirectory();
13+
final support = await AppDataRoot.applicationSupportDirectory();
1414
return Directory(p.join(support.path, _themesSegment));
1515
}
1616

0 commit comments

Comments
 (0)