Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
2 changes: 2 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: ______
Expand Down
4 changes: 2 additions & 2 deletions src/ConsoleMode/ConsoleMode.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<LangVersion>latest</LangVersion>
<Platform Condition="'$(Platform)' == ''">x64</Platform>
<Version>1.6.0-beta.3</Version>
<Version>1.6.0-beta.4</Version>
<AssemblyVersion>1.6.0.0</AssemblyVersion>
<FileVersion>1.6.0.0</FileVersion>
<InformationalVersion>1.6.0-beta.3</InformationalVersion>
<InformationalVersion>1.6.0-beta.4</InformationalVersion>
<AssemblyTitle>Console Mode</AssemblyTitle>
<Product>Console Mode</Product>
<Authors>lippdev</Authors>
Expand Down
206 changes: 206 additions & 0 deletions src/ConsoleMode/Native/WindowIcons.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
using System.Runtime.InteropServices;
using System.Text;
using ConsoleMode.Services;

namespace ConsoleMode.Native;

/// <summary>An icon as premultiplied BGRA pixels, top row first.</summary>
public sealed record IconBitmap(int Width, int Height, byte[] Pixels);

/// <summary>
/// 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.
/// </summary>
public static class WindowIcons
{
/// <summary>Never throws: null means the card keeps its generic icon.</summary>
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); }
}

/// <summary>The package app behind a window, or null for a classic program.</summary>
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<BITMAP>(), 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<BITMAPINFOHEADER>(), 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This direct import does not cover the x86 target: Microsoft documents GetClassLongPtr as a header macro that maps to GetClassLong in 32-bit builds. If both WM_GETICON requests fail, the missing entry point aborts FromWindow, so Load catches it and the taskbar-icon fallback is skipped.

Technical details
# Keep the taskbar-icon fallback working on x86

## Affected sites
- `src/ConsoleMode/Native/WindowIcons.cs:195` — imports `GetClassLongPtrW` directly; the x86 API is `GetClassLongW`.
- `src/ConsoleMode/Native/WindowIcons.cs:97-100` — this lookup is reached after `WM_GETICON` returns no icon; the thrown entry-point exception prevents trying the next class-icon index.

## Required outcome
- Read class icons through the correct API entry point on x86 as well as x64/ARM64.

## Suggested approach
- Select the `GetClassLongW` entry point for x86 and `GetClassLongPtrW` for pointer-sized targets (or use an equivalent architecture-aware wrapper).

## Reference
- [Microsoft: GetClassLongPtrW](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclasslongptrw) documents that 32-bit builds map the macro to `GetClassLong`.

[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);
}
2 changes: 1 addition & 1 deletion src/ConsoleMode/Resources/Strings.en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion src/ConsoleMode/Resources/Strings.es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion src/ConsoleMode/Resources/Strings.pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
43 changes: 43 additions & 0 deletions src/ConsoleMode/Services/IconPixels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace ConsoleMode.Services;

/// <summary>
/// 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.
/// </summary>
public static class IconPixels
{
/// <summary>
/// Turns straight BGRA into premultiplied BGRA, in place. An icon without any alpha takes it from
/// <paramref name="mask"/> (BGRA, same size: a non-black pixel is transparent), or becomes opaque without one.
/// </summary>
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;
}
}

/// <summary>Nothing to see: every pixel is fully transparent (or there are none).</summary>
public static bool IsBlank(byte[] pixels)
{
for (var i = 3; i < pixels.Length; i += 4)
if (pixels[i] != 0) return false;
return true;
}
}
1 change: 1 addition & 0 deletions src/ConsoleMode/Services/LocalizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading
Loading