diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index d562ff8..fe35048 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,6 +4,13 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] +## [1.6.0-beta.4] +### Heads-up: beta version, not the stable one +- This is a test version of 1.6. The stable version is still 1.5.1, and people on it don't get this update automatically: only those who turned on Settings → "Receive test versions (alpha and beta)" or are already on an alpha/beta. If something breaks, go back to 1.5.1 and report it with the feedback button (attach `consolemode.log`). + +### Fixes +- Session menu: the window cards are all the same size and show each program's right icon (Microsoft Store apps too), sharp on the TV; the close-window confirmation is now its own card with the window's icon and title, and clear "Cancel" and "Close window" buttons. (#135) + ## [1.6.0-beta.3] ### Heads-up: beta version, not the stable one - This is a test version of 1.6. The stable version is still 1.5.1, and people on it don't get this update automatically: only those who turned on Settings → "Receive test versions (alpha and beta)" or are already on an alpha/beta. If something breaks, go back to 1.5.1 and report it with the feedback button (attach `consolemode.log`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 85885a0..5511142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] +## [1.6.0-beta.4] +### Atenção: versão beta, não é a estável +- Esta é uma versão de teste da 1.6. A versão estável continua sendo a 1.5.1, e quem está nela não recebe esta atualização automaticamente: só quem ligou Ajustes → "Receber versões de teste (alpha e beta)" ou já está numa alpha/beta. Se algo falhar, volte para a 1.5.1 e conte pelo botão de feedback (anexe o `consolemode.log`). + +### Correções +- Menu da sessão: os cartões das janelas têm todos o mesmo tamanho e mostram o ícone certo de cada programa (também de apps da Microsoft Store), nítido na TV; a confirmação de fechar janela ganhou um cartão próprio com o ícone e o título da janela, "Cancelar" e "Fechar janela" em destaque. (#135) + ## [1.6.0-beta.3] ### Atenção: versão beta, não é a estável - Esta é uma versão de teste da 1.6. A versão estável continua sendo a 1.5.1, e quem está nela não recebe esta atualização automaticamente: só quem ligou Ajustes → "Receber versões de teste (alpha e beta)" ou já está numa alpha/beta. Se algo falhar, volte para a 1.5.1 e conte pelo botão de feedback (anexe o `consolemode.log`). diff --git a/docs/TESTING.md b/docs/TESTING.md index 51dd89b..6e2834e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -126,6 +126,8 @@ Pré-condições: Ajustes → "Entrar ao conectar um controle" **ligado**; app n - [ ] Direcional entre o painel lateral e o grid de janelas (esquerda/direita) e dentro de cada um (cima/baixo) nunca fica "morto"; com o seletor (resolução, áudio, FPS) aberto o foco não escapa para trás. Resultado: ______ - [ ] **Janelas**: lista as janelas abertas (ícone, programa e título) em ordem de frente para trás. A traz a escolhida para a frente (restaura se estiver minimizada) e fecha o menu; X (□ no PlayStation) ou Delete pede para a janela fechar e o card some quando ela fecha; uma que pergunta "salvar?" mantém o card. Resultado: ______ - [ ] **Confirmação ao fechar janela**: pelo X do controle, Delete e botão de fechar no cartão, aparece um modal com o título correto; Cancelar ou B mantém a janela aberta e devolve o foco ao cartão; confirmar fecha só a janela selecionada. Enquanto o modal está aberto, direcional, A, X e B não acionam opções atrás dele. Resultado: ______ +- [ ] **Cartões das janelas**: todos os cartões têm o mesmo tamanho, com título curto ou de duas linhas. Cada um mostra o ícone do próprio programa, nítido e do mesmo tamanho: um programa comum (Bloco de Notas, navegador), a Steam, uma pasta do Explorador e um app da Microsoft Store (Calculadora, Configurações). Só um programa sem ícone fica com o ícone genérico no círculo. Resultado: ______ +- [ ] **Visual da confirmação**: o modal é um cartão centralizado com cantos arredondados, o ícone, o programa e o título da janela escolhida, "Cancelar" (em foco ao abrir) e "Fechar janela" em vermelho, lado a lado e da mesma largura; título longo quebra em até 2 linhas sem estourar o cartão. Resultado: ______ - [ ] **Falha ao ativar uma janela**: abra o menu com o Bloco de Notas aberto, feche o Bloco de Notas por outro meio depois de a lista carregar (por exemplo, `taskkill /IM notepad.exe /F`) e escolha o card obsoleto com A. O menu continua aberto, o foco permanece no card e o log mostra `Janelas: trocar ... => não conseguiu`; uma falha do Windows ao trazer uma janela válida para frente também deve manter o menu aberto. Resultado: ______ - [ ] A lista não mostra o próprio menu, a barra de tarefas, a área de trabalho nem apps UWP suspensos; sem janelas mostra "Nenhuma janela aberta". Resultado: ______ - [ ] Em sessão (Big Picture/Playnite aberto): trocar para outra janela **não** restaura a mesa, e voltar ao Big Picture pelo grid funciona; uma janela aberta na tela de jogo aparece na lista. Resultado: ______ diff --git a/src/ConsoleMode/ConsoleMode.csproj b/src/ConsoleMode/ConsoleMode.csproj index f0db4a3..64c9687 100644 --- a/src/ConsoleMode/ConsoleMode.csproj +++ b/src/ConsoleMode/ConsoleMode.csproj @@ -17,10 +17,10 @@ true latest x64 - 1.6.0-beta.3 + 1.6.0-beta.4 1.6.0.0 1.6.0.0 - 1.6.0-beta.3 + 1.6.0-beta.4 Console Mode Console Mode lippdev diff --git a/src/ConsoleMode/Native/WindowIcons.cs b/src/ConsoleMode/Native/WindowIcons.cs new file mode 100644 index 0000000..ba26796 --- /dev/null +++ b/src/ConsoleMode/Native/WindowIcons.cs @@ -0,0 +1,206 @@ +using System.Runtime.InteropServices; +using System.Text; +using ConsoleMode.Services; + +namespace ConsoleMode.Native; + +/// An icon as premultiplied BGRA pixels, top row first. +public sealed record IconBitmap(int Width, int Height, byte[] Pixels); + +/// +/// The program icon of a window for the session menu's switcher, always at the size asked for. +/// Store apps (hosted by ApplicationFrameHost, or installed under WindowsApps) give their tile logo +/// through the shell; other programs the icon of their exe, scaled by Windows; and a window whose +/// exe has none (or can't be read) the icon it shows on the taskbar. +/// +public static class WindowIcons +{ + /// Never throws: null means the card keeps its generic icon. + public static IconBitmap? Load(SwitchWindow window, int size) + { + try + { + return FromPackage(window, size) ?? FromExe(window.ExePath, size) ?? FromWindow(window.Handle); + } + catch (Exception ex) + { + AppLog.Write($"Janelas: ícone de \"{window.ProcessName}\": {ex.Message}"); + return null; + } + } + + private static IconBitmap? FromPackage(SwitchWindow window, int size) + { + var id = AppUserModelIdOf(window); + if (id is null) return null; + var factoryId = typeof(IShellItemImageFactory).GUID; + if (SHCreateItemFromParsingName($@"shell:AppsFolder\{id}", 0, ref factoryId, out var factory) != 0 || factory is null) return null; + try + { + if (factory.GetImage(new SIZE { cx = size, cy = size }, 0, out var bitmap) != 0 || bitmap == 0) return null; + try + { + // The shell's bitmaps are already premultiplied. + return ReadBitmap(bitmap) is { } read && !IconPixels.IsBlank(read.Pixels) ? read : null; + } + finally { DeleteObject(bitmap); } + } + finally { Marshal.ReleaseComObject(factory); } + } + + /// The package app behind a window, or null for a classic program. + private static string? AppUserModelIdOf(SwitchWindow window) + { + GetWindowThreadProcessId(window.Handle, out var pid); + var isFrameHost = string.Equals(Path.GetFileName(window.ExePath), "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase); + if (isFrameHost) + { + // The frame belongs to the host; the app is the process of the window inside it. + uint inner = 0; + EnumChildWindows(window.Handle, (child, _) => + { + GetWindowThreadProcessId(child, out var childPid); + if (childPid == pid) return true; + inner = childPid; + return false; + }, 0); + if (inner == 0) return null; + pid = inner; + } + + var process = OpenProcess(0x1000 /* PROCESS_QUERY_LIMITED_INFORMATION */, false, pid); + if (process == 0) return null; + try + { + var length = 512; + var id = new StringBuilder(length); + return GetApplicationUserModelId(process, ref length, id) == 0 && id.Length > 0 ? id.ToString() : null; + } + finally { CloseHandle(process); } + } + + private static IconBitmap? FromExe(string? path, int size) + { + if (string.IsNullOrWhiteSpace(path)) return null; + var icons = new nint[1]; + // Picks the closest size stored in the exe and scales it, so a small icon doesn't come back lost in a big frame. + var count = PrivateExtractIcons(path, 0, size, size, icons, null, 1, 0); + if (count == 0 || count == uint.MaxValue || icons[0] == 0) return null; + try { return ReadIcon(icons[0]); } + finally { DestroyIcon(icons[0]); } + } + + private static IconBitmap? FromWindow(nint hwnd) + { + foreach (var (message, classIndex) in new[] { (IconBig, GclpHIcon), (IconSmall2, GclpHIconSm) }) + { + var icon = SendMessageTimeout(hwnd, WmGetIcon, message, 0, SmtoAbortIfHung, 150, out var result) != 0 ? result : 0; + if (icon == 0) icon = GetClassLongPtr(hwnd, classIndex); + // Owned by the window: read, never destroyed. + if (icon != 0 && ReadIcon(icon) is { } read) return read; + } + return null; + } + + private static IconBitmap? ReadIcon(nint icon) + { + if (!GetIconInfo(icon, out var info)) return null; + try + { + if (info.hbmColor == 0) return null; // monochrome: not worth showing + var color = ReadBitmap(info.hbmColor); + if (color is null) return null; + var mask = info.hbmMask == 0 ? null : ReadBitmap(info.hbmMask); + var sameSize = mask is not null && mask.Width == color.Width && mask.Height == color.Height; + IconPixels.Premultiply(color.Pixels, sameSize ? mask!.Pixels : null); + return IconPixels.IsBlank(color.Pixels) ? null : color; + } + finally + { + if (info.hbmColor != 0) DeleteObject(info.hbmColor); + if (info.hbmMask != 0) DeleteObject(info.hbmMask); + } + } + + private static IconBitmap? ReadBitmap(nint bitmap) + { + if (GetObject(bitmap, Marshal.SizeOf(), out var facts) == 0) return null; + int width = facts.bmWidth, height = Math.Abs(facts.bmHeight); + if (width <= 0 || height <= 0 || width > 1024 || height > 1024) return null; + + // Negative height: rows come top first. + var header = new BITMAPINFOHEADER { biSize = Marshal.SizeOf(), biWidth = width, biHeight = -height, biPlanes = 1, biBitCount = 32 }; + var pixels = new byte[width * height * 4]; + var screen = GetDC(0); + try + { + return GetDIBits(screen, bitmap, 0, (uint)height, pixels, ref header, 0) == height ? new IconBitmap(width, height, pixels) : null; + } + finally { ReleaseDC(0, screen); } + } + + private const uint WmGetIcon = 0x007F; + private const nint IconBig = 1; + private const nint IconSmall2 = 2; + private const int GclpHIcon = -14; + private const int GclpHIconSm = -34; + private const uint SmtoAbortIfHung = 0x0002; + + [StructLayout(LayoutKind.Sequential)] + private struct SIZE { public int cx, cy; } + + [StructLayout(LayoutKind.Sequential)] + private struct ICONINFO + { + [MarshalAs(UnmanagedType.Bool)] public bool fIcon; + public int xHotspot, yHotspot; + public nint hbmMask, hbmColor; + } + + [StructLayout(LayoutKind.Sequential)] + private struct BITMAP + { + public int bmType, bmWidth, bmHeight, bmWidthBytes; + public ushort bmPlanes, bmBitsPixel; + public nint bmBits; + } + + [StructLayout(LayoutKind.Sequential)] + private struct BITMAPINFOHEADER + { + public int biSize, biWidth, biHeight; + public ushort biPlanes, biBitCount; + public int biCompression, biSizeImage, biXPelsPerMeter, biYPelsPerMeter, biClrUsed, biClrImportant; + } + + [ComImport, Guid("bcc18b79-ba16-442f-80c4-8a59c30c463b"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellItemImageFactory + { + [PreserveSig] int GetImage(SIZE size, int flags, out nint bitmap); + } + + private delegate bool EnumProc(nint hwnd, nint lParam); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SHCreateItemFromParsingName(string path, nint bindContext, ref Guid interfaceId, + [MarshalAs(UnmanagedType.Interface)] out IShellItemImageFactory? item); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern uint PrivateExtractIcons(string file, int index, int width, int height, nint[] icons, uint[]? ids, uint count, uint flags); + [DllImport("user32.dll")] private static extern bool DestroyIcon(nint icon); + [DllImport("user32.dll")] private static extern bool GetIconInfo(nint icon, out ICONINFO info); + [DllImport("user32.dll")] private static extern bool EnumChildWindows(nint parent, EnumProc proc, nint lParam); + [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(nint hwnd, out uint processId); + [DllImport("user32.dll", EntryPoint = "SendMessageTimeoutW")] + private static extern nint SendMessageTimeout(nint hwnd, uint message, nint wParam, nint lParam, uint flags, uint timeoutMs, out nint result); + [DllImport("user32.dll", EntryPoint = "GetClassLongPtrW")] private static extern nint GetClassLongPtr(nint hwnd, int index); + [DllImport("user32.dll")] private static extern nint GetDC(nint hwnd); + [DllImport("user32.dll")] private static extern int ReleaseDC(nint hwnd, nint dc); + [DllImport("gdi32.dll", EntryPoint = "GetObjectW")] private static extern int GetObject(nint handle, int size, out BITMAP bitmap); + [DllImport("gdi32.dll")] + private static extern int GetDIBits(nint dc, nint bitmap, uint start, uint lines, [Out] byte[] bits, ref BITMAPINFOHEADER info, uint usage); + [DllImport("gdi32.dll")] private static extern bool DeleteObject(nint handle); + [DllImport("kernel32.dll")] private static extern nint OpenProcess(uint access, bool inherit, uint pid); + [DllImport("kernel32.dll")] private static extern bool CloseHandle(nint handle); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetApplicationUserModelId(nint process, ref int length, StringBuilder id); +} diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index 1dd97e8..2c6fd27 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -97,7 +97,7 @@ "SwitcherOpen": "Open", "SwitcherCloseWindow": "Close window", "SwitcherCloseConfirmTitle": "Close this window?", - "SwitcherCloseConfirmBody": "{0} will be closed. If there are unsaved changes, the app may still ask you to save them.", + "SwitcherCloseConfirmBody": "If there are unsaved changes, the app may still ask you to save them.", "SwitcherCloseConfirmAction": "Close window", "SessionPreviewBadge": "Preview · outside a session", "ConsoleActiveHint": "Or hold {0} on the controller for 1 second.", diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json index a64e9a2..f8c7501 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -97,7 +97,7 @@ "SwitcherOpen": "Abrir", "SwitcherCloseWindow": "Cerrar ventana", "SwitcherCloseConfirmTitle": "¿Cerrar esta ventana?", - "SwitcherCloseConfirmBody": "Se cerrará {0}. Si hay cambios sin guardar, la aplicación todavía puede pedirte que los guardes.", + "SwitcherCloseConfirmBody": "Si hay cambios sin guardar, la aplicación todavía puede pedirte que los guardes.", "SwitcherCloseConfirmAction": "Cerrar ventana", "SessionPreviewBadge": "Vista previa · fuera de la sesión", "ConsoleActiveHint": "O mantén {0} en el mando durante 1 segundo.", diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json index ebe5804..94c44ea 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -97,7 +97,7 @@ "SwitcherOpen": "Abrir", "SwitcherCloseWindow": "Fechar janela", "SwitcherCloseConfirmTitle": "Fechar esta janela?", - "SwitcherCloseConfirmBody": "{0} será fechado. Se houver alterações sem salvar, o aplicativo ainda poderá pedir que você as salve.", + "SwitcherCloseConfirmBody": "Se houver alterações sem salvar, o aplicativo ainda poderá pedir que você as salve.", "SwitcherCloseConfirmAction": "Fechar janela", "SessionPreviewBadge": "Prévia · fora da sessão", "ConsoleActiveHint": "Ou segure {0} no controle por 1 segundo.", diff --git a/src/ConsoleMode/Services/IconPixels.cs b/src/ConsoleMode/Services/IconPixels.cs new file mode 100644 index 0000000..9acf1f6 --- /dev/null +++ b/src/ConsoleMode/Services/IconPixels.cs @@ -0,0 +1,43 @@ +namespace ConsoleMode.Services; + +/// +/// Pure pixel rules for the program icons of the window switcher. Windows hands an icon over as BGRA +/// with straight alpha, or, for old icons, with no alpha at all and a separate mask; the card needs +/// premultiplied BGRA. Kept apart so it is tested. +/// +public static class IconPixels +{ + /// + /// Turns straight BGRA into premultiplied BGRA, in place. An icon without any alpha takes it from + /// (BGRA, same size: a non-black pixel is transparent), or becomes opaque without one. + /// + public static void Premultiply(byte[] color, byte[]? mask) + { + var hasAlpha = false; + for (var i = 3; i < color.Length; i += 4) + { + if (color[i] == 0) continue; + hasAlpha = true; + break; + } + + for (var i = 0; i + 3 < color.Length; i += 4) + { + var alpha = hasAlpha ? color[i + 3] + : mask is not null && i + 2 < mask.Length && (mask[i] | mask[i + 1] | mask[i + 2]) != 0 ? (byte)0 + : (byte)255; + color[i] = (byte)(color[i] * alpha / 255); + color[i + 1] = (byte)(color[i + 1] * alpha / 255); + color[i + 2] = (byte)(color[i + 2] * alpha / 255); + color[i + 3] = alpha; + } + } + + /// Nothing to see: every pixel is fully transparent (or there are none). + public static bool IsBlank(byte[] pixels) + { + for (var i = 3; i < pixels.Length; i += 4) + if (pixels[i] != 0) return false; + return true; + } +} diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs index 99e2d16..465b545 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -200,6 +200,7 @@ internal void NotifyAll() public string SwitcherOpen => LocalizationService.Get(nameof(SwitcherOpen)); public string SwitcherCloseWindow => LocalizationService.Get(nameof(SwitcherCloseWindow)); public string SwitcherCloseConfirmTitle => LocalizationService.Get(nameof(SwitcherCloseConfirmTitle)); + public string SwitcherCloseConfirmBody => LocalizationService.Get(nameof(SwitcherCloseConfirmBody)); public string SwitcherCloseConfirmAction => LocalizationService.Get(nameof(SwitcherCloseConfirmAction)); public string ShortcutCancel => LocalizationService.Get(nameof(ShortcutCancel)); public string BackgroundCard => LocalizationService.Get(nameof(BackgroundCard)); diff --git a/src/ConsoleMode/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml index 0029b48..9159529 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml +++ b/src/ConsoleMode/SessionMenuWindow.xaml @@ -24,6 +24,9 @@ + + + @@ -32,6 +35,9 @@ + + +