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 @@
+
+
+