Until now a collected image was a row of text, and after a restart it was not
visible at all. The collect list gains a row thumbnail, and a Gallery page
browses the whole store with filters by source, format and address, paged at
120 tiles, with a built-in viewer showing the full size beside its provenance.
Thumbnails decode straight to the width they are drawn at. That is the whole
memory story: a 4000x3000 JPEG is about 48 MB once decoded, so decoding full
size and scaling afterwards runs out of memory long before the user finishes
scrolling. The cache is bounded and owns its bitmaps, which means its capacity
has to comfortably exceed a page - a bitmap evicted while still on screen would
be disposed out from under the renderer.
Paged rather than infinite-scrolled for the same reason: how much to decode is a
decision the page should make, not one the archive's size makes for it.
Video is not previewed and will not be. Extracting a first frame means FFmpeg,
which is a media stack in exchange for one picture per tile; those tiles show a
format badge instead. The refusal happens before touching the disk, because
attempting it would be an exception per tile.
Rendering the page caught the viewer overlay being see-through: it named a brush
that does not exist, and an unresolved DynamicResource fails silently - the
property just keeps its default. That is the second silent-reference bug to
reach a screenshot, so both kinds now have guards: one resolves every
{DynamicResource} in the XAML against both themes, the other checks every
{l:Loc} key exists. Both were confirmed to fail before being kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 KiB
CLAUDE.md
Конвенции этого репозитория и грабли, на которые здесь уже наступили. Читать до правок.
Команды
Полный локальный гейт (формат → сборка → тесты) — то, что нужно прогнать перед коммитом:
./build.ps1
Отдельные шаги, если нужен только один из них:
dotnet build AvParser.slnx -c Release
dotnet test AvParser.slnx -c Release
dotnet csharpier check .
./run.ps1
На Linux/macOS — ./build.sh и ./run.sh с теми же шагами.
Слои
Core ← Infrastructure ← UI ← Desktop, строго в одну сторону.
AvParser.Coreне ссылается на Avalonia. Это единственное ограничение, которое здесь по-настоящему несущее: домен должен запускаться из CLI, worker-сервиса или бенчмарка. Как только Avalonia станет доступна из домена, кто-нибудь потянется кDispatcher.UIThreadилиIStorageProviderвнутри источника.AvParser.UI— библиотека, а не exe. Headless-тесты собирают настоящие View, не подтягиваяProgram.cs, Serilog и контейнер.AvParser.Desktop— тонкий composition root. Логика туда не переезжает.
Добавить источник
- Реализовать
IMediaSource— вCore/Collecting/Sources/, если сети не нужно, иначе вInfrastructure/Collecting/. - Одна строка регистрации:
AddAvParserCore()для доменного,AddAvParserCollecting()для сетевого. - Два ключа в оба resx:
Source.{id}.NameиSource.{id}.Description(без них источник отрисуется английским текстом из самого класса, а не сломается).
Всё. Каталог, страница «Сбор» и выпадающий список подхватят его сами.
Источник ищет, а не качает. Он отдаёт MediaCandidate — адрес плюс метаданные. Скачиванием,
редиректами, тайм-аутами, сниффингом и троттлингом занимается MediaFetcher, один на всех. Источник,
который сам лезет за байтами, дублирует всё это и почти наверняка неправильно.
Сетевой источник обязан переопределить RequiresNetwork => true — иначе он не попадёт под гейт
и поедет напрямую в обход настройки.
Добавить страницу
- Наследник
PageViewModelвUI/ViewModels/XxxViewModel.cs(Title,IconKey). UI/Views/XxxView.axaml— имя обязано соответствовать конвенцииViewLocator:...ViewModels.XxxViewModel→...Views.XxxView.- Регистрация в
AddAvParserUI(): конкретным типом и какPageViewModel— порядок этих регистраций и есть порядок пунктов в рельсе навигации.
ReactiveUI 24 (дистрибутив Primitives)
Это не классический ReactiveUI. System.Reactive не используется:
| Классика | Здесь |
|---|---|
Unit |
RxVoid |
IScheduler |
ISequencer (ReactiveUI.Primitives.Concurrency) |
Subject<T> / BehaviorSubject<T> |
Signal<T> / BehaviorSignal<T> |
RxApp.MainThreadScheduler |
RxSchedulers.MainThreadScheduler |
TestScheduler |
VirtualClock, ImmediateSequencer.Instance |
Привычные имена операторов (Select, Where, Throttle, DistinctUntilChanged,
CombineLatest) работают — Primitives отдаёт оба набора. using ReactiveUI.Primitives;
нужен ради Subscribe(Action<T>).
ReactiveUI 24 не инициализируется сама. Первый WhenAnyValue бросит
InvalidOperationException, пока не отработал builder. В приложении это делает
AppBuilder.UseReactiveUI(...); в проекте VM-тестов — module initializer
ReactiveUiBootstrap. Новый тестовый проект без Avalonia обязан сделать то же самое.
Конвенции ViewModel
- Каждая VM принимает
ISequencer? mainThread = nullи использует его вoutputScheduler:иToProperty(..., scheduler). Именно это делает тесты синхронными: они передаютImmediateSequencer.Instance. Без этого пришлось бы гонять диспетчер. - У VM с необязательным
ISequencerрегистрация в DI — явная фабрика, а не по типу: иначе выбор конструктора контейнером зависит от порядка регистраций. [Reactive]изReactiveUI.SourceGeneratorsна partial-свойствах; класс —partial.
Добавить источник прокси
- Реализовать
IProxySource(илиIMutableProxySource, если список редактируемый). - Зарегистрировать как
IProxySourceвAddAvParserProxies(). Порядок регистрации = порядок слияния; свой список идёт последним, чтобы пользовательский адрес перебивал фидовый.
ProxyPool при обновлении переиспользует существующие ProxyEntry по Endpoint.Key — иначе
перезагрузка списка стирала бы всю накопленную статистику, а публичные фиды переиздаются каждые
несколько минут.
Инварианты прокси-пула
- Доступность определяется карантином, а не
Health.Health— это «что видели в последний раз». Если исключать всё, что когда-либо падало, окно карантина становится бессмысленным, а прокси теряется навсегда после первой же осечки. Это уже был баг, его ловитA_failing_proxy_is_quarantined_and_comes_back_later. - Проба не трогает
SuccessCount/FailureCount. Эти счётчики про реальные запросы; свип по паре тысяч прокси перезаписал бы всё, на чём держится взвешенный выбор. Провалившаяся проба выставляет карантин черезRecordProbe(..., quarantineOnFailure:). - Лиза без вердикта нейтральна. Отменённая операция — не вина прокси; считать это отказом значит карантинить здоровые прокси на каждый Cancel.
SelectиNext— зарезервированные слова для CA1716. Метод стратегии называетсяPick.LiveCountсчитает толькоAliveи не в карантине. На нём висит гейт сбора, поэтому «доступна» (карантин истёк) и «живая» здесь намеренно расходятся: гейт не должен открываться от одного лишь истечения окна.- Прогрев обрывается по достижении цели, а не проходит список до конца.
WarmUpAsyncлинкует CTS и гасит остаток, как только набралосьMinimumLiveProxies. Порядок кандидатов —WarmUpOrder(), он публичный ровно затем, чтобы порядок проверялся без прогона проб. RestoreStateне выставляетHealth = Alive. «Работала вчера» живёт в отдельномWasAliveOnLastRunи влияет только на порядок прогрева. Если восстанавливать какAlive, пул отрапортует живыми тех, с кем не разговаривал: прогрев сочтёт цель достигнутой и не проверит никого, а гейт сбора откроется по данным недельной давности. ЛовитA_remembered_proxy_is_not_reported_live_until_it_answers_again.RestoreStateне восстанавливает карантин. Окно — стенные часы, между запусками могли пройти сутки; перенос окна сажал бы прокси за то, что давно истекло.- Сохраняются только
HasEverAnswered=SuccessCount > 0 || IsBelievedAlive, где «believed» = вердикт этой сессии, а без него — прошлой. Мёртвые в фиде исчисляются тысячами и переиздаются каждые пять минут. Важен именно перенос: прогрев обрывается рано, поэтому большинство запомненных заканчивают сессию непроверенными — строгоеHealth == Aliveстирало бы накопленный список за пару запусков.
Гейт сбора
IParser.RequiresNetwork — дефолтная реализация возвращает false, поэтому добавление источника
остаётся однострочным.
Гейт живёт в CollectViewModel.RefreshProxyGate() и складывается из трёх условий: источник сетевой,
AllowDirectConnection выключен, LiveCount == 0. Он пересчитывается по событию пула (с
throttle 250 мс — пул дёргается на каждый исход лизы), при смене источника и при смене настроек.
Второй экземпляр того же правила — в FetchOptions.RequireProxy: гейт закрывает кнопку, а фетчер
бросает ProxyUnavailableException вместо тихого прямого запроса. Одного UI мало — запрос ушёл бы
с адреса пользователя ровно тогда, когда он просил этого не делать.
CollectView.axaml держит баннер под x:Name="ProxyGateBanner"; CollectViewTests рендерит его
по-настоящему, потому что мёртвый биндинг IsVisible не ломает ни одного VM-теста.
Инварианты хранилища медиа
- В
blobs/попадает только дочитанное. Загрузка идёт во временный файл в соседнем каталоге на том же томе и продвигается переименованием. Обрыв оставляет.part, который подметает следующий старт, а не обрезанную картинку, неотличимую от настоящей навсегда. - Тип — по сигнатуре, никогда по URL, расширению или
Content-Type. Два из трёх выбирает тот, кто отдаёт файл, и расширение на диске у пользователя не должно зависеть от чужого сервера. GIF89aне доказывает анимацию, и APNG не определяется по фиксированному префиксу: нужен обход блоков (второй Image Descriptor) и чанков (acTLраньше первогоIDAT). Тихая ошибка, поэтому обходчики изолированы заinternal staticшвами и проверяются на массивах байтов.ref_countденормализован и пересчитывается, а не инкрементится. Апсертitemможет заменить строку, указывавшую на другой blob, и слепой+1уехал бы навсегда. ЕстьVerifyReferenceCountsAsync, и он часть замысла, а не отладка.- Журнал
seen_urlпереживает чистку. Иначе следующий прогон скачает заново ровно то, что пользователь только что удалил. Терминальные исходы отделены от повторяемых: отказ описывает момент, а не ресурс, и считать его окончательным значит терять контент на каждой сетевой икоте. - Вердикт лизы — про транспорт, а не про ресурс. 404 и 429 — это успех прокси. Иначе пул карантинил бы рабочие адреса ровно с той частотой, с какой встречаются мёртвые ссылки, а на лимит отвечал бы сменой прокси, то есть обходом лимита.
- Троттл поднимается только сигналами хоста (429/503 с
Retry-After) и никогда не приводит к ротации прокси. Флажка «повторить через другую прокси при 429» в настройках быть не должно. - Жёсткая ссылка — привилегия ФС, а не гарантия. Откат на копию удваивает расход диска, поэтому
достигнутый режим пишется в
showcase_modeи виден в UI. - Имя из
SuggestedNameвраждебно. Остаётся только последний сегмент, разделители не переживают, устройства Windows отодвигаются, расширение берётся из типа.
Добавить строку в UI
- Ключ и оба перевода — в
Strings.resxиStrings.ru.resx(оба, иначе упадётLocalizationTests.Russian_translates_every_english_key). - В XAML —
{l:Loc Ключ}, во ViewModel —Localizer.Instance[...]/.Format(...).
Никакого хардкода в Views/ кроме имени продукта и примеров адресов.
- Счётчики — только через
Localizer.Pluralс ключами.One/.Few/.Many. У русского три формы; «{0} records» с приклеенным окончанием непереводимо. - Перечисления в списках —
LocalizedOption<T>, не сырые значения. Конвертер разрешил бы подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы выбор не слетал. - Текст из домена переводится по коду.
Coreо языках не знает:ParseErrorнесётCodeиArguments, UI ищетCollect.Error.{Code}с откатом наMessage. Имена источников — так же:Source.{id}.Nameс откатом наDisplayName, поэтому обещание «добавить источник = одна строка» остаётся в силе. - VM, у которой есть производный от языка текст, переопределяет
OnLanguageChangedи зовётbase. Без этого заголовок страницы останется на прежнем языке.
Грабли, уже оплаченные
- Селектор типа в Avalonia матчит точный тип.
UserControl.shellне матчитShellView(наследникReactiveUserControl<T>) и молча не делает ничего. Использовать:is(UserControl).shell. Голый.shellтоже матчит, но тогда XAML-компилятор не может вывести тип дляSetterи падает с AVLN2200. - Style-сеттер навсегда проигрывает локальному значению. Не стилизовать
IsPaneOpenиDisplayMode— они биндятся во ViewModel. IPseudoClasses.Setтребует ведущего:.InvariantGlobalizationобязан бытьfalse: Semi.Avalonia строитCultureInfoв статическом конструкторе и падает целиком.- Превьюер рефлексирует безпараметровый статический
BuildAvaloniaApp(). Необязательный параметр ломает его вызов, вторая перегрузка —AmbiguousMatchException. - Compiled bindings включены по умолчанию (Avalonia 12):
x:DataTypeнужен на каждомUserControlи каждомDataTemplate. Avalonia.Headless.XUnit12.x — это xUnit v3, а не v2. Тестовые проекты —Exe.- Headless: ручной
Measure/Arrangeвнутри окна бесполезен — следующий проход layout окна вернёт свой размер. Задавать ширину самомуWindow. Но и без окна нельзя: у открепленного контрола не строится визуальное дерево. - csharpier — единственный владелец форматирования (включая
.axamlи.csproj).IDE0055понижен до suggestion: два форматтера сTreatWarningsAsErrorsдерутся насмерть. Condition="'$(Configuration)' == 'Debug'"наPackageReference— ловушка.dotnet restoreвычисляется с конфигурацией по умолчанию, поэтому пакет обязан резолвиться, даже если эту конфигурацию никто не собирает. Так тут проехал мёртвыйAvalonia.Diagnostics(его нет под Avalonia 12):dotnet build -c Releaseработал, а голыйdotnet restoreпадал.Execute()завершился ≠IsExecutingуже false. Второе публикуется на выходном планировщике. Тест, который сразу послеawaitдёргает команду, закрытую по чужомуIsExecuting, будет мигать под нагрузкой — ждитеCanExecute, а не предполагайте.- Проект VM-тестов не параллелится.
ReactiveUiBootstrapставит глобальные планировщики ReactiveUI, то есть тесты делят изменяемое состояние независимо от их желания. - Инспектора в Avalonia 12 нет из коробки.
Avalonia.Diagnosticsзакончился на 11.3.x; DevTools живут отдельно (AvaloniaUI.DiagnosticsSupport+.WithDeveloperTools()), со своей установкой. Зависимость намеренно не добавлена.
Качество
- Центральные версии пакетов —
Directory.Packages.props. НикакихVersion=в csproj (иначе NU1008 на restore). TreatWarningsAsErrorsвключён; NuGet-advisory (NU19xx) выведены из ошибок, чтобы свежая CVE не роняла сборку кода, который никто не трогал.- Тестовые послабления анализаторов — в
tests/Directory.Build.props, не в самих тестах.
Отображение медиа
- Миниатюры декодируются в нужную ширину, а не декодируются целиком и потом масштабируются. На архиве это разница между «работает» и «кончилась память».
- Кэш владеет своими
Bitmapи удаляет их при вытеснении, поэтому вызывающий не должен их освобождать — и поэтому ёмкость кэша обязана заметно превышать страницу галереи: вытесненная картинка, которая ещё на экране, освободилась бы под рендерером. - Видео не декодируется, и попытка была бы исключением на каждой плитке.
MediaKinds.IsImageотсекает это до всякого обращения к диску. {DynamicResource}с несуществующим ключом молча не срабатывает — свойство остаётся со значением по умолчанию, фон не красится, кисть прозрачная. Это уже дважды доезжало до скриншота; ловитResourceKeyTestsв обеих темах.{l:Loc}с ключом, которого нет в обоих resx, тест паритета не поймает — файлы согласованы между собой. ЛовитLocalizationCoverageTests.
Границы, выбранные намеренно
- robots.txt не читается. Оба источника v1 либо принадлежат пользователю, либо введены им вручную, поэтому спрашивать разрешения не у кого. Это решение, а не забывчивость: с появлением третьего источника, который ходит по чужому сайту, robots.txt становится обязательным.
- Перебора идентификаторов нет и не планируется. Источник перечисляет то, что сайт сам публикует; подбор адресов — это не «сбор опубликованного», и прокси-пул существует ради лимитов и доступности, а не ради их обхода.
- Возобновления по
Rangeнет. Оборванная загрузка выбрасывается целиком; частичный файл в хранилище дороже, чем повторное скачивание. - SVG не поддерживается сознательно — это текст, он умеет исполнять скрипты и несёт XXE. BMP/ICO/HEIC/JPEG-XL просто отложены.
- Перцептивных хешей нет. Дедуп точный, по SHA-256; «похожие» картинки — отдельная задача.
- Кадры из видео не извлекаются. Это FFmpeg целиком ради одной картинки в плитке.