diff --git a/CLAUDE.md b/CLAUDE.md index d76aca0..60b4907 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -241,6 +241,17 @@ throttle 250 мс — пул дёргается на каждый исход л свежая CVE не роняла сборку кода, который никто не трогал. - Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах. +## Пути + +- **Медиа лежит рядом с exe, настройки — в профиле.** Это осознанное расхождение: конфигурация + пользовательская, а коллекция принадлежит установке и переезжает вместе с папкой. +- **`DefaultMediaDirectory()` проверяет запись пробным файлом**, а не только созданием каталога: + каталог создаётся и там, куда потом нельзя записать. При отказе — откат в профиль. +- **Смена каталога требует перезапуска.** `AppPaths` строится до контейнера (медиа-корень читается + из settings.json напрямую), так что применить его на лету нельзя без переподключения индекса, + blob-хранилища и кэша миниатюр. Настройки честно это говорят и показывают действующий путь. +- **Смена каталога не переносит файлы.** Перенос архива — отдельная операция с другой ценой ошибки. + ## Отображение медиа - **Миниатюры декодируются в нужную ширину**, а не декодируются целиком и потом масштабируются. diff --git a/README.md b/README.md index 1ec6aa7..1900342 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,21 @@ proxifly-запись с `"protocol": "https"` — это всё равно HTTP десяти адресам, занимает место один раз. Провенанс (откуда, когда, каким прогоном, через какую прокси) пишется отдельно. +### Где всё лежит + +**Собранное — рядом с приложением**, в папке `media` возле исполняемого файла: коллекция и есть +смысл программы, она растёт без предела, и копирование папки должно уносить архив с собой. Каталог +меняется в настройках (кнопка «Выбрать…»), применяется после перезапуска — пути разбираются один +раз, до того как собран контейнер, и писать не туда, куда показывает поле, было бы хуже, чем +попросить перезапуск. Существующие файлы при смене **не переносятся**. + +Если папка рядом с exe недоступна для записи — установка в Program Files, монтирование только для +чтения — происходит откат в профиль пользователя. Действующий путь всегда виден в настройках, так +что откат заметен, а не загадочен. + +Настройки, логи и состояние прокси остаются в профиле (`%APPDATA%/AvParser`, +`~/.config/AvParser`): это конфигурация, она пользовательская и лежит там, где положено по правилам ОС. + ### Витрина `blobs/ab/cd/.png` не годится для просмотра глазами, поэтому рядом строится diff --git a/src/AvParser.Infrastructure/Storage/AppPaths.cs b/src/AvParser.Infrastructure/Storage/AppPaths.cs index 69dd49a..6bdf0bf 100644 --- a/src/AvParser.Infrastructure/Storage/AppPaths.cs +++ b/src/AvParser.Infrastructure/Storage/AppPaths.cs @@ -59,16 +59,53 @@ public sealed class AppPaths : IAppPaths private const string FolderName = "AvParser"; /// Creates paths under the current user's application-data directory. + /// Media lands beside the executable instead; see . public AppPaths() - : this( - Path.Combine( - Environment.GetFolderPath( - Environment.SpecialFolder.ApplicationData, - Environment.SpecialFolderOption.Create - ), - FolderName - ) - ) { } + : this(ProfileDirectory(), DefaultMediaDirectory()) { } + + /// Where settings, logs and proxy state live: the per-user profile. + public static string ProfileDirectory() => + Path.Combine( + Environment.GetFolderPath( + Environment.SpecialFolder.ApplicationData, + Environment.SpecialFolderOption.Create + ), + FolderName + ); + + /// + /// Where collected media goes when the user has not said otherwise: beside the executable. + /// + /// + /// Deliberately not the profile directory. The collection is the point of the application and + /// grows without bound, so it belongs where the application itself was put — copy the folder + /// and the archive comes with it, which is what anyone running this from a drive expects. + /// + /// That location is not always writable: an install under Program Files, or a read-only mount. + /// Falling back to the profile is better than refusing to start, and the effective path is + /// shown in Settings so the fallback is visible rather than mysterious. + /// + /// + public static string DefaultMediaDirectory() + { + var beside = Path.Combine(AppContext.BaseDirectory, "media"); + + try + { + Directory.CreateDirectory(beside); + + // Creating the directory can succeed where writing a file into it does not. + var probe = Path.Combine(beside, ".write-probe"); + File.WriteAllText(probe, string.Empty); + File.Delete(probe); + + return beside; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + return Path.Combine(ProfileDirectory(), "media"); + } + } /// Creates paths under an explicit root. Used by tests. /// Root for settings, logs and proxy state. diff --git a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs index e8b7fbe..53bfc3d 100644 --- a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs +++ b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs @@ -31,6 +31,7 @@ public static class UiServiceCollectionExtensions sp.GetRequiredService(), sp.GetRequiredService>() )); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -42,7 +43,8 @@ public static class UiServiceCollectionExtensions sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(static sp => new CollectViewModel( sp.GetRequiredService(), diff --git a/src/AvParser.UI/Localization/Strings.resx b/src/AvParser.UI/Localization/Strings.resx index bdc440d..0961f6b 100644 --- a/src/AvParser.UI/Localization/Strings.resx +++ b/src/AvParser.UI/Localization/Strings.resx @@ -700,4 +700,19 @@ PREVIEW + + Choose… + + + Where to keep collected media + + + Back to the default, beside the application + + + Empty means the default: a "media" folder beside the application, so copying the folder takes the archive with it. Point it at another drive if the collection outgrows this one. + + + Applies after a restart. This run is still writing to: + diff --git a/src/AvParser.UI/Localization/Strings.ru.resx b/src/AvParser.UI/Localization/Strings.ru.resx index b0a8cce..6ade22e 100644 --- a/src/AvParser.UI/Localization/Strings.ru.resx +++ b/src/AvParser.UI/Localization/Strings.ru.resx @@ -700,4 +700,19 @@ ПРЕВЬЮ + + Выбрать… + + + Где хранить собранное + + + Вернуть по умолчанию, рядом с приложением + + + Пусто — значит по умолчанию: папка «media» рядом с приложением, так что архив переезжает вместе с ней. Укажите другой диск, если коллекция перерастёт этот. + + + Применится после перезапуска. Этот запуск пишет сюда: + diff --git a/src/AvParser.UI/Services/FolderPicker.cs b/src/AvParser.UI/Services/FolderPicker.cs new file mode 100644 index 0000000..1ed7c90 --- /dev/null +++ b/src/AvParser.UI/Services/FolderPicker.cs @@ -0,0 +1,48 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; + +namespace AvParser.UI.Services; + +/// Asks the user for a directory. +/// +/// An interface because the picker lives on the window, not on the view model: reaching for a +/// TopLevel from a view model would make it untestable and would tie it to there being a +/// window at all. Tests substitute a canned answer. +/// +public interface IFolderPicker +{ + /// Returns the chosen directory, or null when the user cancelled. + Task PickAsync(string title, string? startAt = null); +} + +/// +public sealed class FolderPicker : IFolderPicker +{ + /// + public async Task PickAsync(string title, string? startAt = null) + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + { + return null; + } + + var window = desktop.MainWindow; + + if (window?.StorageProvider is not { CanPickFolder: true } storage) + { + return null; + } + + var options = new FolderPickerOpenOptions { Title = title, AllowMultiple = false }; + + if (!string.IsNullOrWhiteSpace(startAt) && Directory.Exists(startAt)) + { + options.SuggestedStartLocation = await storage.TryGetFolderFromPathAsync(startAt).ConfigureAwait(true); + } + + var chosen = await storage.OpenFolderPickerAsync(options).ConfigureAwait(true); + + return chosen.Count == 0 ? null : chosen[0].TryGetLocalPath(); + } +} diff --git a/src/AvParser.UI/ViewModels/SettingsViewModel.cs b/src/AvParser.UI/ViewModels/SettingsViewModel.cs index a8f7cfc..32317ea 100644 --- a/src/AvParser.UI/ViewModels/SettingsViewModel.cs +++ b/src/AvParser.UI/ViewModels/SettingsViewModel.cs @@ -87,7 +87,19 @@ public partial class SettingsViewModel : PageViewModel [Reactive] public partial LocalizedOption SelectedShowcaseMode { get; set; } + /// Configured media root; empty means the default beside the executable. + [Reactive] + public partial string MediaRootOverride { get; set; } + /// Creates the page. + /// Persisted settings. + /// Applies the theme choice. + /// Where the app writes; also the effective media root for this run. + /// Serilog level, changed live. + /// Reconfigured when a proxy knob changes. + /// Applies the language choice. + /// Asks the user for a directory. + /// Scheduler for UI-affine updates; tests pass an immediate one. public SettingsViewModel( ISettingsService settings, IThemeService theme, @@ -95,6 +107,7 @@ public partial class SettingsViewModel : PageViewModel LoggingLevelSwitch levelSwitch, IProxyPool proxyPool, ILocalizationService localization, + IFolderPicker folders, ISequencer? mainThread = null ) { @@ -129,6 +142,7 @@ public partial class SettingsViewModel : PageViewModel HostDelayMs = current.HostDelayMs; MaxItemMegabytes = (int)Math.Max(1, current.MaxItemBytes / (1024 * 1024)); SelectedShowcaseMode = Option(ShowcaseModes, current.ShowcaseMode); + MediaRootOverride = current.MediaRootOverride ?? string.Empty; this.WhenAnyValue(x => x.SelectedTheme).ObserveOn(scheduler).Subscribe(option => _theme.Apply(option.Value)); @@ -169,12 +183,38 @@ public partial class SettingsViewModel : PageViewModel x => x.HostDelayMs, x => x.MaxItemMegabytes, x => x.SelectedShowcaseMode, - (_, _, _, _, _) => RxVoid.Default + x => x.MediaRootOverride, + (_, _, _, _, _, _) => RxVoid.Default ) .Throttle(TimeSpan.FromMilliseconds(200), scheduler) .ObserveOn(scheduler) .Subscribe(_ => ApplySettings()); + this.WhenAnyValue(x => x.MediaRootOverride) + .Subscribe(_ => this.RaisePropertyChanged(nameof(MediaDirectoryNeedsRestart))); + + BrowseMediaDirectoryCommand = ReactiveCommand.CreateFromTask( + async () => + { + var chosen = await folders + .PickAsync(Localizer.Instance["Settings.MediaDirectoryPick"], MediaRootOverride) + .ConfigureAwait(true); + + if (!string.IsNullOrWhiteSpace(chosen)) + { + MediaRootOverride = chosen; + } + }, + outputScheduler: scheduler + ); + + ResetMediaDirectoryCommand = ReactiveCommand.Create( + () => MediaRootOverride = string.Empty, + outputScheduler: scheduler + ); + + BrowseMediaDirectoryCommand.ThrownExceptions.Subscribe(static _ => { }); + this.WhenAnyValue(x => x.SelectedRotation) .Subscribe(_ => this.RaisePropertyChanged(nameof(RotationDescription))); @@ -229,9 +269,31 @@ public partial class SettingsViewModel : PageViewModel /// Directory holding rolling log files. public string LogDirectory { get; } - /// Root of the collected media. Shown rather than edited: changing it needs a restart. + /// Where media is written right now. Fixed for this run; a change needs a restart. public string MediaDirectory { get; } + /// Chooses a new media root. + public ReactiveCommand BrowseMediaDirectoryCommand { get; } + + /// Puts the media root back to its default, beside the executable. + public ReactiveCommand ResetMediaDirectoryCommand { get; } + + /// + /// Whether the configured root differs from where this run is actually writing. + /// + /// + /// The paths are resolved once, before the container exists, so a change cannot take effect + /// until the next launch. Saying so is the honest option: silently writing somewhere other + /// than the box says would be worse than asking for a restart. + /// + public bool MediaDirectoryNeedsRestart => + !string.IsNullOrWhiteSpace(MediaRootOverride) + && !string.Equals( + Path.TrimEndingDirectorySeparator(MediaRootOverride.Trim()), + Path.TrimEndingDirectorySeparator(MediaDirectory), + StringComparison.OrdinalIgnoreCase + ); + /// Width in pixels at which the shell switches from compact to the icon rail. public double MediumBreakpoint => ResponsiveLayout.MediumMinWidth; @@ -289,6 +351,7 @@ public partial class SettingsViewModel : PageViewModel HostDelayMs = HostDelayMs, MaxItemBytes = (long)MaxItemMegabytes * 1024 * 1024, ShowcaseMode = SelectedShowcaseMode.Value, + MediaRootOverride = string.IsNullOrWhiteSpace(MediaRootOverride) ? null : MediaRootOverride.Trim(), }; return applied; diff --git a/src/AvParser.UI/Views/SettingsView.axaml b/src/AvParser.UI/Views/SettingsView.axaml index c1775a0..a97ca95 100644 --- a/src/AvParser.UI/Views/SettingsView.axaml +++ b/src/AvParser.UI/Views/SettingsView.axaml @@ -206,7 +206,37 @@ - + + +