From 7db575a35b8cce0bb565243c0743d8804eb116b9 Mon Sep 17 00:00:00 2001 From: Feraris20 <222025665+Feraris20@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:49:36 +0200 Subject: [PATCH 1/2] Add marker-based portable storage for release archives --- README.md | 18 ++ build/build-installer.ps1 | 10 +- build/build-portable.ps1 | 2 + build/smoke-portable.ps1 | 3 + docs/ARCHITECTURE.md | 12 ++ installer/linux/INSTALL.txt | 3 + installer/linux/install.sh | 3 + installer/macos/INSTALL.txt | 3 + src/Filee.App/Assets/i18n/ar.json | 2 + src/Filee.App/Assets/i18n/de.json | 2 + src/Filee.App/Assets/i18n/en.json | 2 + src/Filee.App/Assets/i18n/es.json | 2 + src/Filee.App/Assets/i18n/fr.json | 2 + src/Filee.App/Assets/i18n/hi.json | 2 + src/Filee.App/Assets/i18n/id.json | 2 + src/Filee.App/Assets/i18n/it.json | 2 + src/Filee.App/Assets/i18n/ja.json | 2 + src/Filee.App/Assets/i18n/ko.json | 2 + src/Filee.App/Assets/i18n/ne.json | 2 + src/Filee.App/Assets/i18n/pl.json | 2 + src/Filee.App/Assets/i18n/pt-BR.json | 2 + src/Filee.App/Assets/i18n/pt-PT.json | 2 + src/Filee.App/Assets/i18n/ru.json | 2 + src/Filee.App/Assets/i18n/th.json | 2 + src/Filee.App/Assets/i18n/tr.json | 2 + src/Filee.App/Assets/i18n/vi.json | 2 + src/Filee.App/Assets/i18n/zh-CN.json | 2 + src/Filee.App/Assets/i18n/zh-TW.json | 2 + src/Filee.App/PortableStartupFailure.cs | 52 ++++++ src/Filee.App/Program.cs | 9 + src/Filee.App/Services/UpdateService.cs | 13 +- .../ViewModels/Pages/GeneralPageViewModel.cs | 12 +- src/Filee.App/Views/Pages/GeneralPage.axaml | 10 +- src/Filee.Cli/Program.cs | 9 + src/Filee.Core/Settings/PortableMode.cs | 98 ++++++++++ src/Filee.Core/Settings/UserDataStore.cs | 4 + tests/Filee.App.Tests/PortableModeUiTests.cs | 81 ++++++++ .../Filee.App.Tests/SystemIntegrationTests.cs | 30 +++ tests/Filee.Core.Tests/PortableModeTests.cs | 173 ++++++++++++++++++ .../LinuxInstallerTests.cs | 5 + 40 files changed, 576 insertions(+), 14 deletions(-) create mode 100644 src/Filee.App/PortableStartupFailure.cs create mode 100644 src/Filee.Core/Settings/PortableMode.cs create mode 100644 tests/Filee.App.Tests/PortableModeUiTests.cs create mode 100644 tests/Filee.Core.Tests/PortableModeTests.cs diff --git a/README.md b/README.md index 162134f6a..9f3531a40 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,24 @@ winget install KnifeLemon.Filee - Drag gestures need X11. On Wayland, use the drop zone and the file manager's right-click menu. +### True portable mode + +Portable release archives include an empty `filee.portable` beside `Filee.exe` (or the `Filee` executable on Linux). +For other copies, create this marker before starting Filee to enable portable mode. +On macOS, put the marker beside `Filee.app`, outside the bundle. Settings, downloaded engines and logs then live +in a `data` folder beside the marker. Both the app and CLI use it. Login startup and file-manager integration +are disabled, leaving an installed copy's entries untouched. The data folder must be writable; Filee reports +an error instead of falling back to the user profile. + +Conversion work folders, temporary files and engine caches keep their existing behavior and locations. +An explicit `FILEE_DATA_DIR` still takes precedence. +Installer packages keep using the normal user profile. Existing profile data is not imported automatically. +Update a marked copy by replacing its application files while keeping `filee.portable` and `data`. + +To bring existing data along, close Filee first, copy the existing settings/library files into the new `data` +folder, and copy downloaded engines into `data/engines`. The original copies are left alone. Custom engine +paths remain external; downloaded copies travel with the portable folder. + Filee shows new releases in its menu and updates itself, keeping your settings and engines. After an update, a few slides show what's new. diff --git a/build/build-installer.ps1 b/build/build-installer.ps1 index aeb6240cf..bfe8660e4 100644 --- a/build/build-installer.ps1 +++ b/build/build-installer.ps1 @@ -11,8 +11,8 @@ Setup itself (src/Filee.Setup) is published as one self-contained, trimmed program (no .NET needed on the PC); build/tools/make-setup.cs appends the publish folder to it as an LZMA payload with a SHA-256 manifest, and the - engines Setup offers from EngineDownloads. The portable zip is the publish folder as it is: without Setup's - uninstall.exe next to Filee.exe, Filee knows it is a portable copy. + engines Setup offers from EngineDownloads. The portable zip adds filee.portable beside Filee.exe; the installer + payload does not include this marker. .PARAMETER Runtime win-x64 (default) or win-arm64. The ARM64 build (Filee--win-arm64-Setup.exe and -Portable.zip) has an ARM64 @@ -195,6 +195,12 @@ if ($Portable) { Write-Host "zip $zip" Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::CreateFromDirectory($PublishDir, $zip, [System.IO.Compression.CompressionLevel]::Optimal, $false) + # Add only to the archive: the publish folder also supplies Setup's installed payload. + $archive = [System.IO.Compression.ZipFile]::Open($zip, [System.IO.Compression.ZipArchiveMode]::Update) + try { + if (-not $archive.GetEntry('filee.portable')) { $null = $archive.CreateEntry('filee.portable') } + } + finally { $archive.Dispose() } $files += $zip } diff --git a/build/build-portable.ps1 b/build/build-portable.ps1 index 3d40a00bc..28efc5563 100644 --- a/build/build-portable.ps1 +++ b/build/build-portable.ps1 @@ -177,6 +177,8 @@ else { Copy-Item -LiteralPath (Join-Path $root 'docs/GNOME-WAYLAND.md') -Destination $package } Invoke-Checked 'Installer permissions' { chmod +x (Join-Path $package 'install.sh') (Join-Path $package 'uninstall.sh') } +# Keep the marker outside the signed macOS bundle. The install scripts keep installed copies separate. +New-Item -ItemType File -Path (Join-Path $package 'filee.portable') | Out-Null $temporaryArchive = "$archive.$([Guid]::NewGuid().ToString('N')).partial" Invoke-Checked 'Create tar.gz' { tar -czf $temporaryArchive -C $stage $packageName } Move-Item -LiteralPath $temporaryArchive -Destination $archive diff --git a/build/smoke-portable.ps1 b/build/smoke-portable.ps1 index 9cc707a29..b30ace459 100644 --- a/build/smoke-portable.ps1 +++ b/build/smoke-portable.ps1 @@ -31,6 +31,9 @@ New-Item -ItemType Directory -Path $WorkDir | Out-Null $smoke = (Resolve-Path -LiteralPath $WorkDir).Path Invoke-Checked 'Archive extraction' { tar -xzf $archive.FullName -C $smoke } $package = Join-Path $smoke ($archive.Name.Replace('.tar.gz', '')) +if (-not (Test-Path -LiteralPath (Join-Path $package 'filee.portable') -PathType Leaf)) { + throw 'Portable archive is missing filee.portable.' +} if ($IsMacOS) { Invoke-Checked 'Extracted app signature' { codesign --verify --deep --strict (Join-Path $package 'Filee.app') } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index debabe1fd..5e61d2175 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -374,6 +374,18 @@ hidden rows and manual breaks, then see the PDF that those settings will produce ## Settings and data +`PortableMode.Initialize` runs in both entry points before logging, settings or engine discovery. A +`filee.portable` marker selects the adjacent `data` directory through `FILEE_DATA_DIR`; an explicit override +keeps precedence. macOS stores the marker/data outside `.app` to preserve the signed bundle. Startup validates +write/delete access and reports failure without loading the normal user profile. Portable settings turn off +login and file-manager integration; the existing separate-data guard prevents registration and unregistration. +The General page explains this and disables those controls. + +Conversion work folders, temporary files and engine caches keep their existing behavior and locations. +Portable mode does not change the environment passed to external engines. +Portable release archives include the marker by default. Installer payloads keep normal profile storage; +existing settings and engines are not imported automatically. + `UserDataStore` owns `settings.json`, `presets.json`, `profiles.json` and `history.json` (System.Text.Json source generation, atomic writes, broken files are kept as `*.broken`). Bump `AppSettings.CurrentSchemaVersion` and add a step to `SettingsMigrations` when the format changes. diff --git a/installer/linux/INSTALL.txt b/installer/linux/INSTALL.txt index 20a40378e..8a153f73e 100644 --- a/installer/linux/INSTALL.txt +++ b/installer/linux/INSTALL.txt @@ -1,4 +1,7 @@ Run ./Filee to test the extracted app, or ./filee-cli help for the command line. +The included filee.portable marker keeps settings, downloaded engines and logs +in data beside the executable. Existing profile data is not imported automatically. +Installing with install.sh uses the normal user profile instead. Run ./install.sh to install for the current user. It requires Python 3, creates an application launcher and ~/.local/bin/filee, and does not require root. diff --git a/installer/linux/install.sh b/installer/linux/install.sh index 890bbdb9d..df1cce030 100755 --- a/installer/linux/install.sh +++ b/installer/linux/install.sh @@ -135,6 +135,8 @@ if [ "$upgrade" -eq 0 ]; then mkdir -p "$data_dir/filee" "$bin_dir" "$data_dir/applications" cp -R "$source_dir" "$install_dir" + # The archive is portable; an installed copy uses the ordinary user profile. + rm -f "$install_dir/filee.portable" printf '%s\n' 'com.filee.app' > "$install_dir/.filee-package" rm -f "$install_dir/.filee-upgrade-token" ln -s "$install_dir/filee-cli" "$bin_dir/filee" @@ -186,6 +188,7 @@ else # Copy incoming payload into staging (preserves source_dir without moving/deleting it). cp -R "$source_dir/." "$staging_dir/" + rm -f "$staging_dir/filee.portable" printf '%s\n' 'com.filee.app' > "$staging_dir/.filee-package" printf '%s\n' "$upgrade_token" > "$staging_dir/.filee-upgrade-token" chmod +x "$staging_dir/Filee" "$staging_dir/filee-cli" diff --git a/installer/macos/INSTALL.txt b/installer/macos/INSTALL.txt index 156ff4f48..246e39438 100644 --- a/installer/macos/INSTALL.txt +++ b/installer/macos/INSTALL.txt @@ -1,5 +1,8 @@ This package requires macOS 13 or later and contains a separate build for Apple Silicon or Intel Macs. The .NET runtime is included. +The tar.gz includes filee.portable beside Filee.app: running there keeps settings, +downloaded engines and logs in an adjacent data folder. Existing profile data is +not imported automatically. Installation and the .dmg use the normal user profile. Run ./install.sh to copy Filee.app to ~/Applications and add ~/.local/bin/filee. From the .dmg, drag Filee.app onto Applications instead (no filee command then). You can also move Filee.app into Applications manually. Keep its location stable diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 8b51aa6a0..380683bb9 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -535,6 +535,8 @@ "format.category.Data": "بيانات", "format.category.Model3D": "نموذج ثلاثي الأبعاد", "format.category.Geo": "بيانات جغرافية", + "startup.portable_unwritable": "لا يستطيع Filee الكتابة في مجلد البيانات المحمول:\n{0}\n\nانقل Filee إلى مجلد قابل للكتابة أو اسمح بالكتابة في هذا المجلد، ثم حاول مجددًا.", + "general.portable_mode": "الوضع المحمول: تُحفظ الإعدادات والمحركات المنزلة والسجلات في مجلد البيانات المحمول. بدء التشغيل التلقائي وتكامل مدير الملفات معطلان.", "general.title": "عام", "general.language": "اللغة", "general.language_auto": "لغة النظام", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index c14ff225b..ec7f7e0ee 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -535,6 +535,8 @@ "format.category.Data": "Daten", "format.category.Model3D": "3D-Modell", "format.category.Geo": "Geodaten", + "startup.portable_unwritable": "Filee kann nicht in seinen portablen Datenordner schreiben:\n{0}\n\nVerschieben Sie Filee in einen beschreibbaren Ordner oder erlauben Sie das Schreiben in diesen Ordner und versuchen Sie es erneut.", + "general.portable_mode": "Portabler Modus: Einstellungen, heruntergeladene Engines und Protokolle bleiben im portablen Datenordner. Autostart und Dateimanager-Integration sind deaktiviert.", "general.title": "Allgemein", "general.language": "Sprache", "general.language_auto": "Systemstandard", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 833c9efb2..af46f89d6 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -547,6 +547,8 @@ "format.category.Model3D": "3D model", "format.category.Geo": "Geospatial", + "startup.portable_unwritable": "Filee cannot write to its portable data folder:\n{0}\n\nMove Filee to a writable folder or make this folder writable, then try again.", + "general.portable_mode": "Portable mode: settings, downloaded engines and logs stay in the portable data folder. Startup and file-manager integration are disabled.", "general.title": "General", "general.language": "Language", "general.language_auto": "System default", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 016df3d3f..8a9a6e0f6 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -535,6 +535,8 @@ "format.category.Data": "Datos", "format.category.Model3D": "Modelo 3D", "format.category.Geo": "Geoespacial", + "startup.portable_unwritable": "Filee no puede escribir en su carpeta de datos portátil:\n{0}\n\nMueve Filee a una carpeta con permiso de escritura o habilita la escritura en esta carpeta y vuelve a intentarlo.", + "general.portable_mode": "Modo portátil: los ajustes, motores descargados y registros permanecen en la carpeta de datos portátil. El inicio automático y la integración con el gestor de archivos están desactivados.", "general.title": "General", "general.language": "Idioma", "general.language_auto": "Predeterminado del sistema", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index e6f341f8d..bba74e864 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -547,6 +547,8 @@ "format.category.Model3D": "Modèle 3D", "format.category.Geo": "Géospatial", + "startup.portable_unwritable": "Filee ne peut pas écrire dans son dossier de données portable :\n{0}\n\nDéplacez Filee vers un dossier accessible en écriture ou autorisez l’écriture dans ce dossier, puis réessayez.", + "general.portable_mode": "Mode portable : les paramètres, moteurs téléchargés et journaux restent dans le dossier de données portable. Le démarrage automatique et l’intégration au gestionnaire de fichiers sont désactivés.", "general.title": "Général", "general.language": "Langue", "general.language_auto": "Langue du système", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index c48453d97..046372f5e 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -535,6 +535,8 @@ "format.category.Data": "डेटा", "format.category.Model3D": "3D मॉडल", "format.category.Geo": "भू-स्थानिक", + "startup.portable_unwritable": "Filee अपने पोर्टेबल डेटा फ़ोल्डर में नहीं लिख सकता:\n{0}\n\nFilee को लिखने योग्य फ़ोल्डर में ले जाएँ या इस फ़ोल्डर में लिखने की अनुमति दें, फिर कोशिश करें।", + "general.portable_mode": "पोर्टेबल मोड: सेटिंग, डाउनलोड किए गए इंजन और लॉग पोर्टेबल डेटा फ़ोल्डर में रहते हैं। अपने आप शुरू होना और फ़ाइल प्रबंधक एकीकरण बंद हैं।", "general.title": "सामान्य", "general.language": "भाषा", "general.language_auto": "सिस्टम डिफ़ॉल्ट", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 2551da6ee..389ed84f3 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -535,6 +535,8 @@ "format.category.Data": "Data", "format.category.Model3D": "Model 3D", "format.category.Geo": "Geospasial", + "startup.portable_unwritable": "Filee tidak dapat menulis ke folder data portabelnya:\n{0}\n\nPindahkan Filee ke folder yang dapat ditulis atau izinkan penulisan ke folder ini, lalu coba lagi.", + "general.portable_mode": "Mode portabel: pengaturan, mesin unduhan, dan log tetap di folder data portabel. Mulai otomatis dan integrasi pengelola berkas dinonaktifkan.", "general.title": "Umum", "general.language": "Bahasa", "general.language_auto": "Bawaan sistem", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index 6e5128895..f6cc63487 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -534,6 +534,8 @@ "format.category.Data": "Dati", "format.category.Model3D": "Modello 3D", "format.category.Geo": "Geospaziale", + "startup.portable_unwritable": "Filee non può scrivere nella cartella dati portatile:\n{0}\n\nSposta Filee in una cartella scrivibile o consenti la scrittura in questa cartella, quindi riprova.", + "general.portable_mode": "Modalità portatile: impostazioni, motori scaricati e registri rimangono nella cartella dati portatile. Avvio automatico e integrazione con il gestore file sono disattivati.", "general.title": "Generali", "general.language": "Lingua", "general.language_auto": "Predefinita del sistema", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 6aa8429a1..b59225251 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -535,6 +535,8 @@ "format.category.Data": "データ", "format.category.Model3D": "3Dモデル", "format.category.Geo": "地理空間データ", + "startup.portable_unwritable": "Filee のポータブルデータフォルダーに書き込めません:\n{0}\n\nFilee を書き込み可能なフォルダーに移動するか、このフォルダーへの書き込みを許可してから、もう一度お試しください。", + "general.portable_mode": "ポータブルモード: 設定、ダウンロードしたエンジン、ログはポータブルデータフォルダーに保存されます。自動起動とファイルマネージャー連携は無効です。", "general.title": "一般", "general.language": "言語", "general.language_auto": "システム設定に合わせる", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index bb5d2873e..bd07bbc00 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -547,6 +547,8 @@ "format.category.Model3D": "3D 모델", "format.category.Geo": "지리 정보", + "startup.portable_unwritable": "Filee가 포터블 데이터 폴더에 쓸 수 없습니다:\n{0}\n\nFilee를 쓰기 가능한 폴더로 옮기거나 이 폴더에 쓰기를 허용한 후 다시 시도하세요.", + "general.portable_mode": "포터블 모드: 설정, 다운로드한 엔진과 로그는 포터블 데이터 폴더에 저장됩니다. 자동 시작과 파일 관리자 통합은 비활성화됩니다.", "general.title": "일반", "general.language": "언어", "general.language_auto": "시스템 기본값", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index dc49d2467..e0e292eda 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -535,6 +535,8 @@ "format.category.Data": "डाटा", "format.category.Model3D": "थ्रीडी मोडल", "format.category.Geo": "भौगोलिक डाटा", + "startup.portable_unwritable": "Filee ले आफ्नो पोर्टेबल डेटा फोल्डरमा लेख्न सकेन:\n{0}\n\nFilee लाई लेख्न मिल्ने फोल्डरमा सार्नुहोस् वा यो फोल्डरमा लेख्ने अनुमति दिनुहोस्, अनि फेरि प्रयास गर्नुहोस्।", + "general.portable_mode": "पोर्टेबल मोड: सेटिङ, डाउनलोड गरिएका इन्जिन र लग पोर्टेबल डेटा फोल्डरमा रहन्छन्। स्वतः सुरु र फाइल प्रबन्धक एकीकरण बन्द छन्।", "general.title": "सामान्य", "general.language": "भाषा", "general.language_auto": "प्रणाली पूर्वनिर्धारित", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 54ddb3c74..95ea3fd16 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -547,6 +547,8 @@ "format.category.Model3D": "Model 3D", "format.category.Geo": "Geoprzestrzenne", + "startup.portable_unwritable": "Filee nie może zapisywać w przenośnym folderze danych:\n{0}\n\nPrzenieś Filee do folderu z prawem zapisu lub zezwól na zapis w tym folderze i spróbuj ponownie.", + "general.portable_mode": "Tryb przenośny: ustawienia, pobrane silniki i dzienniki pozostają w przenośnym folderze danych. Autostart i integracja z menedżerem plików są wyłączone.", "general.title": "Ogólne", "general.language": "Język", "general.language_auto": "Domyślny systemu", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 87c5141e2..a6d8ddcf1 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -547,6 +547,8 @@ "format.category.Model3D": "Modelo 3D", "format.category.Geo": "Geoespacial", + "startup.portable_unwritable": "O Filee não consegue gravar na pasta de dados portátil:\n{0}\n\nMova o Filee para uma pasta com permissão de gravação ou permita a gravação nesta pasta e tente novamente.", + "general.portable_mode": "Modo portátil: configurações, mecanismos baixados e registros ficam na pasta de dados portátil. A inicialização automática e a integração com o gerenciador de arquivos estão desativadas.", "general.title": "Geral", "general.language": "Idioma", "general.language_auto": "Padrão do sistema", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index deb422396..8c30d054f 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -547,6 +547,8 @@ "format.category.Model3D": "Modelo 3D", "format.category.Geo": "Dados geoespaciais", + "startup.portable_unwritable": "O Filee não consegue escrever na pasta de dados portátil:\n{0}\n\nMova o Filee para uma pasta com permissão de escrita ou permita a escrita nesta pasta e tente novamente.", + "general.portable_mode": "Modo portátil: as definições, os motores transferidos e os registos ficam na pasta de dados portátil. O arranque automático e a integração com o gestor de ficheiros estão desativados.", "general.title": "Geral", "general.language": "Idioma", "general.language_auto": "Predefinição do sistema", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index ba0a62a18..a661fb6c3 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -547,6 +547,8 @@ "format.category.Model3D": "3D-модель", "format.category.Geo": "Геоданные", + "startup.portable_unwritable": "Filee не может записать данные в переносимую папку:\n{0}\n\nПереместите Filee в папку с правом записи или разрешите запись в эту папку и повторите попытку.", + "general.portable_mode": "Переносимый режим: настройки, загруженные движки и журналы остаются в переносимой папке данных. Автозапуск и интеграция с файловым менеджером отключены.", "general.title": "Общие", "general.language": "Язык", "general.language_auto": "Как в системе", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 69cd14a9c..99cb531ce 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -535,6 +535,8 @@ "format.category.Data": "ข้อมูล", "format.category.Model3D": "โมเดล 3D", "format.category.Geo": "ข้อมูลเชิงพื้นที่", + "startup.portable_unwritable": "Filee ไม่สามารถเขียนลงในโฟลเดอร์ข้อมูลแบบพกพาได้:\n{0}\n\nย้าย Filee ไปยังโฟลเดอร์ที่เขียนได้ หรืออนุญาตให้เขียนลงในโฟลเดอร์นี้ แล้วลองอีกครั้ง", + "general.portable_mode": "โหมดพกพา: การตั้งค่า เอนจินที่ดาวน์โหลด และบันทึกจะอยู่ในโฟลเดอร์ข้อมูลแบบพกพา การเริ่มอัตโนมัติและการผสานกับตัวจัดการไฟล์จะถูกปิด", "general.title": "ทั่วไป", "general.language": "ภาษา", "general.language_auto": "ตามระบบ", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 99a858611..bcfe28105 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -535,6 +535,8 @@ "format.category.Data": "Veri", "format.category.Model3D": "3B model", "format.category.Geo": "Coğrafi veri", + "startup.portable_unwritable": "Filee taşınabilir veri klasörüne yazamıyor:\n{0}\n\nFilee’yi yazılabilir bir klasöre taşıyın veya bu klasöre yazma izni verip tekrar deneyin.", + "general.portable_mode": "Taşınabilir mod: ayarlar, indirilen motorlar ve günlükler taşınabilir veri klasöründe kalır. Otomatik başlatma ve dosya yöneticisi tümleştirmesi devre dışıdır.", "general.title": "Genel", "general.language": "Dil", "general.language_auto": "Sistem varsayılanı", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index a4b74617e..de5850591 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -547,6 +547,8 @@ "format.category.Model3D": "Mô hình 3D", "format.category.Geo": "Không gian địa lý", + "startup.portable_unwritable": "Filee không thể ghi vào thư mục dữ liệu di động:\n{0}\n\nHãy chuyển Filee sang thư mục có thể ghi hoặc cấp quyền ghi cho thư mục này rồi thử lại.", + "general.portable_mode": "Chế độ di động: cài đặt, bộ máy đã tải và nhật ký nằm trong thư mục dữ liệu di động. Tự khởi động và tích hợp trình quản lý tệp bị tắt.", "general.title": "Chung", "general.language": "Ngôn ngữ", "general.language_auto": "Mặc định của hệ thống", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 9b2002caf..49d7e23e8 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -547,6 +547,8 @@ "format.category.Model3D": "3D 模型", "format.category.Geo": "地理信息", + "startup.portable_unwritable": "Filee 无法写入便携数据文件夹:\n{0}\n\n请将 Filee 移到可写文件夹,或允许写入此文件夹后重试。", + "general.portable_mode": "便携模式:设置、下载的引擎和日志保存在便携数据文件夹中。自动启动和文件管理器集成已禁用。", "general.title": "常规", "general.language": "语言", "general.language_auto": "跟随系统", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index f456ed208..207fa3d40 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -547,6 +547,8 @@ "format.category.Model3D": "3D 模型", "format.category.Geo": "地理資訊", + "startup.portable_unwritable": "Filee 無法寫入可攜資料夾:\n{0}\n\n請將 Filee 移至可寫入的資料夾,或允許寫入此資料夾後再試。", + "general.portable_mode": "可攜模式:設定、下載的引擎與記錄保存在可攜資料夾中。自動啟動及檔案管理員整合已停用。", "general.title": "一般", "general.language": "語言", "general.language_auto": "跟隨系統", diff --git a/src/Filee.App/PortableStartupFailure.cs b/src/Filee.App/PortableStartupFailure.cs new file mode 100644 index 000000000..eb3b12310 --- /dev/null +++ b/src/Filee.App/PortableStartupFailure.cs @@ -0,0 +1,52 @@ +// A portable copy with an unwritable data folder shows an error without loading settings or logging elsewhere. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Layout; +using Avalonia.Media; + +namespace Filee.App; + +internal static class PortableStartupFailure +{ + public static int Show(string message) + { + Console.Error.WriteLine(message); + if (OperatingSystem.IsWindows()) + Filee.Platform.Windows.Elevation.ShowWarning(message); + else + AppBuilder.Configure(() => new FailureApplication(message)).UsePlatformDetect() + .StartWithClassicDesktopLifetime([], ShutdownMode.OnMainWindowClose); + return 1; + } + + private sealed class FailureApplication(string message) : Application + { + public override void Initialize() => Styles.Add(new Avalonia.Themes.Fluent.FluentTheme()); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var close = new Button { Content = Services.LocalizationService.EarlyText("common.close"), HorizontalAlignment = HorizontalAlignment.Right }; + var window = new Window + { + Title = "Filee", + Width = 520, + SizeToContent = SizeToContent.Height, + CanResize = false, + Content = new StackPanel + { + Margin = new Thickness(24), + Spacing = 18, + Children = { new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, close }, + }, + }; + close.Click += (_, _) => window.Close(); + desktop.MainWindow = window; + } + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs index fee11680b..1ee0b9894 100644 --- a/src/Filee.App/Program.cs +++ b/src/Filee.App/Program.cs @@ -47,6 +47,15 @@ internal static class Program [STAThread] public static int Main(string[] args) { + IDisposable? portable; + try { portable = PortableMode.Initialize(); } + catch (PortableStorageException ex) + { + var message = string.Format(System.Globalization.CultureInfo.CurrentCulture, + LocalizationService.EarlyText("startup.portable_unwritable"), ex.DataDirectory); + return PortableStartupFailure.Show(message); + } + using var portableScope = portable; Options = CommandLine.Parse(args); PreloadedStore = null; Log = StartupDiagnostics.CreateEarlyLogger(Path.Combine(UserDataStore.DefaultDirectory, "logs"), out var earlyStoreLogger); diff --git a/src/Filee.App/Services/UpdateService.cs b/src/Filee.App/Services/UpdateService.cs index 58a1a759c..7d4ccac4e 100644 --- a/src/Filee.App/Services/UpdateService.cs +++ b/src/Filee.App/Services/UpdateService.cs @@ -94,15 +94,16 @@ internal UpdateService(ILogger log, HttpMessageHandler handler) /// The website: one big download button, friendlier than the GitHub release page. public const string WebsiteUrl = "https://filee.sh/"; - /// - /// True when this copy was installed by Setup on Windows (uninstall.exe; the Inno Setup installer of 1.x put unins000.exe there) - /// or by install.sh on Linux (.filee-package marker containing com.filee.app). - /// Only then can the new installer update it in place; the portable zip and a copy run from source have no uninstaller. - /// - public static bool IsInstalled { get; } = + private static readonly bool InstalledCopy = OperatingSystem.IsWindows() && IsInstalledIn(AppContext.BaseDirectory) || OperatingSystem.IsLinux() && IsLinuxInstalled(AppContext.BaseDirectory); + /// + /// Installed by Windows Setup or Linux install.sh, and not marked portable. A marked copy is updated manually + /// so an installer cannot register it or replace the user's installed copy. + /// + public static bool IsInstalled => !PortableMode.IsEnabled && InstalledCopy; + internal static bool IsInstalledIn(string folder) => File.Exists(Path.Combine(folder, "uninstall.exe")) || File.Exists(Path.Combine(folder, "unins000.exe")); diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs index f086dfdc0..3b8461f75 100644 --- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs @@ -13,6 +13,10 @@ namespace Filee.App.ViewModels.Pages; public sealed partial class GeneralPageViewModel : ObservableObject, IDisposable { + /// Portable copies leave installed startup and file-manager entries alone. + public bool IsPortable => PortableMode.IsEnabled; + public bool CanChangeSystemIntegration => !IsPortable; + public string PortableDataDirectory => PortableMode.DataDirectory ?? ""; private readonly UserDataStore _store; private readonly ILocalizer _loc; private readonly IPlatformServices _platform; @@ -163,9 +167,9 @@ private void OnSettingsChanged(object? sender, EventArgs e) [ObservableProperty] private string? _modernMenuStatus; - public bool ShowModernMenu => ContextMenu && ModernMenuState != ModernContextMenuState.Unsupported; - public bool CanAddModernMenu => !ModernMenuBusy && ModernMenuState is ModernContextMenuState.Off or ModernContextMenuState.Outdated; - public bool CanRemoveModernMenu => !ModernMenuBusy && ModernMenuState is ModernContextMenuState.On or ModernContextMenuState.Outdated; + public bool ShowModernMenu => CanChangeSystemIntegration && ContextMenu && ModernMenuState != ModernContextMenuState.Unsupported; + public bool CanAddModernMenu => CanChangeSystemIntegration && !ModernMenuBusy && ModernMenuState is ModernContextMenuState.Off or ModernContextMenuState.Outdated; + public bool CanRemoveModernMenu => CanChangeSystemIntegration && !ModernMenuBusy && ModernMenuState is ModernContextMenuState.On or ModernContextMenuState.Outdated; partial void OnLanguageChanged(Choice? oldValue, Choice newValue) { @@ -219,7 +223,7 @@ partial void OnContextMenuChanged(bool value) private async Task ChangeModernMenu(bool enabled) { - if (Environment.ProcessPath is not { } exe) + if (!CanChangeSystemIntegration || Environment.ProcessPath is not { } exe) return; ModernMenuBusy = true; ModernMenuStatus = _loc["general.modern_menu_busy"]; diff --git a/src/Filee.App/Views/Pages/GeneralPage.axaml b/src/Filee.App/Views/Pages/GeneralPage.axaml index e34190284..411bda872 100644 --- a/src/Filee.App/Views/Pages/GeneralPage.axaml +++ b/src/Filee.App/Views/Pages/GeneralPage.axaml @@ -5,6 +5,12 @@ x:DataType="vm:GeneralPageViewModel"> + + + + + + @@ -12,7 +18,7 @@ - + - + diff --git a/src/Filee.Cli/Program.cs b/src/Filee.Cli/Program.cs index 39676df5b..c4364260e 100644 --- a/src/Filee.Cli/Program.cs +++ b/src/Filee.Cli/Program.cs @@ -3,8 +3,17 @@ using System.Text; using Filee.Cli; +using Filee.Core.Settings; Console.OutputEncoding = Encoding.UTF8; +IDisposable? portable; +try { portable = PortableMode.Initialize(); } +catch (PortableStorageException ex) +{ + Console.Error.WriteLine(new EnglishTexts().Format("startup.portable_unwritable", ex.DataDirectory)); + return 1; +} +using var portableScope = portable; using var cancel = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { diff --git a/src/Filee.Core/Settings/PortableMode.cs b/src/Filee.Core/Settings/PortableMode.cs new file mode 100644 index 000000000..4de4f755a --- /dev/null +++ b/src/Filee.Core/Settings/PortableMode.cs @@ -0,0 +1,98 @@ +// Opt-in portable storage, selected before settings, logging or engines touch a user profile. + +namespace Filee.Core.Settings; + +/// Portable storage enabled by a filee.portable marker next to the application. +public static class PortableMode +{ + /// The marker shipped or created beside Filee. + public const string MarkerName = "filee.portable"; + + /// The selected portable data directory, or null in an ordinary installation. + public static string? DataDirectory { get; private set; } + + /// Whether this process was started with a portable marker. + public static bool IsEnabled => DataDirectory is not null; + + /// + /// Selects and checks portable storage before any profile-dependent service initializes. FILEE_DATA_DIR, + /// when explicitly set, retains precedence. Disposing the scope restores the previous data override. + /// + /// Package location, defaulting to the executable directory. + public static IDisposable? Initialize(string? applicationDirectory = null) + { + var data = Resolve(applicationDirectory ?? AppContext.BaseDirectory, UserDataStore.OverrideDirectory); + if (data is null) return null; + var previousData = Environment.GetEnvironmentVariable(UserDataStore.DirectoryVariable); + // Set this before probing: even the failure path must never open the installed copy's log or data. + DataDirectory = data; + Environment.SetEnvironmentVariable(UserDataStore.DirectoryVariable, data); + try + { + Validate(data); + return new DataScope(previousData); + } + catch + { + DataDirectory = null; + Environment.SetEnvironmentVariable(UserDataStore.DirectoryVariable, previousData); + throw; + } + } + + /// Finds a marked package; macOS data stays outside the signed .app bundle. + internal static string? Resolve(string baseDirectory, string? explicitDirectory) + { + var directory = new DirectoryInfo(Path.GetFullPath(baseDirectory)); + if (directory.Name == "MacOS" && directory.Parent?.Name == "Contents" + && directory.Parent.Parent is { } bundle && bundle.Name.EndsWith(".app", StringComparison.OrdinalIgnoreCase)) + directory = bundle.Parent ?? directory; + return File.Exists(Path.Combine(directory.FullName, MarkerName)) + ? UserDataStore.DirectoryFrom(explicitDirectory) ?? Path.Combine(directory.FullName, "data") + : null; + } + + /// Checks creation, writing and deletion needed for atomic settings saves. + internal static void Validate(string directory) + { + try + { + System.IO.Directory.CreateDirectory(directory); + using var probe = new FileStream(Path.Combine(directory, ".filee-write-" + Guid.NewGuid().ToString("N")), + FileMode.CreateNew, FileAccess.Write, FileShare.None, 1, FileOptions.DeleteOnClose); + probe.WriteByte(0); + probe.Flush(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new PortableStorageException(directory, ex); + } + } + + /// Portable copies cannot take ownership of an installed copy's login or file-manager entries. + internal static void DisableIntegration(AppSettings settings) + { + settings.StartWithSystem = false; + settings.ContextMenuEnabled = false; + } + + private sealed class DataScope(string? previousData) : IDisposable + { + private bool _disposed; + public void Dispose() + { + if (_disposed) return; + _disposed = true; + DataDirectory = null; + Environment.SetEnvironmentVariable(UserDataStore.DirectoryVariable, previousData); + } + } +} + +/// A marked package cannot use its selected data directory; no profile fallback is allowed. +public sealed class PortableStorageException(string dataDirectory, Exception innerException) + : IOException($"Portable data folder is not writable: {dataDirectory}", innerException) +{ + /// The data directory that must be made writable or moved. + public string DataDirectory { get; } = dataDirectory; +} diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index b41b403b3..c435bf76c 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -84,6 +84,8 @@ public void Load() var schema = Settings.SchemaVersion; SettingsMigrations.Apply(Settings); + if (PortableMode.IsEnabled) + PortableMode.DisableIntegration(Settings); var libraryChanged = File.Exists(ProfilesPath) && SettingsMigrations.ApplyToLibrary(schema, Presets, Profiles); EnsureConsistency(); @@ -99,6 +101,8 @@ public void Load() public void SaveSettings() { + if (PortableMode.IsEnabled) + PortableMode.DisableIntegration(Settings); Write(SettingsPath, Settings, FileeJsonContext.Default.AppSettings); SettingsChanged?.Invoke(this, EventArgs.Empty); } diff --git a/tests/Filee.App.Tests/PortableModeUiTests.cs b/tests/Filee.App.Tests/PortableModeUiTests.cs new file mode 100644 index 000000000..1c8019ab1 --- /dev/null +++ b/tests/Filee.App.Tests/PortableModeUiTests.cs @@ -0,0 +1,81 @@ +// Portable settings clearly disable integration and retain the ordinary user interface elsewhere. + +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using Filee.App.Services; +using Filee.App.ViewModels.Pages; +using Filee.App.Views.Pages; +using Filee.Core.Localization; +using Filee.Core.Platform; +using Filee.Core.Settings; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.App.Tests; + +[CollectionDefinition("Portable UI environment", DisableParallelization = true)] +public sealed class PortableUiEnvironmentCollection; + +[Collection("Portable UI environment")] +public sealed class PortableModeUiTests +{ + [AvaloniaTheory] + [InlineData("en")] + [InlineData("pt-BR")] + public void Portable_settings_show_the_data_location_and_disable_integration(string language) + { + TestServices.EnsureInitialized(language); + var root = Directory.CreateTempSubdirectory("filee-portable-ui-").FullName; + var previous = Environment.GetEnvironmentVariable(UserDataStore.DirectoryVariable); + Environment.SetEnvironmentVariable(UserDataStore.DirectoryVariable, null); + try + { + File.WriteAllText(Path.Combine(root, PortableMode.MarkerName), ""); + using var portable = PortableMode.Initialize(root); + Assert.False(UpdateService.IsInstalled); + var store = new UserDataStore(UserDataStore.DefaultDirectory); + store.Load(); + using var vm = new GeneralPageViewModel(store, AppHost.Get(), AppHost.Get(), + NullLogger.Instance); + Assert.True(vm.IsPortable); + Assert.False(vm.StartWithSystem); + Assert.False(vm.ContextMenu); + Assert.False(vm.CanChangeSystemIntegration); + Assert.False(vm.CanAddModernMenu); + Assert.False(vm.CanRemoveModernMenu); + Assert.False(vm.ShowModernMenu); + Assert.Equal(Path.Combine(root, "data"), vm.PortableDataDirectory); + var window = new Window { Content = new GeneralPage { DataContext = vm }, Width = 800, Height = 1000 }; + try + { + window.Show(); + RenderTests.Pump(); + var switches = window.GetVisualDescendants().OfType() + .Where(t => t.DataContext == vm && (t.Content?.ToString() == AppHost.Get()["general.start_with_system"] + || t.Content?.ToString() == vm.ContextMenuText)).ToArray(); + Assert.Equal(2, switches.Length); + Assert.All(switches, t => Assert.False(t.IsEnabled)); + RenderTests.Save(window, $"general-portable-{language}.png"); + } + finally { window.Close(); } + } + finally + { + Environment.SetEnvironmentVariable(UserDataStore.DirectoryVariable, previous); + Directory.Delete(root, true); + } + } + + [AvaloniaFact] + public void Every_language_has_the_portable_hint_and_formattable_startup_error() + { + TestServices.EnsureInitialized("en"); + foreach (var language in LocalizationService.Languages) + { + var catalog = LocalizationService.LoadStrings(language.Code); + Assert.False(string.IsNullOrWhiteSpace(catalog["general.portable_mode"])); + Assert.Contains("example-data", string.Format(catalog["startup.portable_unwritable"], "example-data")); + } + } +} diff --git a/tests/Filee.App.Tests/SystemIntegrationTests.cs b/tests/Filee.App.Tests/SystemIntegrationTests.cs index 4bba1ef16..c10a29928 100644 --- a/tests/Filee.App.Tests/SystemIntegrationTests.cs +++ b/tests/Filee.App.Tests/SystemIntegrationTests.cs @@ -6,8 +6,38 @@ namespace Filee.App.Tests; +[Collection("Portable UI environment")] public sealed class SystemIntegrationTests { + [Fact] + public void A_marked_copy_never_registers_or_removes_installed_integration() + { + var root = Directory.CreateTempSubdirectory("filee-portable-integration-").FullName; + var previous = Environment.GetEnvironmentVariable(UserDataStore.DirectoryVariable); + Environment.SetEnvironmentVariable(UserDataStore.DirectoryVariable, null); + try + { + File.WriteAllText(Path.Combine(root, PortableMode.MarkerName), ""); + using var portable = PortableMode.Initialize(root); + var platform = new RecordingPlatform(); + var service = new SystemIntegrationService(platform, new Texts(), NullLogger.Instance) + { + RegistrationEnabled = true, + ExecutablePath = Path.Combine(root, "Filee.exe"), + }; + Assert.True(service.SeparateDataDirectory); + service.Apply(new AppSettings { StartWithSystem = true, ContextMenuEnabled = true }); + service.Apply(new AppSettings { StartWithSystem = false, ContextMenuEnabled = false }); + Assert.Equal(0, platform.StartupCalls); + Assert.Equal(0, platform.MenuCalls); + } + finally + { + Environment.SetEnvironmentVariable(UserDataStore.DirectoryVariable, previous); + Directory.Delete(root, true); + } + } + [Fact] public void Unrelated_settings_do_not_reregister_the_file_manager() { diff --git a/tests/Filee.Core.Tests/PortableModeTests.cs b/tests/Filee.Core.Tests/PortableModeTests.cs new file mode 100644 index 000000000..13b9158c3 --- /dev/null +++ b/tests/Filee.Core.Tests/PortableModeTests.cs @@ -0,0 +1,173 @@ +// Marker detection, writable storage and portable settings without touching the real user profile. + +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +[CollectionDefinition("Portable environment", DisableParallelization = true)] +public sealed class PortableEnvironmentCollection; + +[Collection("Portable environment")] +public sealed class PortableModeTests : IDisposable +{ + private readonly string _root = Directory.CreateTempSubdirectory("filee-portable-tests-").FullName; + private readonly string? _data = Environment.GetEnvironmentVariable(UserDataStore.DirectoryVariable); + private readonly string? _cache = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); + + public PortableModeTests() => Environment.SetEnvironmentVariable(UserDataStore.DirectoryVariable, null); + + public void Dispose() + { + Environment.SetEnvironmentVariable(UserDataStore.DirectoryVariable, _data); + Environment.SetEnvironmentVariable("XDG_CACHE_HOME", _cache); + Directory.Delete(_root, true); + } + + private string Mark(string? directory = null) + { + directory ??= _root; + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, PortableMode.MarkerName), ""); + return directory; + } + + [Fact] + public void An_unmarked_copy_keeps_its_existing_behavior() + { + Assert.Null(PortableMode.Resolve(_root, null)); + Assert.Null(PortableMode.Initialize(_root)); + Assert.False(PortableMode.IsEnabled); + Assert.Null(Environment.GetEnvironmentVariable(UserDataStore.DirectoryVariable)); + Assert.False(Directory.Exists(Path.Combine(_root, "data"))); + } + + [Fact] + public void A_marker_selects_data_beside_the_application_and_cleans_its_probe() + { + Mark(); + using var portable = PortableMode.Initialize(_root); + Assert.True(PortableMode.IsEnabled); + Assert.Equal(Path.Combine(_root, "data"), UserDataStore.DefaultDirectory); + Assert.Empty(Directory.GetFiles(UserDataStore.DefaultDirectory)); + } + + [Fact] + public void An_explicit_data_override_still_wins() + { + Mark(); + var chosen = Path.Combine(_root, "chosen"); + Environment.SetEnvironmentVariable(UserDataStore.DirectoryVariable, chosen); + using var portable = PortableMode.Initialize(_root); + Assert.Equal(chosen, PortableMode.DataDirectory); + Assert.False(Directory.Exists(Path.Combine(_root, "data"))); + } + + [Fact] + public void A_mac_bundle_uses_the_marker_and_data_outside_its_signed_contents() + { + var executable = Directory.CreateDirectory(Path.Combine(_root, "Filee.app", "Contents", "MacOS")).FullName; + Assert.Null(PortableMode.Resolve(executable, null)); + Mark(); + Assert.Equal(Path.Combine(_root, "data"), PortableMode.Resolve(executable, null)); + } + + [Fact] + public void A_directory_named_like_the_marker_does_not_enable_portable_mode() + { + Directory.CreateDirectory(Path.Combine(_root, PortableMode.MarkerName)); + Assert.Null(PortableMode.Resolve(_root, null)); + } + + [Fact] + public void An_unwritable_data_path_fails_without_creating_a_fallback() + { + Mark(); + var data = Path.Combine(_root, "data"); + File.WriteAllText(data, "blocks directory creation"); + var error = Assert.Throws(() => PortableMode.Initialize(_root)); + Assert.Equal(data, error.DataDirectory); + Assert.IsAssignableFrom(error.InnerException); + Assert.False(PortableMode.IsEnabled); + Assert.Null(Environment.GetEnvironmentVariable(UserDataStore.DirectoryVariable)); + Assert.Equal("blocks directory creation", File.ReadAllText(data)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Fresh_and_existing_settings_cannot_enable_system_integration(bool existing) + { + Mark(); + using var portable = PortableMode.Initialize(_root); + if (existing) + File.WriteAllText(Path.Combine(UserDataStore.DefaultDirectory, "settings.json"), + "{\"startWithSystem\":true,\"contextMenuEnabled\":true}"); + var store = new UserDataStore(UserDataStore.DefaultDirectory); + store.Load(); + Assert.False(store.Settings.StartWithSystem); + Assert.False(store.Settings.ContextMenuEnabled); + store.Settings.StartWithSystem = store.Settings.ContextMenuEnabled = true; + store.SaveSettings(); + var reloaded = new UserDataStore(UserDataStore.DefaultDirectory); + reloaded.Load(); + Assert.False(reloaded.Settings.StartWithSystem); + Assert.False(reloaded.Settings.ContextMenuEnabled); + } + + [Fact] + [System.Runtime.Versioning.UnsupportedOSPlatform("windows")] + public void A_read_only_unix_directory_reports_the_selected_path() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix directory permissions."); + var data = Directory.CreateDirectory(Path.Combine(_root, "data")).FullName; + var mode = File.GetUnixFileMode(data); + try + { + File.SetUnixFileMode(data, UnixFileMode.UserRead | UnixFileMode.UserExecute); + var error = Assert.Throws(() => PortableMode.Validate(data)); + Assert.Equal(data, error.DataDirectory); + } + finally { File.SetUnixFileMode(data, mode); } + } + + [Fact] + public void A_moved_copy_keeps_its_settings_in_the_moved_data_folder() + { + var first = Mark(Path.Combine(_root, "first")); + using (PortableMode.Initialize(first)) + { + var store = new UserDataStore(UserDataStore.DefaultDirectory); + store.Load(); + store.Settings.Language = "de"; + store.SaveSettings(); + } + var moved = Path.Combine(_root, "moved"); + Directory.Move(first, moved); + using (PortableMode.Initialize(moved)) + { + var store = new UserDataStore(UserDataStore.DefaultDirectory); + store.Load(); + Assert.Equal("de", store.Settings.Language); + Assert.Equal(Path.Combine(moved, "data"), store.Directory); + } + Assert.False(Directory.Exists(first)); + } + + [Fact] + public void Portable_mode_preserves_the_existing_temp_cache_and_profile_environment() + { + Mark(); + string[] keys = ["TEMP", "TMP", "TMPDIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME"]; + var before = keys.ToDictionary(key => key, Environment.GetEnvironmentVariable); + var temporary = Path.GetTempPath(); + using (PortableMode.Initialize(_root)) + { + Assert.Equal(temporary, Path.GetTempPath()); + foreach (var key in keys) + Assert.Equal(before[key], Environment.GetEnvironmentVariable(key)); + } + Assert.False(PortableMode.IsEnabled); + Assert.Equal(_cache, Environment.GetEnvironmentVariable("XDG_CACHE_HOME")); + Assert.False(Directory.Exists(Path.Combine(_root, "data", "temp"))); + } +} diff --git a/tests/Filee.Platform.Tests/LinuxInstallerTests.cs b/tests/Filee.Platform.Tests/LinuxInstallerTests.cs index ce70a3616..ca7526b89 100644 --- a/tests/Filee.Platform.Tests/LinuxInstallerTests.cs +++ b/tests/Filee.Platform.Tests/LinuxInstallerTests.cs @@ -155,6 +155,8 @@ public void First_time_installation_succeeds_in_isolated_environment() var installDir = Path.Combine(env.DataDir, "filee", "application"); Assert.True(File.Exists(Path.Combine(installDir, "Filee"))); Assert.True(File.Exists(Path.Combine(installDir, "filee-cli"))); + Assert.False(File.Exists(Path.Combine(installDir, "filee.portable"))); + Assert.True(File.Exists(Path.Combine(env.SourceDir, "filee.portable"))); Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); var markerPath = Path.Combine(installDir, ".filee-package"); @@ -224,6 +226,8 @@ public void Upgrade_succeeds_and_replaces_binaries_while_preserving_engine_folde // Verify application was updated to v2.0.0 Assert.Equal("v2.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + Assert.False(File.Exists(Path.Combine(installDir, "filee.portable"))); + Assert.True(File.Exists(Path.Combine(v2Source, "filee.portable"))); // Verify downloaded engines are STILL PRESERVED! Assert.True(File.Exists(mockEngineBinary)); @@ -749,6 +753,7 @@ private void CreateMockPayload(string dir, string version) File.WriteAllText(cli, "#!/bin/sh\nexit 0\n"); File.WriteAllText(icon, "dummy-png"); File.WriteAllText(ver, version); + File.WriteAllText(Path.Combine(dir, "filee.portable"), ""); File.Copy(_installScriptPath, script, overwrite: true); File.SetUnixFileMode(filee, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); From 1a865e58fc09bada746150da64066151bdf6cd9c Mon Sep 17 00:00:00 2001 From: Feraris20 <222025665+Feraris20@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:02:00 +0200 Subject: [PATCH 2/2] Add selective portable migration and owned integration cleanup --- README.md | 18 +- docs/ARCHITECTURE.md | 4 +- docs/PORTABLE.md | 45 +++++ installer/linux/INSTALL.txt | 3 +- installer/linux/install.sh | 23 ++- installer/macos/INSTALL.txt | 9 +- src/Filee.App/App.axaml.cs | 11 +- src/Filee.App/Assets/i18n/ar.json | 13 ++ src/Filee.App/Assets/i18n/de.json | 13 ++ src/Filee.App/Assets/i18n/en.json | 13 ++ src/Filee.App/Assets/i18n/es.json | 13 ++ src/Filee.App/Assets/i18n/fr.json | 13 ++ src/Filee.App/Assets/i18n/hi.json | 13 ++ src/Filee.App/Assets/i18n/id.json | 13 ++ src/Filee.App/Assets/i18n/it.json | 13 ++ src/Filee.App/Assets/i18n/ja.json | 13 ++ src/Filee.App/Assets/i18n/ko.json | 13 ++ src/Filee.App/Assets/i18n/ne.json | 13 ++ src/Filee.App/Assets/i18n/pl.json | 13 ++ src/Filee.App/Assets/i18n/pt-BR.json | 13 ++ src/Filee.App/Assets/i18n/pt-PT.json | 13 ++ src/Filee.App/Assets/i18n/ru.json | 13 ++ src/Filee.App/Assets/i18n/th.json | 13 ++ src/Filee.App/Assets/i18n/tr.json | 13 ++ src/Filee.App/Assets/i18n/vi.json | 13 ++ src/Filee.App/Assets/i18n/zh-CN.json | 13 ++ src/Filee.App/Assets/i18n/zh-TW.json | 13 ++ src/Filee.App/PortableStartupFailure.cs | 14 +- src/Filee.App/Services/PortableFirstStart.cs | 166 ++++++++++++++++++ src/Filee.App/Services/UninstallCleanup.cs | 2 + src/Filee.Core/Settings/PortableImport.cs | 140 +++++++++++++++ .../ExplorerMenuRegistration.cs | 11 ++ .../PortableIntegration.cs | 73 ++++++++ src/Filee.Setup/Installation/Installer.cs | 5 + .../PortableFirstStartTests.cs | 140 +++++++++++++++ tests/Filee.App.Tests/PortableModeUiTests.cs | 11 ++ tests/Filee.Core.Tests/PortableImportTests.cs | 150 ++++++++++++++++ .../LinuxInstallerTests.cs | 7 +- .../PortableIntegrationTests.cs | 53 ++++++ tests/Filee.Setup.Tests/InstallerTests.cs | 1 + 40 files changed, 1120 insertions(+), 26 deletions(-) create mode 100644 docs/PORTABLE.md create mode 100644 src/Filee.App/Services/PortableFirstStart.cs create mode 100644 src/Filee.Core/Settings/PortableImport.cs create mode 100644 src/Filee.Platform.Windows/PortableIntegration.cs create mode 100644 tests/Filee.App.Tests/PortableFirstStartTests.cs create mode 100644 tests/Filee.Core.Tests/PortableImportTests.cs create mode 100644 tests/Filee.Platform.Tests/PortableIntegrationTests.cs diff --git a/README.md b/README.md index 9f3531a40..7af2b2ac5 100644 --- a/README.md +++ b/README.md @@ -101,23 +101,7 @@ winget install KnifeLemon.Filee - Drag gestures need X11. On Wayland, use the drop zone and the file manager's right-click menu. -### True portable mode - -Portable release archives include an empty `filee.portable` beside `Filee.exe` (or the `Filee` executable on Linux). -For other copies, create this marker before starting Filee to enable portable mode. -On macOS, put the marker beside `Filee.app`, outside the bundle. Settings, downloaded engines and logs then live -in a `data` folder beside the marker. Both the app and CLI use it. Login startup and file-manager integration -are disabled, leaving an installed copy's entries untouched. The data folder must be writable; Filee reports -an error instead of falling back to the user profile. - -Conversion work folders, temporary files and engine caches keep their existing behavior and locations. -An explicit `FILEE_DATA_DIR` still takes precedence. -Installer packages keep using the normal user profile. Existing profile data is not imported automatically. -Update a marked copy by replacing its application files while keeping `filee.portable` and `data`. - -To bring existing data along, close Filee first, copy the existing settings/library files into the new `data` -folder, and copy downloaded engines into `data/engines`. The original copies are left alone. Custom engine -paths remain external; downloaded copies travel with the portable folder. +Portable releases keep their data beside the app; see [portable mode](docs/PORTABLE.md). Filee shows new releases in its menu and updates itself, keeping your settings and engines. After an update, a few slides show what's new. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5e61d2175..a046d0c0c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -384,7 +384,9 @@ The General page explains this and disables those controls. Conversion work folders, temporary files and engine caches keep their existing behavior and locations. Portable mode does not change the environment passed to external engines. Portable release archives include the marker by default. Installer payloads keep normal profile storage; -existing settings and engines are not imported automatically. +the desktop app offers a one-time copy of an existing profile into unused portable data before loading settings. +Import stages copies first and preserves the source. Windows integration cleanup checks the exact executable +target and asks independently of import. See [portable mode](PORTABLE.md) for migration and quarantine details. `UserDataStore` owns `settings.json`, `presets.json`, `profiles.json` and `history.json` (System.Text.Json source generation, atomic writes, broken files are kept as `*.broken`). diff --git a/docs/PORTABLE.md b/docs/PORTABLE.md new file mode 100644 index 000000000..0ea822021 --- /dev/null +++ b/docs/PORTABLE.md @@ -0,0 +1,45 @@ +### True portable mode + +Portable release archives include an empty `filee.portable` beside `Filee.exe` (or the `Filee` executable on Linux). +For other copies, create this marker before starting Filee to enable portable mode. +On macOS, put the marker beside `Filee.app`, outside the bundle. Settings, downloaded engines and logs then live +in a `data` folder beside the marker. Both the app and CLI use it. Login startup and file-manager integration +are disabled, leaving an installed copy's entries untouched. The data folder must be writable; Filee reports +an error instead of falling back to the user profile. + +Conversion work folders, temporary files and engine caches keep their existing behavior and locations. +An explicit `FILEE_DATA_DIR` still takes precedence. +Installer packages keep using the normal user profile. +Update a marked copy by replacing its application files while keeping `filee.portable` and `data`. + +### Bringing existing data along + +Close the previous Filee copy before starting the portable app. If portable data has no settings, library, +history or downloaded engines and the normal profile contains `settings.json`, the app offers a one-time import. +It copies settings, presets, profiles, history and downloaded engines, with percentage progress. Originals stay +untouched. Settings, presets, profiles and history each have their own checkbox; downloaded engines are one +additional group, including shared dependencies. Available choices are selected by default. The engines choice +shows their size when it can be measured. Custom profiles may need their matching presets; import both to keep +custom actions. History can be excluded independently. Unselected engines can be installed later; without imported +settings, normal setup opens. +Choose **Import existing data** to copy the selected categories, or **Start fresh** to use defaults and +remember that choice. This appears before Home and engine setup. A failed copy can be retried; +existing portable data is never overwritten. Start the desktop app before using the CLI if you want to import. +Custom engine paths remain external; downloaded copies travel with the portable folder. + +If this same executable previously registered startup or file-manager integration on Windows, the app asks before +removing those entries. Entries targeting another executable are preserved. Cleanup is offered independently of +import, so declining import does not hide obsolete integration. If cleanup fails or is cancelled, it is offered +again next time while owned entries remain. `--uninstall-cleanup` also removes only this copy's entries. + +### macOS quarantine + +Extract the tar.gz into a writable folder and keep the marker beside `Filee.app`. Finder can launch a quarantined +app from an App Translocation path, where the marker is not visible. After verifying the download's source and +checksum, remove quarantine from the extracted app before launching: + +```sh +xattr -dr com.apple.quarantine /path/to/Filee.app +``` + +The .dmg is an installation image, not a writable portable folder. Installation copies only the app bundle. diff --git a/installer/linux/INSTALL.txt b/installer/linux/INSTALL.txt index 8a153f73e..9168287f8 100644 --- a/installer/linux/INSTALL.txt +++ b/installer/linux/INSTALL.txt @@ -1,6 +1,7 @@ Run ./Filee to test the extracted app, or ./filee-cli help for the command line. The included filee.portable marker keeps settings, downloaded engines and logs -in data beside the executable. Existing profile data is not imported automatically. +in data beside the executable. On first start the app offers to copy existing +profile data and downloaded engines into unused portable data, leaving originals intact. Installing with install.sh uses the normal user profile instead. Run ./install.sh to install for the current user. It requires Python 3, creates diff --git a/installer/linux/install.sh b/installer/linux/install.sh index df1cce030..9d3b92ee6 100755 --- a/installer/linux/install.sh +++ b/installer/linux/install.sh @@ -1,6 +1,25 @@ #!/bin/sh set -eu +# Copy application files only: a package may already have been run in portable mode. +copy_application() { + python3 - "$source_dir" "$1" <<'PY' +import os, shutil, sys +source, target = sys.argv[1:] +os.makedirs(target, exist_ok=True) +for name in os.listdir(source): + if name in ('data', 'filee.portable'): + continue + src, dst = os.path.join(source, name), os.path.join(target, name) + if os.path.islink(src): + os.symlink(os.readlink(src), dst) + elif os.path.isdir(src): + shutil.copytree(src, dst, symlinks=True) + else: + shutil.copy2(src, dst) +PY +} + # Filee Linux standalone installer. # Supports first-time installation and in-place upgrade (--upgrade). # Preserves downloaded conversion engines (~/.local/share/Filee/engines) and user settings (~/.config/Filee). @@ -134,7 +153,7 @@ if [ "$upgrade" -eq 0 ]; then fi mkdir -p "$data_dir/filee" "$bin_dir" "$data_dir/applications" - cp -R "$source_dir" "$install_dir" + copy_application "$install_dir" # The archive is portable; an installed copy uses the ordinary user profile. rm -f "$install_dir/filee.portable" printf '%s\n' 'com.filee.app' > "$install_dir/.filee-package" @@ -187,7 +206,7 @@ else mkdir "$staging_dir" # Copy incoming payload into staging (preserves source_dir without moving/deleting it). - cp -R "$source_dir/." "$staging_dir/" + copy_application "$staging_dir" rm -f "$staging_dir/filee.portable" printf '%s\n' 'com.filee.app' > "$staging_dir/.filee-package" printf '%s\n' "$upgrade_token" > "$staging_dir/.filee-upgrade-token" diff --git a/installer/macos/INSTALL.txt b/installer/macos/INSTALL.txt index 246e39438..0f262e968 100644 --- a/installer/macos/INSTALL.txt +++ b/installer/macos/INSTALL.txt @@ -2,7 +2,14 @@ This package requires macOS 13 or later and contains a separate build for Apple Silicon or Intel Macs. The .NET runtime is included. The tar.gz includes filee.portable beside Filee.app: running there keeps settings, downloaded engines and logs in an adjacent data folder. Existing profile data is -not imported automatically. Installation and the .dmg use the normal user profile. +offered for import on first start when portable data is unused. Originals are kept. +Installation and the .dmg use the normal user profile. +For portable use, move the extracted folder to a writable location. Finder may +launch a quarantined app from a temporary App Translocation path, hiding the marker. +After verifying the download's source and checksum, remove quarantine from the +extracted app in Terminal before launching it: + xattr -dr com.apple.quarantine /path/to/Filee.app +Keep filee.portable beside Filee.app. Do not run portable mode from the read-only .dmg. Run ./install.sh to copy Filee.app to ~/Applications and add ~/.local/bin/filee. From the .dmg, drag Filee.app onto Applications instead (no filee command then). You can also move Filee.app into Applications manually. Keep its location stable diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index a6826d2db..fb4b8a398 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -33,10 +33,19 @@ public override void Initialize() SingleSelectionGuard.Register(); } - public override void OnFrameworkInitializationCompleted() + public override async void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { + try { await PortableFirstStart.RunAsync(); } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + Program.Log.LogError(ex, "Portable first-start preparation failed"); + desktop.Shutdown(1); + return; + } + // macOS preloads settings to configure its Dock; reload after a possible import. + if (PortableMode.IsEnabled) Program.PreloadedStore?.Load(); AppHost.Build(preloadedStore: Program.PreloadedStore); Start(desktop); } diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 380683bb9..3eceae1c3 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -535,6 +535,19 @@ "format.category.Data": "بيانات", "format.category.Model3D": "نموذج ثلاثي الأبعاد", "format.category.Geo": "بيانات جغرافية", + "portable.import_existing": "استيراد البيانات الحالية", + "portable.start_fresh": "البدء من جديد", + "portable.import_summary": "انسخ الإعدادات والإعدادات المسبقة والسجل والمحركات المنزّلة إلى هذا المجلد المحمول. لن تتغير الملفات الأصلية أو تُحذف.", + "portable.import_next": "يتابع Filee بالبيانات المحددة. إذا لم تحدد الإعدادات، يبدأ الإعداد الأولي بالقيم الافتراضية. ستظهر موافقة منفصلة لحذف إدخالات بدء التشغيل أو المستكشف القديمة الخاصة بهذا البرنامج. لن تتغير التثبيتات الأخرى.", + "portable.fresh_next": "البدء من جديد يتخطى النسخ ويفتح الإعدادات الافتراضية. تبقى بياناتك الحالية في مكانها.", + "portable.choose_settings": "الإعدادات", + "portable.choose_presets": "الإعدادات المسبقة", + "portable.choose_profiles": "الملفات الشخصية", + "portable.choose_history": "السجل", + "portable.profile_dependencies": "قد تحتاج الملفات الشخصية المخصصة إلى إعداداتها المسبقة. استورد الاثنين للحفاظ على الإجراءات. تبقى المحركات مع تبعياتها.", + "portable.choose_engines": "المحركات المنزّلة", + "portable.import_prompt": "هل تريد نسخ الإعدادات والإعدادات المسبقة والسجل والمحركات المنزّلة من {0} إلى {1}؟ ستبقى النسخ الأصلية دون تغيير. البدء من جديد يبدأ ملفًا محمولًا جديدًا.", + "portable.cleanup_prompt": "هل تريد إزالة إدخالات بدء التشغيل ومدير الملفات السابقة الخاصة بهذا الملف التنفيذي؟ ستبقى إدخالات التثبيتات الأخرى دون تغيير.", "startup.portable_unwritable": "لا يستطيع Filee الكتابة في مجلد البيانات المحمول:\n{0}\n\nانقل Filee إلى مجلد قابل للكتابة أو اسمح بالكتابة في هذا المجلد، ثم حاول مجددًا.", "general.portable_mode": "الوضع المحمول: تُحفظ الإعدادات والمحركات المنزلة والسجلات في مجلد البيانات المحمول. بدء التشغيل التلقائي وتكامل مدير الملفات معطلان.", "general.title": "عام", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index ec7f7e0ee..d19e671bc 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -535,6 +535,19 @@ "format.category.Data": "Daten", "format.category.Model3D": "3D-Modell", "format.category.Geo": "Geodaten", + "portable.import_existing": "Vorhandene Daten importieren", + "portable.start_fresh": "Neu beginnen", + "portable.import_summary": "Einstellungen, Vorgaben, Verlauf und heruntergeladene Engines in diesen portablen Ordner kopieren. Die Originale werden weder geändert noch gelöscht.", + "portable.import_next": "Danach verwendet Filee die ausgewählten Daten. Ohne Einstellungen öffnet sich die normale Einrichtung mit Standardwerten. Alte Autostart- oder Explorer-Einträge dieser Programmdatei werden nur nach separater Bestätigung entfernt. Andere Installationen bleiben unverändert.", + "portable.fresh_next": "Neu beginnen überspringt das Kopieren und öffnet die Einrichtung mit Standardwerten. Vorhandene Daten bleiben erhalten.", + "portable.choose_settings": "Einstellungen", + "portable.choose_presets": "Vorgaben", + "portable.choose_profiles": "Profile", + "portable.choose_history": "Verlauf", + "portable.profile_dependencies": "Eigene Profile benötigen eventuell ihre Vorgaben. Importieren Sie beide, um eigene Aktionen zu behalten. Engines werden mit ihren Abhängigkeiten zusammengehalten.", + "portable.choose_engines": "Heruntergeladene Engines", + "portable.import_prompt": "Vorhandene Einstellungen, Vorgaben, Verlauf und heruntergeladene Engines von {0} nach {1} kopieren? Die Originale bleiben unverändert. Neu beginnen startet ein neues portables Profil.", + "portable.cleanup_prompt": "Frühere Autostart- und Dateimanager-Einträge dieser Programmdatei entfernen? Einträge anderer Installationen bleiben unverändert.", "startup.portable_unwritable": "Filee kann nicht in seinen portablen Datenordner schreiben:\n{0}\n\nVerschieben Sie Filee in einen beschreibbaren Ordner oder erlauben Sie das Schreiben in diesen Ordner und versuchen Sie es erneut.", "general.portable_mode": "Portabler Modus: Einstellungen, heruntergeladene Engines und Protokolle bleiben im portablen Datenordner. Autostart und Dateimanager-Integration sind deaktiviert.", "general.title": "Allgemein", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index af46f89d6..fae80e5b8 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -547,6 +547,19 @@ "format.category.Model3D": "3D model", "format.category.Geo": "Geospatial", + "portable.import_existing": "Import existing data", + "portable.start_fresh": "Start fresh", + "portable.import_summary": "Copy your settings, presets, history and downloaded engines into this portable folder. The original files will not be changed or deleted.", + "portable.import_next": "After copying, Filee continues with your selected data. If settings are not selected, normal setup opens with defaults. If this executable has old startup or Explorer menu entries, a separate confirmation will ask whether to remove them. Other installations stay unchanged.", + "portable.fresh_next": "Start fresh skips copying and opens normal setup with default settings. Your existing data stays where it is.", + "portable.choose_settings": "Settings", + "portable.choose_presets": "Presets", + "portable.choose_profiles": "Profiles", + "portable.choose_history": "History", + "portable.profile_dependencies": "Custom profiles may need their matching presets. Import both to keep custom actions. Downloaded engines are kept together with their dependencies.", + "portable.choose_engines": "Downloaded engines", + "portable.import_prompt": "Copy existing settings, presets, history and downloaded engines from {0} into {1}? The originals will stay unchanged. Choose Start fresh to use default settings.", + "portable.cleanup_prompt": "Remove the previous startup and file-manager entries belonging to this executable? Entries for other installations will stay unchanged.", "startup.portable_unwritable": "Filee cannot write to its portable data folder:\n{0}\n\nMove Filee to a writable folder or make this folder writable, then try again.", "general.portable_mode": "Portable mode: settings, downloaded engines and logs stay in the portable data folder. Startup and file-manager integration are disabled.", "general.title": "General", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 8a9a6e0f6..0885548cc 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -535,6 +535,19 @@ "format.category.Data": "Datos", "format.category.Model3D": "Modelo 3D", "format.category.Geo": "Geoespacial", + "portable.import_existing": "Importar datos existentes", + "portable.start_fresh": "Empezar de cero", + "portable.import_summary": "Copiar ajustes, preajustes, historial y motores descargados a esta carpeta portátil. Los originales no se modificarán ni eliminarán.", + "portable.import_next": "Filee continúa con los datos elegidos. Sin los ajustes, se abre la configuración inicial predeterminada. Si este ejecutable tiene entradas antiguas de inicio o del Explorador, se pedirá confirmación para quitarlas. Otras instalaciones no cambiarán.", + "portable.fresh_next": "Empezar de cero omite la copia y abre la configuración inicial. Los datos existentes permanecen donde están.", + "portable.choose_settings": "Ajustes", + "portable.choose_presets": "Preajustes", + "portable.choose_profiles": "Perfiles", + "portable.choose_history": "Historial", + "portable.profile_dependencies": "Los perfiles personalizados pueden necesitar sus preajustes. Importe ambos para conservar las acciones. Los motores se mantienen con sus dependencias.", + "portable.choose_engines": "Motores descargados", + "portable.import_prompt": "¿Copiar ajustes, preajustes, historial y motores descargados de {0} a {1}? Los originales no cambiarán. Empezar de cero inicia un perfil portátil nuevo.", + "portable.cleanup_prompt": "¿Eliminar las entradas anteriores de inicio y del explorador asociadas a este ejecutable? Las de otras instalaciones no cambiarán.", "startup.portable_unwritable": "Filee no puede escribir en su carpeta de datos portátil:\n{0}\n\nMueve Filee a una carpeta con permiso de escritura o habilita la escritura en esta carpeta y vuelve a intentarlo.", "general.portable_mode": "Modo portátil: los ajustes, motores descargados y registros permanecen en la carpeta de datos portátil. El inicio automático y la integración con el gestor de archivos están desactivados.", "general.title": "General", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index bba74e864..14d8989a6 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -547,6 +547,19 @@ "format.category.Model3D": "Modèle 3D", "format.category.Geo": "Géospatial", + "portable.import_existing": "Importer les données existantes", + "portable.start_fresh": "Repartir de zéro", + "portable.import_summary": "Copier les paramètres, préréglages, l’historique et les moteurs téléchargés dans ce dossier portable. Les originaux ne seront ni modifiés ni supprimés.", + "portable.import_next": "Filee continue avec les données choisies. Sans les paramètres, la configuration initiale utilise les valeurs par défaut. Une confirmation séparée proposera de supprimer les anciennes entrées de démarrage ou de l’Explorateur liées à cet exécutable. Les autres installations resteront intactes.", + "portable.fresh_next": "Repartir de zéro ouvre la configuration par défaut sans copier les données existantes.", + "portable.choose_settings": "Paramètres", + "portable.choose_presets": "Préréglages", + "portable.choose_profiles": "Profils", + "portable.choose_history": "Historique", + "portable.profile_dependencies": "Les profils personnalisés peuvent nécessiter leurs préréglages. Importez les deux pour conserver les actions. Les moteurs restent avec leurs dépendances.", + "portable.choose_engines": "Moteurs téléchargés", + "portable.import_prompt": "Copier les paramètres, préréglages, l’historique et les moteurs téléchargés de {0} vers {1} ? Les originaux seront conservés. Repartir de zéro démarre un nouveau profil portable.", + "portable.cleanup_prompt": "Supprimer les anciennes entrées de démarrage et du gestionnaire de fichiers liées à cet exécutable ? Les autres installations ne seront pas modifiées.", "startup.portable_unwritable": "Filee ne peut pas écrire dans son dossier de données portable :\n{0}\n\nDéplacez Filee vers un dossier accessible en écriture ou autorisez l’écriture dans ce dossier, puis réessayez.", "general.portable_mode": "Mode portable : les paramètres, moteurs téléchargés et journaux restent dans le dossier de données portable. Le démarrage automatique et l’intégration au gestionnaire de fichiers sont désactivés.", "general.title": "Général", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 046372f5e..7054fa40d 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -535,6 +535,19 @@ "format.category.Data": "डेटा", "format.category.Model3D": "3D मॉडल", "format.category.Geo": "भू-स्थानिक", + "portable.import_existing": "मौजूदा डेटा आयात करें", + "portable.start_fresh": "नई शुरुआत करें", + "portable.import_summary": "सेटिंग, प्रीसेट, इतिहास और डाउनलोड किए गए इंजन इस पोर्टेबल फ़ोल्डर में कॉपी करें। मूल फ़ाइलें बदली या हटाई नहीं जाएँगी।", + "portable.import_next": "Filee चुने गए डेटा के साथ आगे बढ़ता है। सेटिंग नहीं चुनने पर डिफ़ॉल्ट प्रारंभिक सेटअप खुलता है। इसके बाद Filee आयातित प्रोफ़ाइल का उपयोग करेगा। इस प्रोग्राम की पुरानी स्टार्टअप या एक्सप्लोरर प्रविष्टियाँ हटाने के लिए अलग पुष्टि माँगी जाएगी। अन्य इंस्टॉलेशन नहीं बदलेंगे।", + "portable.fresh_next": "नई शुरुआत करें कॉपी छोड़कर डिफ़ॉल्ट सेटअप खोलता है। मौजूदा डेटा अपनी जगह रहता है।", + "portable.choose_settings": "सेटिंग", + "portable.choose_presets": "प्रीसेट", + "portable.choose_profiles": "प्रोफ़ाइल", + "portable.choose_history": "इतिहास", + "portable.profile_dependencies": "कस्टम प्रोफ़ाइल को संबंधित प्रीसेट की आवश्यकता हो सकती है। कस्टम क्रियाएँ बनाए रखने के लिए दोनों आयात करें। इंजन अपनी निर्भरताओं के साथ रखे जाते हैं।", + "portable.choose_engines": "डाउनलोड किए गए इंजन", + "portable.import_prompt": "मौजूदा सेटिंग, प्रीसेट, इतिहास और डाउनलोड किए गए इंजन {0} से {1} में कॉपी करें? मूल डेटा नहीं बदलेगा। नई शुरुआत करें नया पोर्टेबल प्रोफ़ाइल शुरू होगा।", + "portable.cleanup_prompt": "इस निष्पादन फ़ाइल की पुरानी स्टार्टअप और फ़ाइल प्रबंधक प्रविष्टियाँ हटाएँ? अन्य इंस्टॉलेशन की प्रविष्टियाँ नहीं बदलेंगी।", "startup.portable_unwritable": "Filee अपने पोर्टेबल डेटा फ़ोल्डर में नहीं लिख सकता:\n{0}\n\nFilee को लिखने योग्य फ़ोल्डर में ले जाएँ या इस फ़ोल्डर में लिखने की अनुमति दें, फिर कोशिश करें।", "general.portable_mode": "पोर्टेबल मोड: सेटिंग, डाउनलोड किए गए इंजन और लॉग पोर्टेबल डेटा फ़ोल्डर में रहते हैं। अपने आप शुरू होना और फ़ाइल प्रबंधक एकीकरण बंद हैं।", "general.title": "सामान्य", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 389ed84f3..7646f74c0 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -535,6 +535,19 @@ "format.category.Data": "Data", "format.category.Model3D": "Model 3D", "format.category.Geo": "Geospasial", + "portable.import_existing": "Impor data yang ada", + "portable.start_fresh": "Mulai baru", + "portable.import_summary": "Salin pengaturan, preset, riwayat dan mesin unduhan ke folder portabel ini. Berkas asli tidak diubah atau dihapus.", + "portable.import_next": "Filee melanjutkan dengan data pilihan. Tanpa pengaturan, penyiapan awal memakai nilai bawaan. Penghapusan entri mulai otomatis atau Explorer lama milik program ini memerlukan konfirmasi terpisah. Instalasi lain tidak berubah.", + "portable.fresh_next": "Mulai baru melewati penyalinan dan membuka pengaturan awal. Data lama tetap di tempatnya.", + "portable.choose_settings": "Pengaturan", + "portable.choose_presets": "Preset", + "portable.choose_profiles": "Profil", + "portable.choose_history": "Riwayat", + "portable.profile_dependencies": "Profil khusus mungkin memerlukan preset terkait. Impor keduanya untuk mempertahankan tindakan. Mesin tetap bersama dependensinya.", + "portable.choose_engines": "Mesin yang diunduh", + "portable.import_prompt": "Salin pengaturan, preset, riwayat dan mesin yang diunduh dari {0} ke {1}? Data asli tidak berubah. Mulai baru memulai profil portabel baru.", + "portable.cleanup_prompt": "Hapus entri mulai otomatis dan pengelola berkas lama milik program ini? Entri instalasi lain tidak berubah.", "startup.portable_unwritable": "Filee tidak dapat menulis ke folder data portabelnya:\n{0}\n\nPindahkan Filee ke folder yang dapat ditulis atau izinkan penulisan ke folder ini, lalu coba lagi.", "general.portable_mode": "Mode portabel: pengaturan, mesin unduhan, dan log tetap di folder data portabel. Mulai otomatis dan integrasi pengelola berkas dinonaktifkan.", "general.title": "Umum", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index f6cc63487..406155f4b 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -534,6 +534,19 @@ "format.category.Data": "Dati", "format.category.Model3D": "Modello 3D", "format.category.Geo": "Geospaziale", + "portable.import_existing": "Importa dati esistenti", + "portable.start_fresh": "Inizia da zero", + "portable.import_summary": "Copia impostazioni, preimpostazioni, cronologia e motori scaricati in questa cartella portatile. Gli originali non verranno modificati o eliminati.", + "portable.import_next": "Filee continua con i dati scelti. Senza le impostazioni, si apre la configurazione iniziale predefinita. Le vecchie voci di avvio o di Esplora file di questo eseguibile saranno rimosse solo dopo una conferma separata. Le altre installazioni non cambieranno.", + "portable.fresh_next": "Inizia da zero salta la copia e apre la configurazione predefinita. I dati esistenti restano al loro posto.", + "portable.choose_settings": "Impostazioni", + "portable.choose_presets": "Preimpostazioni", + "portable.choose_profiles": "Profili", + "portable.choose_history": "Cronologia", + "portable.profile_dependencies": "I profili personalizzati possono richiedere le relative preimpostazioni. Importa entrambi per conservare le azioni. I motori restano con le loro dipendenze.", + "portable.choose_engines": "Motori scaricati", + "portable.import_prompt": "Copiare impostazioni, preimpostazioni, cronologia e motori scaricati da {0} a {1}? Gli originali resteranno invariati. Inizia da zero avvia un nuovo profilo portatile.", + "portable.cleanup_prompt": "Rimuovere le precedenti voci di avvio e del gestore file associate a questo eseguibile? Le altre installazioni resteranno invariate.", "startup.portable_unwritable": "Filee non può scrivere nella cartella dati portatile:\n{0}\n\nSposta Filee in una cartella scrivibile o consenti la scrittura in questa cartella, quindi riprova.", "general.portable_mode": "Modalità portatile: impostazioni, motori scaricati e registri rimangono nella cartella dati portatile. Avvio automatico e integrazione con il gestore file sono disattivati.", "general.title": "Generali", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index b59225251..3c1ddc1e2 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -535,6 +535,19 @@ "format.category.Data": "データ", "format.category.Model3D": "3Dモデル", "format.category.Geo": "地理空間データ", + "portable.import_existing": "既存のデータをインポート", + "portable.start_fresh": "新規に開始", + "portable.import_summary": "設定、プリセット、履歴、ダウンロードしたエンジンをこのポータブルフォルダーにコピーします。元のファイルは変更も削除もしません。", + "portable.import_next": "コピー後は選択したデータで続行します。設定を選択しない場合は初期設定を開きます。 この実行ファイルの古い自動起動やエクスプローラー項目の削除は、別途確認します。他のインストールは変更しません。", + "portable.fresh_next": "新規に開始するとコピーせずに初期設定を開きます。既存のデータは元の場所に残ります。", + "portable.choose_settings": "設定", + "portable.choose_presets": "プリセット", + "portable.choose_profiles": "プロファイル", + "portable.choose_history": "履歴", + "portable.profile_dependencies": "カスタムプロファイルには対応するプリセットが必要な場合があります。両方をインポートするとアクションを保持できます。エンジンは依存ファイルとまとめて扱います。", + "portable.choose_engines": "ダウンロードしたエンジン", + "portable.import_prompt": "既存の設定、プリセット、履歴、ダウンロードしたエンジンを {0} から {1} にコピーしますか?元のデータは変更しません。新規に開始すると新しいポータブルプロファイルで開始します。", + "portable.cleanup_prompt": "この実行ファイルに属する以前の自動起動とファイルマネージャーの項目を削除しますか?他のインストールの項目は変更しません。", "startup.portable_unwritable": "Filee のポータブルデータフォルダーに書き込めません:\n{0}\n\nFilee を書き込み可能なフォルダーに移動するか、このフォルダーへの書き込みを許可してから、もう一度お試しください。", "general.portable_mode": "ポータブルモード: 設定、ダウンロードしたエンジン、ログはポータブルデータフォルダーに保存されます。自動起動とファイルマネージャー連携は無効です。", "general.title": "一般", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index bd07bbc00..8b1084fc4 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -547,6 +547,19 @@ "format.category.Model3D": "3D 모델", "format.category.Geo": "지리 정보", + "portable.import_existing": "기존 데이터 가져오기", + "portable.start_fresh": "새로 시작", + "portable.import_summary": "설정, 프리셋, 기록과 다운로드한 엔진을 이 포터블 폴더로 복사합니다. 원본은 변경하거나 삭제하지 않습니다.", + "portable.import_next": "선택한 데이터로 계속합니다. 설정을 선택하지 않으면 기본 초기 설정이 열립니다. 이 실행 파일의 이전 자동 시작 및 탐색기 항목을 제거하려면 별도로 확인합니다. 다른 설치는 변경하지 않습니다.", + "portable.fresh_next": "새로 시작은 복사를 건너뛰고 기본 초기 설정을 엽니다. 기존 데이터는 그대로 유지됩니다.", + "portable.choose_settings": "설정", + "portable.choose_presets": "프리셋", + "portable.choose_profiles": "프로필", + "portable.choose_history": "기록", + "portable.profile_dependencies": "사용자 지정 프로필에는 해당 프리셋이 필요할 수 있습니다. 작업을 유지하려면 둘 다 가져오세요. 엔진은 종속 파일과 함께 유지됩니다.", + "portable.choose_engines": "다운로드한 엔진", + "portable.import_prompt": "기존 설정, 프리셋, 기록 및 다운로드한 엔진을 {0}에서 {1}(으)로 복사할까요? 원본은 변경되지 않습니다. 새로 시작하면 새 포터블 프로필로 시작합니다.", + "portable.cleanup_prompt": "이 실행 파일의 이전 자동 시작 및 파일 관리자 항목을 제거할까요? 다른 설치의 항목은 변경되지 않습니다.", "startup.portable_unwritable": "Filee가 포터블 데이터 폴더에 쓸 수 없습니다:\n{0}\n\nFilee를 쓰기 가능한 폴더로 옮기거나 이 폴더에 쓰기를 허용한 후 다시 시도하세요.", "general.portable_mode": "포터블 모드: 설정, 다운로드한 엔진과 로그는 포터블 데이터 폴더에 저장됩니다. 자동 시작과 파일 관리자 통합은 비활성화됩니다.", "general.title": "일반", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index e0e292eda..27a464ede 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -535,6 +535,19 @@ "format.category.Data": "डाटा", "format.category.Model3D": "थ्रीडी मोडल", "format.category.Geo": "भौगोलिक डाटा", + "portable.import_existing": "हालको डेटा आयात गर्नुहोस्", + "portable.start_fresh": "नयाँ सुरु गर्नुहोस्", + "portable.import_summary": "सेटिङ, प्रिसेट, इतिहास र डाउनलोड गरिएका इन्जिन यस पोर्टेबल फोल्डरमा प्रतिलिपि गर्नुहोस्। मूल फाइल परिवर्तन वा मेटिने छैनन्।", + "portable.import_next": "Filee चयन गरिएको डेटासँग अघि बढ्छ। सेटिङ नछानेमा पूर्वनिर्धारित प्रारम्भिक सेटअप खुल्छ। त्यसपछि Filee ले आयातित प्रोफाइल प्रयोग गर्छ। यस प्रोग्रामका पुराना स्टार्टअप वा एक्सप्लोरर प्रविष्टि हटाउन छुट्टै पुष्टि मागिन्छ। अन्य स्थापना परिवर्तन हुँदैनन्।", + "portable.fresh_next": "नयाँ सुरु गर्नुहोस् ले प्रतिलिपि नगरी पूर्वनिर्धारित सेटअप खोल्छ। हालको डेटा उही स्थानमा रहन्छ।", + "portable.choose_settings": "सेटिङ", + "portable.choose_presets": "प्रिसेट", + "portable.choose_profiles": "प्रोफाइल", + "portable.choose_history": "इतिहास", + "portable.profile_dependencies": "अनुकूलित प्रोफाइललाई सम्बन्धित प्रिसेट आवश्यक पर्न सक्छ। कार्यहरू राख्न दुवै आयात गर्नुहोस्। इन्जिनहरू आफ्ना निर्भरतासँगै राखिन्छन्।", + "portable.choose_engines": "डाउनलोड गरिएका इन्जिन", + "portable.import_prompt": "हालका सेटिङ, प्रिसेट, इतिहास र डाउनलोड गरिएका इन्जिन {0} बाट {1} मा प्रतिलिपि गर्ने? मूल डेटा परिवर्तन हुँदैन। नयाँ सुरु गर्नुहोस् नयाँ पोर्टेबल प्रोफाइल सुरु हुन्छ।", + "portable.cleanup_prompt": "यस कार्यान्वयन फाइलका पुराना स्टार्टअप र फाइल प्रबन्धक प्रविष्टिहरू हटाउने? अन्य स्थापनाका प्रविष्टिहरू परिवर्तन हुँदैनन्।", "startup.portable_unwritable": "Filee ले आफ्नो पोर्टेबल डेटा फोल्डरमा लेख्न सकेन:\n{0}\n\nFilee लाई लेख्न मिल्ने फोल्डरमा सार्नुहोस् वा यो फोल्डरमा लेख्ने अनुमति दिनुहोस्, अनि फेरि प्रयास गर्नुहोस्।", "general.portable_mode": "पोर्टेबल मोड: सेटिङ, डाउनलोड गरिएका इन्जिन र लग पोर्टेबल डेटा फोल्डरमा रहन्छन्। स्वतः सुरु र फाइल प्रबन्धक एकीकरण बन्द छन्।", "general.title": "सामान्य", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 95ea3fd16..cbdf7a840 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -547,6 +547,19 @@ "format.category.Model3D": "Model 3D", "format.category.Geo": "Geoprzestrzenne", + "portable.import_existing": "Importuj istniejące dane", + "portable.start_fresh": "Zacznij od nowa", + "portable.import_summary": "Skopiuj ustawienia, presety, historię i pobrane silniki do tego folderu przenośnego. Oryginały nie zostaną zmienione ani usunięte.", + "portable.import_next": "Filee użyje wybranych danych. Bez ustawień otworzy się konfiguracja domyślna. Usunięcie starych wpisów autostartu lub Eksploratora tego programu wymaga osobnego potwierdzenia. Inne instalacje pozostaną bez zmian.", + "portable.fresh_next": "Zacznij od nowa pomija kopiowanie i otwiera konfigurację domyślną. Obecne dane pozostaną na miejscu.", + "portable.choose_settings": "Ustawienia", + "portable.choose_presets": "Presety", + "portable.choose_profiles": "Profile", + "portable.choose_history": "Historia", + "portable.profile_dependencies": "Własne profile mogą wymagać odpowiednich presetów. Importuj oba, aby zachować działania. Silniki pozostają razem z zależnościami.", + "portable.choose_engines": "Pobrane silniki", + "portable.import_prompt": "Skopiować ustawienia, presety, historię i pobrane silniki z {0} do {1}? Oryginały pozostaną bez zmian. Zacznij od nowa uruchomi nowy profil przenośny.", + "portable.cleanup_prompt": "Usunąć stare wpisy autostartu i menedżera plików dla tego pliku wykonywalnego? Wpisy innych instalacji pozostaną bez zmian.", "startup.portable_unwritable": "Filee nie może zapisywać w przenośnym folderze danych:\n{0}\n\nPrzenieś Filee do folderu z prawem zapisu lub zezwól na zapis w tym folderze i spróbuj ponownie.", "general.portable_mode": "Tryb przenośny: ustawienia, pobrane silniki i dzienniki pozostają w przenośnym folderze danych. Autostart i integracja z menedżerem plików są wyłączone.", "general.title": "Ogólne", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index a6d8ddcf1..88eb3483b 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -547,6 +547,19 @@ "format.category.Model3D": "Modelo 3D", "format.category.Geo": "Geoespacial", + "portable.import_existing": "Importar dados existentes", + "portable.start_fresh": "Começar do zero", + "portable.import_summary": "Copiar configurações, predefinições, histórico e mecanismos baixados para esta pasta portátil. Os originais não serão alterados nem excluídos.", + "portable.import_next": "O Filee continua com os dados escolhidos. Sem as configurações, abre a configuração inicial padrão. Entradas antigas de inicialização ou do Explorador deste executável só serão removidas após outra confirmação. Outras instalações não mudarão.", + "portable.fresh_next": "Começar do zero ignora a cópia e abre a configuração padrão. Seus dados existentes permanecem onde estão.", + "portable.choose_settings": "Configurações", + "portable.choose_presets": "Predefinições", + "portable.choose_profiles": "Perfis", + "portable.choose_history": "Histórico", + "portable.profile_dependencies": "Perfis personalizados podem precisar das predefinições correspondentes. Importe ambos para manter as ações. Os mecanismos ficam com suas dependências.", + "portable.choose_engines": "Mecanismos baixados", + "portable.import_prompt": "Copiar configurações, predefinições, histórico e mecanismos baixados de {0} para {1}? Os originais não serão alterados. Começar do zero inicia um novo perfil portátil.", + "portable.cleanup_prompt": "Remover as entradas anteriores de inicialização e do gerenciador de arquivos deste executável? As de outras instalações não serão alteradas.", "startup.portable_unwritable": "O Filee não consegue gravar na pasta de dados portátil:\n{0}\n\nMova o Filee para uma pasta com permissão de gravação ou permita a gravação nesta pasta e tente novamente.", "general.portable_mode": "Modo portátil: configurações, mecanismos baixados e registros ficam na pasta de dados portátil. A inicialização automática e a integração com o gerenciador de arquivos estão desativadas.", "general.title": "Geral", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 8c30d054f..c21e77dbb 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -547,6 +547,19 @@ "format.category.Model3D": "Modelo 3D", "format.category.Geo": "Dados geoespaciais", + "portable.import_existing": "Importar dados existentes", + "portable.start_fresh": "Começar de novo", + "portable.import_summary": "Copiar definições, predefinições, histórico e motores transferidos para esta pasta portátil. Os originais não serão alterados nem eliminados.", + "portable.import_next": "O Filee continua com os dados escolhidos. Sem as definições, abre a configuração inicial predefinida. Entradas antigas de arranque ou do Explorador deste executável só serão removidas após outra confirmação. Outras instalações não mudarão.", + "portable.fresh_next": "Começar de novo ignora a cópia e abre a configuração inicial. Os dados existentes ficam onde estão.", + "portable.choose_settings": "Definições", + "portable.choose_presets": "Predefinições", + "portable.choose_profiles": "Perfis", + "portable.choose_history": "Histórico", + "portable.profile_dependencies": "Os perfis personalizados podem precisar das predefinições correspondentes. Importe ambos para manter as ações. Os motores ficam com as suas dependências.", + "portable.choose_engines": "Motores transferidos", + "portable.import_prompt": "Copiar definições, predefinições, histórico e motores transferidos de {0} para {1}? Os originais não serão alterados. Começar de novo inicia um novo perfil portátil.", + "portable.cleanup_prompt": "Remover as entradas anteriores de arranque e do gestor de ficheiros deste executável? As de outras instalações não serão alteradas.", "startup.portable_unwritable": "O Filee não consegue escrever na pasta de dados portátil:\n{0}\n\nMova o Filee para uma pasta com permissão de escrita ou permita a escrita nesta pasta e tente novamente.", "general.portable_mode": "Modo portátil: as definições, os motores transferidos e os registos ficam na pasta de dados portátil. O arranque automático e a integração com o gestor de ficheiros estão desativados.", "general.title": "Geral", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index a661fb6c3..18d4b185d 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -547,6 +547,19 @@ "format.category.Model3D": "3D-модель", "format.category.Geo": "Геоданные", + "portable.import_existing": "Импортировать данные", + "portable.start_fresh": "Начать заново", + "portable.import_summary": "Скопировать настройки, пресеты, историю и загруженные движки в эту переносимую папку. Оригиналы не будут изменены или удалены.", + "portable.import_next": "Filee продолжит с выбранными данными. Если настройки не выбраны, откроется начальная настройка со значениями по умолчанию. Удаление старых записей автозапуска или Проводника для этого файла потребует отдельного подтверждения. Другие установки не изменятся.", + "portable.fresh_next": "Начать заново — открыть начальную настройку без копирования. Имеющиеся данные останутся на месте.", + "portable.choose_settings": "Настройки", + "portable.choose_presets": "Пресеты", + "portable.choose_profiles": "Профили", + "portable.choose_history": "История", + "portable.profile_dependencies": "Для пользовательских профилей могут требоваться соответствующие пресеты. Импортируйте оба для сохранения действий. Движки копируются вместе с зависимостями.", + "portable.choose_engines": "Загруженные движки", + "portable.import_prompt": "Скопировать настройки, пресеты, историю и загруженные движки из {0} в {1}? Оригиналы останутся без изменений. Начать заново создаст новый переносимый профиль.", + "portable.cleanup_prompt": "Удалить старые записи автозапуска и файлового менеджера для этого исполняемого файла? Записи других установок останутся без изменений.", "startup.portable_unwritable": "Filee не может записать данные в переносимую папку:\n{0}\n\nПереместите Filee в папку с правом записи или разрешите запись в эту папку и повторите попытку.", "general.portable_mode": "Переносимый режим: настройки, загруженные движки и журналы остаются в переносимой папке данных. Автозапуск и интеграция с файловым менеджером отключены.", "general.title": "Общие", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 99cb531ce..a921a77dd 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -535,6 +535,19 @@ "format.category.Data": "ข้อมูล", "format.category.Model3D": "โมเดล 3D", "format.category.Geo": "ข้อมูลเชิงพื้นที่", + "portable.import_existing": "นำเข้าข้อมูลเดิม", + "portable.start_fresh": "เริ่มใหม่", + "portable.import_summary": "คัดลอกการตั้งค่า พรีเซ็ต ประวัติ และเอนจินที่ดาวน์โหลดมายังโฟลเดอร์พกพานี้ ไฟล์ต้นฉบับจะไม่ถูกเปลี่ยนหรือลบ", + "portable.import_next": "Filee จะดำเนินต่อด้วยข้อมูลที่เลือก หากไม่เลือกการตั้งค่า จะเปิดการตั้งค่าเริ่มต้นแบบค่าเริ่มต้น หลังคัดลอก Filee จะใช้โปรไฟล์ที่นำเข้า หากโปรแกรมนี้มีรายการเริ่มต้นระบบหรือเมนู Explorer เดิม จะขอยืนยันแยกก่อนลบ การติดตั้งอื่นจะไม่เปลี่ยนแปลง", + "portable.fresh_next": "เริ่มใหม่จะข้ามการคัดลอกและเปิดการตั้งค่าเริ่มต้น ข้อมูลเดิมยังอยู่ที่เดิม", + "portable.choose_settings": "การตั้งค่า", + "portable.choose_presets": "พรีเซ็ต", + "portable.choose_profiles": "โปรไฟล์", + "portable.choose_history": "ประวัติ", + "portable.profile_dependencies": "โปรไฟล์ที่กำหนดเองอาจต้องใช้พรีเซ็ตที่ตรงกัน นำเข้าทั้งคู่เพื่อเก็บการทำงานไว้ เอนจินจะอยู่รวมกับส่วนที่จำเป็น", + "portable.choose_engines": "เอนจินที่ดาวน์โหลด", + "portable.import_prompt": "คัดลอกการตั้งค่า พรีเซ็ต ประวัติ และเอนจินที่ดาวน์โหลดจาก {0} ไปยัง {1} หรือไม่? ต้นฉบับจะไม่เปลี่ยนแปลง เริ่มใหม่จะเริ่มโปรไฟล์พกพาใหม่", + "portable.cleanup_prompt": "ลบรายการเริ่มต้นระบบและตัวจัดการไฟล์เดิมของไฟล์ปฏิบัติการนี้หรือไม่? รายการของการติดตั้งอื่นจะไม่เปลี่ยนแปลง", "startup.portable_unwritable": "Filee ไม่สามารถเขียนลงในโฟลเดอร์ข้อมูลแบบพกพาได้:\n{0}\n\nย้าย Filee ไปยังโฟลเดอร์ที่เขียนได้ หรืออนุญาตให้เขียนลงในโฟลเดอร์นี้ แล้วลองอีกครั้ง", "general.portable_mode": "โหมดพกพา: การตั้งค่า เอนจินที่ดาวน์โหลด และบันทึกจะอยู่ในโฟลเดอร์ข้อมูลแบบพกพา การเริ่มอัตโนมัติและการผสานกับตัวจัดการไฟล์จะถูกปิด", "general.title": "ทั่วไป", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index bcfe28105..4c4295d7b 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -535,6 +535,19 @@ "format.category.Data": "Veri", "format.category.Model3D": "3B model", "format.category.Geo": "Coğrafi veri", + "portable.import_existing": "Mevcut verileri içe aktar", + "portable.start_fresh": "Sıfırdan başla", + "portable.import_summary": "Ayarları, ön ayarları, geçmişi ve indirilen motorları bu taşınabilir klasöre kopyalayın. Asıllar değişmez veya silinmez.", + "portable.import_next": "Filee seçilen verilerle devam eder. Ayarlar seçilmezse varsayılan kurulum açılır. Bu programın eski başlangıç veya Gezgin girdilerini kaldırmak için ayrıca onay istenir. Diğer kurulumlar değişmez.", + "portable.fresh_next": "Sıfırdan başla kopyalamayı atlar ve varsayılan kurulumu açar. Mevcut veriler yerinde kalır.", + "portable.choose_settings": "Ayarlar", + "portable.choose_presets": "Ön ayarlar", + "portable.choose_profiles": "Profiller", + "portable.choose_history": "Geçmiş", + "portable.profile_dependencies": "Özel profiller eşleşen ön ayarlara ihtiyaç duyabilir. Özel eylemler için ikisini de aktarın. Motorlar bağımlılıklarıyla birlikte tutulur.", + "portable.choose_engines": "İndirilen motorlar", + "portable.import_prompt": "Ayarlar, ön ayarlar, geçmiş ve indirilen motorlar {0} konumundan {1} konumuna kopyalansın mı? Asıllar değişmez. Sıfırdan başla, yeni bir taşınabilir profil başlatır.", + "portable.cleanup_prompt": "Bu yürütülebilir dosyaya ait eski başlangıç ve dosya yöneticisi girdileri kaldırılsın mı? Diğer kurulumların girdileri değişmez.", "startup.portable_unwritable": "Filee taşınabilir veri klasörüne yazamıyor:\n{0}\n\nFilee’yi yazılabilir bir klasöre taşıyın veya bu klasöre yazma izni verip tekrar deneyin.", "general.portable_mode": "Taşınabilir mod: ayarlar, indirilen motorlar ve günlükler taşınabilir veri klasöründe kalır. Otomatik başlatma ve dosya yöneticisi tümleştirmesi devre dışıdır.", "general.title": "Genel", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index de5850591..5568d9f94 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -547,6 +547,19 @@ "format.category.Model3D": "Mô hình 3D", "format.category.Geo": "Không gian địa lý", + "portable.import_existing": "Nhập dữ liệu hiện có", + "portable.start_fresh": "Bắt đầu mới", + "portable.import_summary": "Sao chép cài đặt, thiết lập sẵn, lịch sử và bộ chuyển đổi đã tải vào thư mục di động này. Không thay đổi hay xóa bản gốc.", + "portable.import_next": "Filee tiếp tục với dữ liệu đã chọn. Nếu không chọn cài đặt, thiết lập ban đầu sẽ dùng mặc định. Việc xóa mục khởi động hoặc Explorer cũ của chương trình này cần xác nhận riêng. Các bản cài đặt khác không thay đổi.", + "portable.fresh_next": "Bắt đầu mới bỏ qua sao chép và mở thiết lập mặc định. Dữ liệu hiện có vẫn ở nguyên vị trí.", + "portable.choose_settings": "Cài đặt", + "portable.choose_presets": "Thiết lập sẵn", + "portable.choose_profiles": "Hồ sơ", + "portable.choose_history": "Lịch sử", + "portable.profile_dependencies": "Hồ sơ tùy chỉnh có thể cần thiết lập sẵn tương ứng. Nhập cả hai để giữ tác vụ. Các bộ chuyển đổi được giữ cùng phần phụ thuộc.", + "portable.choose_engines": "Bộ chuyển đổi đã tải", + "portable.import_prompt": "Sao chép cài đặt, thiết lập sẵn, lịch sử và bộ chuyển đổi đã tải từ {0} sang {1}? Bản gốc không thay đổi. Bắt đầu mới sẽ bắt đầu hồ sơ di động mới.", + "portable.cleanup_prompt": "Xóa các mục khởi động và trình quản lý tệp cũ thuộc tệp thực thi này? Các bản cài đặt khác không thay đổi.", "startup.portable_unwritable": "Filee không thể ghi vào thư mục dữ liệu di động:\n{0}\n\nHãy chuyển Filee sang thư mục có thể ghi hoặc cấp quyền ghi cho thư mục này rồi thử lại.", "general.portable_mode": "Chế độ di động: cài đặt, bộ máy đã tải và nhật ký nằm trong thư mục dữ liệu di động. Tự khởi động và tích hợp trình quản lý tệp bị tắt.", "general.title": "Chung", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 49d7e23e8..c05337f6a 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -547,6 +547,19 @@ "format.category.Model3D": "3D 模型", "format.category.Geo": "地理信息", + "portable.import_existing": "导入现有数据", + "portable.start_fresh": "全新开始", + "portable.import_summary": "将设置、预设、历史记录和已下载的引擎复制到此便携文件夹。原始文件不会更改或删除。", + "portable.import_next": "复制后使用所选数据继续。未选择设置时,将打开默认初始设置。 若此程序有旧的启动项或资源管理器菜单项,会另行询问是否删除。其他安装保持不变。", + "portable.fresh_next": "全新开始将跳过复制并打开默认初始设置。现有数据保留在原处。", + "portable.choose_settings": "设置", + "portable.choose_presets": "预设", + "portable.choose_profiles": "配置", + "portable.choose_history": "历史记录", + "portable.profile_dependencies": "自定义配置可能需要对应的预设。请同时导入以保留自定义操作。已下载的引擎与其依赖项一起处理。", + "portable.choose_engines": "已下载的引擎", + "portable.import_prompt": "将现有设置、预设、历史记录和已下载的引擎从 {0} 复制到 {1}?原始数据保持不变。全新开始将使用新的便携配置。", + "portable.cleanup_prompt": "删除属于此可执行文件的旧启动项和文件管理器菜单项?其他安装的项目保持不变。", "startup.portable_unwritable": "Filee 无法写入便携数据文件夹:\n{0}\n\n请将 Filee 移到可写文件夹,或允许写入此文件夹后重试。", "general.portable_mode": "便携模式:设置、下载的引擎和日志保存在便携数据文件夹中。自动启动和文件管理器集成已禁用。", "general.title": "常规", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 207fa3d40..04672a1ae 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -547,6 +547,19 @@ "format.category.Model3D": "3D 模型", "format.category.Geo": "地理資訊", + "portable.import_existing": "匯入現有資料", + "portable.start_fresh": "全新開始", + "portable.import_summary": "將設定、預設、歷史記錄和已下載的引擎複製到此可攜資料夾。原始檔案不會變更或刪除。", + "portable.import_next": "複製後使用所選資料繼續。未選擇設定時,將開啟預設初始設定。 若此程式有舊的啟動項目或檔案總管選單項目,會另行詢問是否刪除。其他安裝保持不變。", + "portable.fresh_next": "全新開始將略過複製並開啟預設初始設定。現有資料保留在原處。", + "portable.choose_settings": "設定", + "portable.choose_presets": "預設", + "portable.choose_profiles": "設定檔", + "portable.choose_history": "歷史記錄", + "portable.profile_dependencies": "自訂設定檔可能需要對應的預設。請同時匯入以保留自訂操作。已下載的引擎與其相依項目一起處理。", + "portable.choose_engines": "已下載的引擎", + "portable.import_prompt": "將現有設定、預設、歷史記錄和已下載的引擎從 {0} 複製到 {1}?原始資料保持不變。全新開始將使用新的可攜設定。", + "portable.cleanup_prompt": "刪除屬於此執行檔的舊啟動項目和檔案管理員選單項目?其他安裝的項目保持不變。", "startup.portable_unwritable": "Filee 無法寫入可攜資料夾:\n{0}\n\n請將 Filee 移至可寫入的資料夾,或允許寫入此資料夾後再試。", "general.portable_mode": "可攜模式:設定、下載的引擎與記錄保存在可攜資料夾中。自動啟動及檔案管理員整合已停用。", "general.title": "一般", diff --git a/src/Filee.App/PortableStartupFailure.cs b/src/Filee.App/PortableStartupFailure.cs index eb3b12310..896d1a04f 100644 --- a/src/Filee.App/PortableStartupFailure.cs +++ b/src/Filee.App/PortableStartupFailure.cs @@ -16,8 +16,18 @@ public static int Show(string message) if (OperatingSystem.IsWindows()) Filee.Platform.Windows.Elevation.ShowWarning(message); else - AppBuilder.Configure(() => new FailureApplication(message)).UsePlatformDetect() - .StartWithClassicDesktopLifetime([], ShutdownMode.OnMainWindowClose); + { + try + { + AppBuilder.Configure(() => new FailureApplication(message)).UsePlatformDetect() + .StartWithClassicDesktopLifetime([], ShutdownMode.OnMainWindowClose); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // stderr already contains the storage error; an SSH/headless session may have no display. + Console.Error.WriteLine(ex.Message); + } + } return 1; } diff --git a/src/Filee.App/Services/PortableFirstStart.cs b/src/Filee.App/Services/PortableFirstStart.cs new file mode 100644 index 000000000..a639f2ac5 --- /dev/null +++ b/src/Filee.App/Services/PortableFirstStart.cs @@ -0,0 +1,166 @@ +// Runs before loading the portable profile, so importing it does not race with first-run saves. + +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Filee.Core.Settings; +using Filee.Platform.Windows; + +namespace Filee.App.Services; + +internal static class PortableFirstStart +{ + public static async Task RunAsync() + { + if (!PortableMode.IsEnabled || PortableMode.DataDirectory is not { } destination) return; + var source = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Filee"); + if (PortableImport.CanOffer(source, destination)) + { + var engines = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Filee", "engines"); + var selection = new PortableImport.Selection { Engines = Directory.Exists(engines) }; + var engineBytes = await Task.Run(() => PortableImport.EngineSize(engines)); + var prompt = string.Format(System.Globalization.CultureInfo.CurrentCulture, + LocalizationService.EarlyText("portable.import_prompt"), source, destination); + var imported = await AskAsync(prompt, async progress => + { + await Task.Run(() => PortableImport.Copy(source, engines, destination, progress, selection)); + }, determinate: true, + acceptText: LocalizationService.EarlyText("portable.import_existing"), + declineText: LocalizationService.EarlyText("portable.start_fresh"), sourcePath: source, destinationPath: destination, + selection: selection, enginesPath: engines, engineBytes: engineBytes); + if (!imported) PortableImport.Decline(destination); + } + if (OperatingSystem.IsWindows() && Environment.ProcessPath is { } executable + && PortableIntegration.HasOwnedEntries(executable)) + await AskAsync(LocalizationService.EarlyText("portable.cleanup_prompt"), + _ => OperatingSystem.IsWindows() ? PortableIntegration.RemoveOwnedEntriesAsync(executable) : Task.CompletedTask); + } + + internal static Task AskAsync(string message, Func, Task> accept, bool determinate = false, + Action? shown = null, string? acceptText = null, string? declineText = null, + string? sourcePath = null, string? destinationPath = null, PortableImport.Selection? selection = null, string? enginesPath = null, + long? engineBytes = null) + { + var answer = new TaskCompletionSource(); + var window = new Window + { + Title = "Filee", + Width = 640, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterScreen + }; + var text = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }; + var yes = new Button { Content = acceptText ?? LocalizationService.EarlyText("common.ok") }; + yes.Classes.Add("accent"); + var no = new Button { Content = declineText ?? LocalizationService.EarlyText("common.cancel") }; + var progress = new ProgressBar + { + IsIndeterminate = !determinate, + IsVisible = false, + Minimum = 0, + Maximum = 100, + ShowProgressText = determinate, + ProgressTextFormat = "{1:0}%" + }; + var reporter = new Progress(value => progress.Value = value); + var busy = false; + CheckBox? enginesChoice = null; + var choices = new List(); + void UpdateChoices() => yes.IsEnabled = !busy && (selection is null || selection.Any); + var content = new StackPanel + { + Margin = new(28), + Spacing = 20, + }; + if (sourcePath is not null && destinationPath is not null) + { + content.Children.Add(new TextBlock { Text = LocalizationService.EarlyText("portable.import_existing"), FontSize = 22, FontWeight = FontWeight.SemiBold }); + text.Text = LocalizationService.EarlyText("portable.import_summary"); + content.Children.Add(text); + selection ??= new PortableImport.Selection(); + var choicePanel = new StackPanel { Spacing = 2 }; + void AddChoice(string key, bool selected, Action changed) + { + var label = LocalizationService.EarlyText(key); + if (key == "portable.choose_profiles") label += " *"; + var checkbox = new CheckBox { Content = label, IsChecked = selected }; + checkbox.IsCheckedChanged += (_, _) => { changed(checkbox.IsChecked == true); UpdateChoices(); }; + choices.Add(checkbox); + choicePanel.Children.Add(checkbox); + } + AddChoice("portable.choose_settings", selection.Settings, value => selection.Settings = value); + AddChoice("portable.choose_presets", selection.Presets, value => selection.Presets = value); + AddChoice("portable.choose_profiles", selection.Profiles, value => selection.Profiles = value); + AddChoice("portable.choose_history", selection.History, value => selection.History = value); + enginesChoice = new CheckBox + { + Content = LocalizationService.EarlyText("portable.choose_engines"), + IsChecked = selection.Engines, + IsEnabled = enginesPath is null || Directory.Exists(enginesPath) + }; + if (engineBytes is { } size) + enginesChoice.Content += size >= 1024L * 1024 * 1024 ? $" ({size / (1024.0 * 1024 * 1024):N1} GB)" : $" ({size / (1024.0 * 1024):N1} MB)"; + enginesChoice.IsCheckedChanged += (_, _) => { selection.Engines = enginesChoice.IsChecked == true; UpdateChoices(); }; + choices.Add(enginesChoice); + choicePanel.Children.Add(enginesChoice); + content.Children.Add(choicePanel); + content.Children.Add(new TextBlock { Text = "* " + LocalizationService.EarlyText("portable.profile_dependencies"), TextWrapping = TextWrapping.Wrap, Opacity = .8 }); + UpdateChoices(); + var paths = new StackPanel { Spacing = 10 }; + paths.Children.Add(new SelectableTextBlock { Text = sourcePath, TextWrapping = TextWrapping.Wrap }); + paths.Children.Add(new TextBlock { Text = "↓", FontSize = 20 }); + paths.Children.Add(new SelectableTextBlock { Text = destinationPath, TextWrapping = TextWrapping.Wrap, FontWeight = FontWeight.SemiBold }); + content.Children.Add(new Border + { + Padding = new(16), + CornerRadius = new(8), + BorderThickness = new(1), + BorderBrush = Brushes.Gray, + Child = paths + }); + content.Children.Add(new TextBlock { Text = LocalizationService.EarlyText("portable.import_next"), TextWrapping = TextWrapping.Wrap }); + content.Children.Add(new TextBlock { Text = LocalizationService.EarlyText("portable.fresh_next"), TextWrapping = TextWrapping.Wrap, Opacity = .8 }); + } + else content.Children.Add(text); + content.Children.Add(progress); + content.Children.Add(new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 10, + Children = { no, yes } + }); + window.Content = content; + no.Click += (_, _) => window.Close(); + yes.Click += async (_, _) => + { + busy = true; + yes.IsEnabled = no.IsEnabled = false; + foreach (var choice in choices) choice.IsEnabled = false; + progress.IsVisible = true; + try + { + await accept(reporter); + busy = false; + answer.TrySetResult(true); + window.Close(); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + text.Text = message + "\n\n" + ex.Message; + busy = false; + yes.IsEnabled = no.IsEnabled = true; + foreach (var choice in choices) choice.IsEnabled = true; + if (enginesChoice is not null) enginesChoice.IsEnabled = enginesPath is null || Directory.Exists(enginesPath); + UpdateChoices(); + progress.IsVisible = false; + } + }; + window.Closing += (_, e) => e.Cancel = busy; + window.Closed += (_, _) => answer.TrySetResult(false); + window.Show(); + shown?.Invoke(window); + return answer.Task; + } +} diff --git a/src/Filee.App/Services/UninstallCleanup.cs b/src/Filee.App/Services/UninstallCleanup.cs index 41ca9b361..46fd1d9bc 100644 --- a/src/Filee.App/Services/UninstallCleanup.cs +++ b/src/Filee.App/Services/UninstallCleanup.cs @@ -18,6 +18,8 @@ public static bool Run() var succeeded = true; // A copy with its own data folder (FILEE_DATA_DIR) never registered anything: only its engines go. var integrated = UserDataStore.OverrideDirectory is null; + if (PortableMode.IsEnabled && OperatingSystem.IsWindows() && Environment.ProcessPath is { } executable) + Clean(() => PortableIntegration.RemoveOwnedEntriesAsync(executable).GetAwaiter().GetResult()); if (integrated) { Clean(() => platform.SetContextMenu(false, "", "")); diff --git a/src/Filee.Core/Settings/PortableImport.cs b/src/Filee.Core/Settings/PortableImport.cs new file mode 100644 index 000000000..4f9659a3f --- /dev/null +++ b/src/Filee.Core/Settings/PortableImport.cs @@ -0,0 +1,140 @@ +// Copies the previous profile into an unused portable data folder, leaving the originals untouched. + +namespace Filee.Core.Settings; + +public static class PortableImport +{ + public sealed class Selection + { + public bool Settings { get; set; } = true; + public bool Presets { get; set; } = true; + public bool Profiles { get; set; } = true; + public bool History { get; set; } = true; + public bool Engines { get; set; } = true; + public bool Any => Settings || Presets || Profiles || History || Engines; + internal bool Includes(string file) => file switch + { + "settings.json" => Settings, + "presets.json" => Presets, + "profiles.json" => Profiles, + "history.json" => History, + _ => false, + }; + } + private static readonly string[] Files = ["presets.json", "profiles.json", "history.json", "settings.json"]; + public const string DecisionFile = ".portable-import-decided"; + + public static bool CanOffer(string source, string destination) => + !SamePath(source, destination) && File.Exists(Path.Combine(source, "settings.json")) + && !File.Exists(Path.Combine(destination, DecisionFile)) + && Files.All(name => !File.Exists(Path.Combine(destination, name))) + && !Directory.Exists(Path.Combine(destination, "engines")); + + public static void Decline(string destination) => File.WriteAllText(Path.Combine(destination, DecisionFile), "declined"); + + public static long? EngineSize(string directory) + { + if (!Directory.Exists(directory)) return 0; + try + { + long total = 0; + var pending = new Stack(); + pending.Push(directory); + while (pending.TryPop(out var current)) + { + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return null; + foreach (var item in new DirectoryInfo(current).EnumerateFileSystemInfos()) + { + if ((item.Attributes & FileAttributes.ReparsePoint) != 0) return null; + if (item is DirectoryInfo) pending.Push(item.FullName); + else total = checked(total + ((FileInfo)item).Length); + } + } + return total; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or OverflowException) { return null; } + } + + /// Stages all copies first, refuses links and existing targets, and rolls back a failed commit. + public static void Copy(string source, string engines, string destination, IProgress? progress = null, Selection? selection = null) + { + selection ??= new Selection(); + if (!selection.Any) throw new ArgumentException("Select data to import."); + if (!CanOffer(source, destination)) throw new IOException("Portable data is no longer empty."); + var stage = Path.Combine(destination, ".portable-import-" + Guid.NewGuid().ToString("N")); + var committed = new List(); + Directory.CreateDirectory(stage); + try + { + if (Files.Any(selection.Includes)) RejectLink(source); + var copies = new List<(string Source, string Target)>(); + foreach (var name in Files.Where(selection.Includes)) + { + var file = Path.Combine(source, name); + if (!File.Exists(file)) continue; + RejectLink(file); + using (System.Text.Json.JsonDocument.Parse(File.ReadAllText(file))) { } + copies.Add((file, Path.Combine(stage, name))); + } + if (selection.Engines && Directory.Exists(engines)) CollectDirectory(engines, Path.Combine(stage, "engines"), copies); + var total = Math.Max(1, copies.Sum(copy => new FileInfo(copy.Source).Length)); + long completed = 0; + var buffer = new byte[1024 * 1024]; + foreach (var copy in copies) + { + using var input = File.OpenRead(copy.Source); + using var output = new FileStream(copy.Target, FileMode.CreateNew, FileAccess.Write); + int read; + while ((read = input.Read(buffer)) > 0) + { + output.Write(buffer, 0, read); + completed += read; + progress?.Report(Math.Min(99, 100.0 * completed / total)); + } + if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(copy.Target, File.GetUnixFileMode(copy.Source)); + } + // Settings last: a failed import must not look like a completed profile on the next launch. + foreach (var name in new[] { "engines" }.Concat(Files)) + { + var from = Path.Combine(stage, name); + var to = Path.Combine(destination, name); + if (Directory.Exists(from)) Directory.Move(from, to); + else if (File.Exists(from)) File.Move(from, to); + else continue; + committed.Add(to); + } + File.WriteAllText(Path.Combine(destination, DecisionFile), "imported"); + progress?.Report(100); + } + catch + { + foreach (var path in committed.AsEnumerable().Reverse()) + if (Directory.Exists(path)) Directory.Delete(path, true); + else File.Delete(path); + throw; + } + finally { Directory.Delete(stage, true); } + } + + private static void CollectDirectory(string source, string destination, List<(string Source, string Target)> copies) + { + RejectLink(source); + Directory.CreateDirectory(destination); + foreach (var item in Directory.EnumerateFileSystemEntries(source)) + { + RejectLink(item); + var target = Path.Combine(destination, Path.GetFileName(item)); + if (Directory.Exists(item)) CollectDirectory(item, target, copies); + else copies.Add((item, target)); + } + } + + private static void RejectLink(string path) + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + throw new IOException($"Cannot import a symbolic link or junction: {path}"); + } + + private static bool SamePath(string a, string b) => string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar), + Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar), OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); +} diff --git a/src/Filee.Platform.Windows/ExplorerMenuRegistration.cs b/src/Filee.Platform.Windows/ExplorerMenuRegistration.cs index 147b3471f..1a01e4fad 100644 --- a/src/Filee.Platform.Windows/ExplorerMenuRegistration.cs +++ b/src/Filee.Platform.Windows/ExplorerMenuRegistration.cs @@ -153,6 +153,17 @@ public static async Task RemoveAsync(bool allowElevatio : result; } + /// Removes only the registered package whose external location is this portable folder. + public static async Task RemoveOwnedAsync(string folder) + { + var package = Find(); + if (package?.ExternalLocation is null || !SamePath(package.ExternalLocation, folder)) + return new ModernContextMenuResult(true); + // Name a single package, rather than removing every Filee version registered for this account. + return await RunPowerShellAsync(error => WrapScript( + $"Remove-AppxPackage -Package {Quote(package.FullName)}", error), elevated: false); + } + /// PowerShell that registers the package and writes the error message to . public static string BuildAddScript(string packagePath, string externalLocation, string errorFile) => WrapScript( diff --git a/src/Filee.Platform.Windows/PortableIntegration.cs b/src/Filee.Platform.Windows/PortableIntegration.cs new file mode 100644 index 000000000..04d497ffd --- /dev/null +++ b/src/Filee.Platform.Windows/PortableIntegration.cs @@ -0,0 +1,73 @@ +// A former portable copy may have registered integration before the marker existed. + +using System.Runtime.Versioning; +using Microsoft.Win32; + +namespace Filee.Platform.Windows; + +[SupportedOSPlatform("windows")] +public static class PortableIntegration +{ + internal const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; + internal const string VerbKey = @"Software\Classes\*\shell\Filee"; + private static string Shortcut => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.SendTo), "Filee.lnk"); + + public static bool HasOwnedEntries(string executable) => + HasRegistryEntries(Registry.CurrentUser, executable) + || SameExecutable(ShellLinks.TargetOf(Shortcut), executable) || OwnsModernPackage(executable); + + public static async Task RemoveOwnedEntriesAsync(string executable) + { + RemoveRegistryEntries(Registry.CurrentUser, executable); + if (SameExecutable(ShellLinks.TargetOf(Shortcut), executable)) File.Delete(Shortcut); + if (OwnsModernPackage(executable)) + { + var result = await ExplorerMenuRegistration.RemoveOwnedAsync(Path.GetDirectoryName(executable)!); + if (!result.Succeeded) throw new IOException(result.Error ?? "Could not remove the Explorer menu."); + } + } + + private static bool OwnsModernPackage(string executable) => ExplorerMenuRegistration.IsSupportedOs + && ExplorerMenuRegistration.Find()?.ExternalLocation is { } folder + && SameExecutable(Path.Combine(folder, "Filee.exe"), executable); + + internal static bool HasRegistryEntries(RegistryKey root, string executable) + { + using var run = root.OpenSubKey(RunKey); + using var command = root.OpenSubKey(VerbKey + @"\command"); + return CommandTargets(run?.GetValue("Filee") as string, executable) + || CommandTargets(command?.GetValue(null) as string, executable); + } + + internal static void RemoveRegistryEntries(RegistryKey root, string executable) + { + using var run = root.OpenSubKey(RunKey, writable: true); + if (CommandTargets(run?.GetValue("Filee") as string, executable)) run!.DeleteValue("Filee", false); + using var command = root.OpenSubKey(VerbKey + @"\command"); + var ownsVerb = CommandTargets(command?.GetValue(null) as string, executable); + command?.Dispose(); + if (ownsVerb) root.DeleteSubKeyTree(VerbKey, false); + } + + internal static bool CommandTargets(string? command, string executable) + { + if (string.IsNullOrWhiteSpace(command)) return false; + command = command.Trim(); + string target; + if (command.StartsWith('"')) + { + var end = command.IndexOf('"', 1); + if (end < 0) return false; + target = command[1..end]; + } + else target = command.Split([' ', '\t'], 2)[0]; + return SameExecutable(target, executable); + } + + private static bool SameExecutable(string? a, string b) + { + if (string.IsNullOrWhiteSpace(a) || !Path.IsPathFullyQualified(a)) return false; + try { return string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase); } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or IOException) { return false; } + } +} diff --git a/src/Filee.Setup/Installation/Installer.cs b/src/Filee.Setup/Installation/Installer.cs index 70a74b2ed..527fab63d 100644 --- a/src/Filee.Setup/Installation/Installer.cs +++ b/src/Filee.Setup/Installation/Installer.cs @@ -76,6 +76,11 @@ public InstallResult Run(InstallChoices choices, PreviousInstall? previous, IPro progress?.Report(new SetupProgress(SetupStep.Copying, 0.12 + 0.68 * bytes / total))), cancellation); Log.Info($"Unpacked and checked {payload.Manifest.Files.Count} files ({payload.Manifest.TotalSize / 1_000_000} MB)"); var newFiles = payload.Manifest.Files.Select(f => f.Path).ToList(); + if (choices.Portable) + { + File.WriteAllText(Path.Combine(swap.Staging, "filee.portable"), ""); + newFiles.Add("filee.portable"); + } if (!choices.Portable) { payload.CopyProgramTo(Path.Combine(swap.Staging, SetupNames.UninstallerExe)); diff --git a/tests/Filee.App.Tests/PortableFirstStartTests.cs b/tests/Filee.App.Tests/PortableFirstStartTests.cs new file mode 100644 index 000000000..f417e556a --- /dev/null +++ b/tests/Filee.App.Tests/PortableFirstStartTests.cs @@ -0,0 +1,140 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using Filee.App.Services; +using Filee.Core.Settings; + +namespace Filee.App.Tests; + +[Collection("Portable UI environment")] +public sealed class PortableFirstStartTests +{ + [AvaloniaFact] + public void Import_choices_default_to_both_and_require_at_least_one_selection() + { + TestServices.EnsureInitialized("en"); + Window? window = null; + var selection = new PortableImport.Selection(); + var result = PortableFirstStart.AskAsync("Import?", _ => Task.CompletedTask, + sourcePath: "old", destinationPath: "portable", selection: selection, engineBytes: 10485760, + shown: value => window = value); + try + { + var choices = window!.GetVisualDescendants().OfType().ToArray(); + Assert.Equal(5, choices.Length); + Assert.All(choices, choice => Assert.True(choice.IsChecked)); + Assert.Contains("10", choices[4].Content?.ToString()); + var import = window.GetVisualDescendants().OfType