diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 075db68f..e918beac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,7 +78,10 @@ jobs: run: | $v = "${{ steps.version.outputs.version }}" $dir = "build\windows\x64\runner\Release" + New-Item -ItemType File -Path "$dir\.portable" -Force | Out-Null + New-Item -ItemType File -Path "$dir\portable.txt" -Force | Out-Null Compress-Archive -Path "$dir\*" -DestinationPath "Querya-Desktop-$v-windows.zip" + Remove-Item -Path "$dir\.portable", "$dir\portable.txt" -Force - name: Install Inno Setup run: choco install innosetup --no-progress -y @@ -143,7 +146,9 @@ jobs: - name: Zip Linux artifact (portable) run: | cd build/linux/x64/release/bundle + touch .portable portable.txt zip -r "${GITHUB_WORKSPACE}/Querya-Desktop-${{ steps.version.outputs.version }}-linux.zip" . + rm -f .portable portable.txt - name: Build Linux AppImage (installable) run: ./scripts/linux/build_appimage.sh diff --git a/docs/packaging.md b/docs/packaging.md index 040322ce..8eecbf83 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -5,21 +5,20 @@ Querya Desktop ships (and will ship) two download channels. See epic | Channel | Typical artifact | Profile data | |---------|------------------|--------------| -| **Portable** | `Querya-Desktop-{ver}-{os}.zip` (Flutter bundle) | OS app-support by default; optional sidecar — see below | +| **Portable** | `Querya-Desktop-{ver}-{os}.zip` (Flutter bundle) | Portable sidecar (`QueryaData/`) automatically via `.portable` marker | | **Installable** | AppImage, Windows setup, `.deb`, `.rpm`, Flatpak | 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`, …). +Official release zip archives include a `.portable` marker file out-of-the-box. +On launch, Querya detects the marker (or explicit `QueryaData` / `QUERYA_PORTABLE=1`) +and automatically creates `QueryaData/` next to the binary if it does not exist yet. -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. +To run in portable mode: +1. Use the pre-packaged portable zip (contains `.portable` marker file out-of-the-box), +2. **or** create a `.portable` or `portable.txt` file next to the binary, +3. **or** create a folder named **`QueryaData`** next to the binary, +4. **or** set environment variable **`QUERYA_PORTABLE=1`** (also `true` / `yes` / `on`). Then local data is stored under that folder: diff --git a/lib/core/storage/app_data_root.dart b/lib/core/storage/app_data_root.dart index 110f9d92..c62cccab 100644 --- a/lib/core/storage/app_data_root.dart +++ b/lib/core/storage/app_data_root.dart @@ -14,6 +14,8 @@ import 'package:path_provider/path_provider.dart'; abstract final class AppDataRoot { static const envPortable = 'QUERYA_PORTABLE'; static const sidecarDirName = 'QueryaData'; + static const portableMarkerFile = '.portable'; + static const portableMarkerAlt = 'portable.txt'; /// Current Linux GTK / XDG application id. static const linuxApplicationId = 'com.queryahub.querya_desktop'; @@ -83,8 +85,11 @@ abstract final class AppDataRoot { final sidecar = Directory(p.join(installDir, sidecarDirName)); final forced = envRequestsPortable(); + final hasMarker = + await File(p.join(installDir, portableMarkerFile)).exists() || + await File(p.join(installDir, portableMarkerAlt)).exists(); - if (forced) { + if (forced || hasMarker) { if (!await sidecar.exists()) { await sidecar.create(recursive: true); } diff --git a/lib/features/settings/preferences_about_section.dart b/lib/features/settings/preferences_about_section.dart index fbbbefd2..c4f73e86 100644 --- a/lib/features/settings/preferences_about_section.dart +++ b/lib/features/settings/preferences_about_section.dart @@ -83,6 +83,50 @@ class PreferencesAboutSection extends StatelessWidget { ).muted().xSmall(), const material.SizedBox(height: 12), + FutureBuilder( + future: AppDataRoot.isPortableMode(), + builder: (context, snapshot) { + final isPortable = snapshot.data ?? false; + if (!isPortable) return const SizedBox.shrink(); + return material.Container( + margin: const material.EdgeInsets.only(bottom: 12), + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.25), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.3), + ), + ), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const QueryaBadge.status( + 'PORTABLE', + status: QueryaBadgeStatus.info, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Portable Mode Active') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 2), + const Text( + 'Data, settings, and themes are stored in the local QueryaData folder. Connection passwords remain protected in the host OS keyring.', + ).muted().xSmall(), + ], + ), + ), + ], + ), + ); + }, + ), + material.Wrap( spacing: 8, runSpacing: 8, diff --git a/test/core/storage/app_data_root_test.dart b/test/core/storage/app_data_root_test.dart index 6a9dc1ed..b52d44ff 100644 --- a/test/core/storage/app_data_root_test.dart +++ b/test/core/storage/app_data_root_test.dart @@ -78,6 +78,28 @@ void main() { expect(await AppDataRoot.isPortableMode(), isTrue); }); + test('.portable marker file automatically enables portable mode and creates QueryaData', () async { + final marker = File(p.join(installDir.path, '.portable')); + await marker.writeAsString(''); + + final portable = await AppDataRoot.resolvePortableRoot(); + expect(portable, isNotNull); + expect(portable!.path, p.join(installDir.path, 'QueryaData')); + expect(await portable.exists(), isTrue); + expect(await AppDataRoot.isPortableMode(), isTrue); + }); + + test('portable.txt marker file enables portable mode', () async { + final marker = File(p.join(installDir.path, 'portable.txt')); + await marker.writeAsString(''); + + final portable = await AppDataRoot.resolvePortableRoot(); + expect(portable, isNotNull); + expect(portable!.path, p.join(installDir.path, 'QueryaData')); + expect(await portable.exists(), isTrue); + 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;