diff --git a/README.md b/README.md index 162134f6a..7af2b2ac5 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ winget install KnifeLemon.Filee - Drag gestures need X11. On Wayland, use the drop zone and the file manager's right-click menu. +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/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..a046d0c0c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -374,6 +374,20 @@ 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; +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`). Bump `AppSettings.CurrentSchemaVersion` and add a step to `SettingsMigrations` when the format changes. 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 20a40378e..9168287f8 100644 --- a/installer/linux/INSTALL.txt +++ b/installer/linux/INSTALL.txt @@ -1,4 +1,8 @@ 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. 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 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..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,9 @@ 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" rm -f "$install_dir/.filee-upgrade-token" ln -s "$install_dir/filee-cli" "$bin_dir/filee" @@ -185,7 +206,8 @@ 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" chmod +x "$staging_dir/Filee" "$staging_dir/filee-cli" diff --git a/installer/macos/INSTALL.txt b/installer/macos/INSTALL.txt index 156ff4f48..0f262e968 100644 --- a/installer/macos/INSTALL.txt +++ b/installer/macos/INSTALL.txt @@ -1,5 +1,15 @@ 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 +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 8b51aa6a0..3eceae1c3 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -535,6 +535,21 @@ "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": "عام", "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..d19e671bc 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -535,6 +535,21 @@ "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", "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..fae80e5b8 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -547,6 +547,21 @@ "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", "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..0885548cc 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -535,6 +535,21 @@ "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", "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..14d8989a6 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -547,6 +547,21 @@ "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", "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..7054fa40d 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -535,6 +535,21 @@ "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": "सामान्य", "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..7646f74c0 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -535,6 +535,21 @@ "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", "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..406155f4b 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -534,6 +534,21 @@ "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", "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..3c1ddc1e2 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -535,6 +535,21 @@ "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": "一般", "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..8b1084fc4 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -547,6 +547,21 @@ "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": "일반", "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..27a464ede 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -535,6 +535,21 @@ "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": "सामान्य", "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..cbdf7a840 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -547,6 +547,21 @@ "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", "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..88eb3483b 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -547,6 +547,21 @@ "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", "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..c21e77dbb 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -547,6 +547,21 @@ "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", "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..18d4b185d 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -547,6 +547,21 @@ "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": "Общие", "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..a921a77dd 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -535,6 +535,21 @@ "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": "ทั่วไป", "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..4c4295d7b 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -535,6 +535,21 @@ "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", "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..5568d9f94 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -547,6 +547,21 @@ "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", "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..c05337f6a 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -547,6 +547,21 @@ "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": "常规", "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..04672a1ae 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -547,6 +547,21 @@ "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": "一般", "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..896d1a04f --- /dev/null +++ b/src/Filee.App/PortableStartupFailure.cs @@ -0,0 +1,62 @@ +// 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 + { + 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; + } + + 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/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.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/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.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/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