From 1f6fdf7ef407a1a914067b695ac58ac97d6902ca Mon Sep 17 00:00:00 2001 From: nextestudios <47460003+nextestudios@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:21:06 -0300 Subject: [PATCH] fix: show a recognizable icon for RAR archives (#274) Use the icon Windows registered for .rar; when the Shell only has the generic blank page, draw an original archive icon (no third-party artwork). The icon cache drops when the .rar association changes. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 2 + CHANGELOG.md | 2 + docs/TESTING.md | 1 + src/ControlFS.App/Controls/IconLoader.cs | 8 ++ src/ControlFS.Core/Contracts/IIconProvider.cs | 6 ++ src/ControlFS.Core/Icons/ArchiveGlyph.cs | 83 +++++++++++++++++++ .../Shell/ShellIconProvider.cs | 55 ++++++++++++ .../Core/ArchiveGlyphTests.cs | 26 ++++++ .../ShellIconIntegrationTests.cs | 9 ++ 9 files changed, 192 insertions(+) create mode 100644 src/ControlFS.Core/Icons/ArchiveGlyph.cs create mode 100644 tests/ControlFS.UnitTests/Core/ArchiveGlyphTests.cs diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 1aa22986..bac31f2f 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -3,6 +3,8 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the release workflow uses the section matching the tag and fails if either is missing. ## [Unreleased] +### Fixes +- **Recognizable icon for .rar files** (#274): with no associated program, Windows showed a blank page. ControlFS now uses the icon Windows registered for .rar (WinRAR's, for example) and, when there is none, its own archive icon (a box with a zipper, drawn in the app, no third-party artwork). It applies to List and Grid at any scale, and the icon refreshes by itself if you install or change the .rar program. ## [0.12.0-alpha.1] ### What's new diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b9e7ab3..6bb6263d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CHANGELOG.en-US.md`. Antes de publicar uma versão, adicione uma seção `## [VERSÃO]` **nos dois arquivos**. O workflow de release usa a seção da tag e falha se faltar alguma. ## [Unreleased] +### Correções +- **Arquivos .rar com ícone reconhecível** (#274): sem programa associado, o Windows mostrava uma página em branco. Agora o ControlFS usa o ícone que o Windows registrou para .rar (o do WinRAR, por exemplo) e, quando não há nenhum, um ícone de arquivo compactado próprio (uma caixa com zíper, desenhado no app, sem arte de terceiros). Vale na lista e na grade, em qualquer escala, e o ícone se atualiza sozinho se você instalar ou trocar o programa de .rar. ## [0.12.0-alpha.1] ### Novidades diff --git a/docs/TESTING.md b/docs/TESTING.md index 025f907f..78539637 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -98,6 +98,7 @@ Ainda manual, num aparelho real: - [ ] Windows → Acessibilidade → Tamanho do texto em 150% e 200% com o app aberto: o layout se ajusta na hora, nada essencial fica cortado (menus rolam até o item focado). - [ ] Arrastar a janela entre um monitor 100% e outro 150%/200%: textos e ícones nítidos, sem reiniciar. +- [ ] .rar: com WinRAR instalado, o ícone é o dele na lista e na grade; sem programa para .rar, aparece a caixa roxa com zíper (nunca a página em branco). Instalar/trocar o programa com o ControlFS aberto atualiza o ícone em até alguns segundos. Não validado em hardware. ## Modais (#172) diff --git a/src/ControlFS.App/Controls/IconLoader.cs b/src/ControlFS.App/Controls/IconLoader.cs index a0293d50..a7de2c32 100644 --- a/src/ControlFS.App/Controls/IconLoader.cs +++ b/src/ControlFS.App/Controls/IconLoader.cs @@ -24,6 +24,7 @@ public sealed class IconLoader(IIconProvider provider, double iconSize = IconLoa public const double TileIconSize = 64; private readonly LruCache _cache = new(capacity); + private long _revision = provider.Revision; private readonly Dictionary _inflight = []; private readonly ConditionalWeakTable _pending = []; @@ -61,6 +62,13 @@ public void Load(Image image, UIElement fallback, IconRequest? request) Show(image, fallback, null, null); return; } + if (provider.Revision is var revision && revision != _revision) + { + // A associação de um tipo mudou (ex.: instalou o WinRAR): os ícones em cache podem estar velhos. + _revision = revision; + _cache.Clear(); + _inflight.Clear(); + } var key = request.Key + "@" + SizePx; image.Tag = key; if (_cache.TryGet(key, out var cached)) diff --git a/src/ControlFS.Core/Contracts/IIconProvider.cs b/src/ControlFS.Core/Contracts/IIconProvider.cs index d2365d9a..fd7dfe60 100644 --- a/src/ControlFS.Core/Contracts/IIconProvider.cs +++ b/src/ControlFS.Core/Contracts/IIconProvider.cs @@ -69,4 +69,10 @@ public interface IIconProvider { /// Ícone com lado de pixels físicos, ou null quando não há (o chamador usa um símbolo). Task GetIconAsync(IconRequest request, int sizePx, CancellationToken cancellationToken); + + /// + /// Muda quando algo que altera os ícones já entregues mudou fora do app (ex.: o programa associado a .rar): o chamador + /// descarta o cache. Barato de ler; implementações sem essa noção devolvem sempre o mesmo valor. + /// + long Revision => 0; } diff --git a/src/ControlFS.Core/Icons/ArchiveGlyph.cs b/src/ControlFS.Core/Icons/ArchiveGlyph.cs new file mode 100644 index 00000000..35e81311 --- /dev/null +++ b/src/ControlFS.Core/Icons/ArchiveGlyph.cs @@ -0,0 +1,83 @@ +using ControlFS.Core.Contracts; + +namespace ControlFS.Core.Icons; + +/// +/// Ícone de reserva para arquivos compactados quando o Windows não tem ícone para a extensão (#274): uma caixa roxa com +/// tampa e um zíper. Desenho original, feito aqui em código (nenhuma arte de terceiros), nítido em qualquer tamanho porque +/// é rasterizado direto no tamanho pedido, com suavização de bordas (3×3 amostras por pixel). +/// +public static class ArchiveGlyph +{ + private const int Samples = 3; + + // Cores em RGB (não pré-multiplicado); o resultado sai BGRA pré-multiplicado, como o resto dos ícones. + private static readonly (byte R, byte G, byte B) Body = (0x7A, 0x5C, 0xE0); + private static readonly (byte R, byte G, byte B) Lid = (0x5B, 0x3F, 0xC4); + private static readonly (byte R, byte G, byte B) Zip = (0xF4, 0xF1, 0xFF); + + public static IconImage Render(int sizePx) + { + var size = Math.Max(16, sizePx); + var pixels = new byte[size * size * 4]; + for (var y = 0; y < size; y++) + { + for (var x = 0; x < size; x++) + { + double r = 0, g = 0, b = 0, a = 0; + for (var sy = 0; sy < Samples; sy++) + { + for (var sx = 0; sx < Samples; sx++) + { + var u = (x + ((sx + 0.5) / Samples)) / size; + var v = (y + ((sy + 0.5) / Samples)) / size; + if (Sample(u, v) is not { } color) continue; + r += color.R; g += color.G; b += color.B; a += 1; + } + } + if (a == 0) continue; + var coverage = a / (Samples * Samples); + var i = ((y * size) + x) * 4; + // Média das cores das amostras cobertas, pré-multiplicada pela cobertura. + pixels[i] = (byte)Math.Round(b / a * coverage); + pixels[i + 1] = (byte)Math.Round(g / a * coverage); + pixels[i + 2] = (byte)Math.Round(r / a * coverage); + pixels[i + 3] = (byte)Math.Round(255 * coverage); + } + } + return new IconImage(size, size, pixels); + } + + /// Cor no ponto (u, v) de 0 a 1, ou null se fora do desenho. + private static (byte R, byte G, byte B)? Sample(double u, double v) + { + // Tampa: faixa de cima, um pouco mais larga que o corpo. + if (InRoundedRect(u, v, 0.14, 0.12, 0.86, 0.36, 0.06)) + { + // Zíper: dentes alternados numa coluna central que atravessa a tampa e o corpo. + return InZip(u, v) ? Zip : Lid; + } + if (InRoundedRect(u, v, 0.20, 0.32, 0.80, 0.90, 0.06)) + return InZip(u, v) ? Zip : Body; + return null; + } + + private static bool InZip(double u, double v) + { + if (v < 0.12 || v > 0.72 || u < 0.455 || u > 0.545) return false; + // Dentes: quadrados alternados à esquerda e à direita do centro, de 0,06 de altura. + var row = (int)Math.Floor((v - 0.12) / 0.06); + var left = u < 0.5; + return left == (row % 2 == 0); + } + + private static bool InRoundedRect(double u, double v, double x0, double y0, double x1, double y1, double radius) + { + if (u < x0 || u > x1 || v < y0 || v > y1) return false; + var cx = u < x0 + radius ? x0 + radius : u > x1 - radius ? x1 - radius : u; + var cy = v < y0 + radius ? y0 + radius : v > y1 - radius ? y1 - radius : v; + var dx = u - cx; + var dy = v - cy; + return (dx * dx) + (dy * dy) <= radius * radius; + } +} diff --git a/src/ControlFS.Infrastructure.Windows/Shell/ShellIconProvider.cs b/src/ControlFS.Infrastructure.Windows/Shell/ShellIconProvider.cs index 20eea361..9c3c9dad 100644 --- a/src/ControlFS.Infrastructure.Windows/Shell/ShellIconProvider.cs +++ b/src/ControlFS.Infrastructure.Windows/Shell/ShellIconProvider.cs @@ -2,6 +2,8 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; using ControlFS.Core.Contracts; +using ControlFS.Core.Icons; +using Microsoft.Win32; namespace ControlFS.Infrastructure.Windows.Shell; @@ -45,6 +47,46 @@ public ShellIconProvider() public void Dispose() => _queue.CompleteAdding(); + // ---------- Associação de .rar (#274) ---------- + + private static readonly string[] ArchiveExtensions = [".rar"]; + private long _revision; + private long _revisionChecked = long.MinValue; + private int _revisionHash; + + /// + /// Muda quando o programa associado a .rar (ou o ícone dele) muda no registro. Lido no máximo a cada 2 s, sem tocar no disco. + /// + public long Revision + { + get + { + if (!OperatingSystem.IsWindows()) return 0; + var now = Environment.TickCount64; + if (now - _revisionChecked < 2000) return _revision; + _revisionChecked = now; + var hash = AssociationFingerprint(".rar"); + if (hash != _revisionHash) { _revisionHash = hash; _revision++; } + return _revision; + } + } + + [SupportedOSPlatform("windows")] + private static int AssociationFingerprint(string extension) + { + try + { + var progId = Registry.GetValue(@"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\" + extension + @"\UserChoice", "ProgId", null) as string + ?? Registry.ClassesRoot.OpenSubKey(extension)?.GetValue(null) as string; + var icon = progId is null ? null : Registry.ClassesRoot.OpenSubKey(progId + @"\DefaultIcon")?.GetValue(null) as string; + return HashCode.Combine(progId, icon); + } + catch (Exception ex) when (ex is System.Security.SecurityException or IOException or UnauthorizedAccessException or ArgumentException) + { + return 0; + } + } + private void Run() { foreach (var work in _queue.GetConsumingEnumerable()) @@ -156,6 +198,10 @@ private struct BitmapInfoHeader } else if (SHGetFileInfoW(name, attributes, &info, (uint)sizeof(ShFileInfo), flags) == 0) return null; + // Compactado sem programa associado: o Shell devolve a página em branco genérica; aqui entra o ícone de reserva. + if (request.Kind == IconSourceKind.Extension && Array.IndexOf(ArchiveExtensions, request.Value) >= 0 && IsGenericFileIcon(info.IIcon)) + return ArchiveGlyph.Render(sizePx); + var list = sizePx <= 16 ? ShilSmall : sizePx <= 32 ? ShilLarge : sizePx <= 48 ? ShilExtraLarge : ShilJumbo; var image = FromImageList(list, info.IIcon); // Tipos sem arte de 256 px vêm pequenos no canto da imagem "jumbo": nesse caso, usa a de 48 px. @@ -163,6 +209,15 @@ private struct BitmapInfoHeader return image; } + /// O ícone é o de arquivo sem tipo (o mesmo de uma extensão que ninguém registrou)? + [SupportedOSPlatform("windows")] + private static unsafe bool IsGenericFileIcon(int iconIndex) + { + ShFileInfo generic = default; + return SHGetFileInfoW("arquivo.controlfs-sem-tipo", FileAttributeNormal, &generic, (uint)sizeof(ShFileInfo), ShgfiSysIconIndex | ShgfiUseFileAttributes) != 0 + && generic.IIcon == iconIndex; + } + /// /// Ícone de um atalho. .url (jogo da Steam): IconFile/IconIndex do atalho e, se o arquivo não existir, /// o mesmo ícone na instalação local da Steam. .lnk: o ícone declarado; sem ele, o do destino (programa .exe/.ico diff --git a/tests/ControlFS.UnitTests/Core/ArchiveGlyphTests.cs b/tests/ControlFS.UnitTests/Core/ArchiveGlyphTests.cs new file mode 100644 index 00000000..6fee4bad --- /dev/null +++ b/tests/ControlFS.UnitTests/Core/ArchiveGlyphTests.cs @@ -0,0 +1,26 @@ +using ControlFS.Core.Icons; +using Xunit; + +namespace ControlFS.UnitTests.Core; + +public sealed class ArchiveGlyphTests +{ + [Theory] + [InlineData(16)] + [InlineData(48)] + [InlineData(256)] + public void The_fallback_archive_icon_is_drawn_at_the_requested_size_with_transparent_corners_and_visible_body(int size) + { + var icon = ArchiveGlyph.Render(size); + var pixels = icon.Pixels.Span; + + Assert.Equal(size, icon.Width); + Assert.Equal(size, icon.Height); + Assert.Equal(size * size * 4, pixels.Length); + Assert.Equal(0, pixels[3]); // canto de cima à esquerda: transparente + var centre = (((size / 2) * size) + (size / 2)) * 4; + Assert.Equal(255, pixels[centre + 3]); // o corpo no centro: opaco + for (var i = 0; i < pixels.Length; i += 4) + Assert.True(pixels[i] <= pixels[i + 3] && pixels[i + 1] <= pixels[i + 3] && pixels[i + 2] <= pixels[i + 3], "alfa pré-multiplicado"); + } +} diff --git a/tests/ControlFS.WindowsIntegrationTests/ShellIconIntegrationTests.cs b/tests/ControlFS.WindowsIntegrationTests/ShellIconIntegrationTests.cs index 5389456c..c2e8e671 100644 --- a/tests/ControlFS.WindowsIntegrationTests/ShellIconIntegrationTests.cs +++ b/tests/ControlFS.WindowsIntegrationTests/ShellIconIntegrationTests.cs @@ -51,4 +51,13 @@ public async Task Text_files_folders_drives_and_the_recycle_bin_get_a_visible_ic Assert.True(visible > icon.Width, $"{request.Key}: ícone sem pixels visíveis"); } } + + [Fact] + public async Task A_rar_file_gets_an_icon_even_when_windows_has_no_program_for_it() + { + var request = IconRequest.For(new FileEntry("a.rar", "a.rar", EntryKind.File, FullPath: Path.Join(_root, "a.rar")))!; + var icon = await _icons.GetIconAsync(request, 48, TestContext.Current.CancellationToken); + Assert.NotNull(icon); // o do Windows, ou o de reserva do ControlFS: nunca a página em branco nem nada + Assert.InRange(icon.Width, 16, 256); + } }