Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/packaging.md
Original file line number Diff line number Diff line change
@@ -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`
5 changes: 3 additions & 2 deletions docs/theme-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|----|----------------|
Expand Down
15 changes: 10 additions & 5 deletions lib/core/extensions/extension_paths.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Directory> 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));
Expand Down
18 changes: 13 additions & 5 deletions lib/core/extensions/sandbox/sandbox_log_paths.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Directory> 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'];
Expand All @@ -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));
}

Expand Down
93 changes: 93 additions & 0 deletions lib/core/storage/app_data_root.dart
Original file line number Diff line number Diff line change
@@ -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<String, String>? mockEnvironment;

@visibleForTesting
static void resetMocks() {
mockPortableRootPath = null;
mockInstallDirectory = null;
mockEnvironment = null;
}

static Map<String, String> 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<Directory?> 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<bool> isPortableMode() async =>
(await resolvePortableRoot()) != null;

/// Application-support equivalent: portable root or [getApplicationSupportDirectory].
static Future<Directory> applicationSupportDirectory() async {
final portable = await resolvePortableRoot();
if (portable != null) return portable;
return getApplicationSupportDirectory();
}
}
4 changes: 2 additions & 2 deletions lib/core/storage/folders_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions lib/core/storage/local_db.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions lib/core/theme/theme_import_service.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -88,7 +88,7 @@ abstract final class ThemeImportService {
}

static Future<File> _storedThemeFile() async {
final support = await getApplicationSupportDirectory();
final support = await AppDataRoot.applicationSupportDirectory();
return File(p.join(support.path, 'themes', storedFileName));
}
}
4 changes: 2 additions & 2 deletions lib/core/theme/theme_paths.dart
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -10,7 +10,7 @@ abstract final class ThemePaths {

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

Expand Down
Loading
Loading