diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 2431c4c..7181799 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -2,8 +2,9 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the workflow publishes the section matching the tag from each one and fails if either is missing. -## [Unreleased] +## [1.6.0-alpha.3] ### What's new +- Session menu: a pinned panel at the top of the side column with the **File explorer (ControlFS)**, the team's open-source, controller-first file manager. One press opens ControlFS on top, always full screen on the session screen (or brings it to the front if already open); if it isn't installed, its download page opens. Console Mode only calls it through its own `--start` flag, never closes it, and never sends F11 to another window if Windows denies focus. (#122) - Session menu (the controller shortcut, Select + Y in the suggestion) redesigned like Steam's overlay (Shift + Tab): it covers the whole screen, the game dims and blurs behind it, a side panel gathers the options (volume with a gauge, resolution, audio, FPS, HDR) and the **open windows** are always visible in the middle, an in-menu Alt + Tab: the D-pad picks, A brings the window to the front, X closes it and B goes back to the game (switching windows does not end the session). The frosted glass only shows when Windows' transparency effects are on (otherwise the panel is dark and solid, and it changes live if you flip the setting), and in high contrast it uses the system colours. It has a focus that grows, sounds and animations (the panel slides in, the rows follow one after another and closing fades it out). In this alpha it also opens **outside a session**, as a preview with a badge on top, so it can be tried without turning on console mode (in it the FPS limit is hidden and "Back to the PC" becomes "Close menu"). (#120) - Redesigned Console interface, made to feel like a console: a top bar with three tabs (Home, Session and System) you switch with LB/RB (L1/R1 on PlayStation) with a slide animation, screen tiles that show what will happen to each monitor, quick settings with icons and a focus that grows as you move over it. Home brings the Play now banner and a summary of the current setup (game screen, audio, launcher and HDR/VRR), each opening its choices. (#117) - Console background: by default, a collage of the covers of your installed Steam games under a dark gradient (the app only reads images Steam already downloaded; nothing is bundled). You can switch to just the gradient or to a picture of your own, in System → Background. (#117) diff --git a/CHANGELOG.md b/CHANGELOG.md index 639dcc1..068b66d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ 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]` com o changelog daquela versão **nos dois arquivos**. O workflow publica as duas seções correspondentes à tag na mesma release e falha se faltar alguma. -## [Unreleased] +## [1.6.0-alpha.3] ### Novidades +- Menu da sessão: painel fixo no topo da lateral com o **Explorador de arquivos (ControlFS)**, o gerenciador de arquivos de código aberto da equipe, feito para o controle. Um toque abre o ControlFS por cima, sempre em tela cheia na tela da sessão (ou o traz para a frente se já estiver aberto); se não estiver instalado, abre a página de download. O Console Mode só o chama pelo `--start` dele, não o encerra nem envia F11 a outra janela se o Windows negar o foco. (#122) - Menu da sessão (o atalho do controle, Select + Y na sugestão) redesenhado como o overlay do Steam (Shift+Tab): cobre a tela inteira, o jogo escurece e desfoca por trás, um painel lateral reúne as opções (volume com medidor, resolução, áudio, FPS, HDR) e no meio ficam sempre visíveis as **janelas abertas**, um ALT+TAB interno: o direcional escolhe, A traz a janela para a frente, X a fecha e B volta ao jogo (trocar de janela não encerra a sessão). O vidro fosco só aparece se os efeitos de transparência do Windows estiverem ligados (senão o painel fica escuro e sólido, e muda ao vivo se você trocar a configuração), e em alto contraste usa as cores do sistema. Tem foco que cresce, sons e animações (o painel entra deslizando, as linhas vêm em cascata e ao fechar ele some suavemente). Nesta alpha ele abre também **fora da sessão**, como prévia com um selo no topo, para testar sem ligar o modo console (nela o limite de FPS não aparece e "Voltar ao PC" vira "Fechar menu"). (#120) - Interface Console redesenhada, com cara de console: barra no topo com três abas (Início, Sessão e Sistema) que você troca com LB/RB (L1/R1 no PlayStation) com uma animação de deslize, blocos das telas que mostram o que vai acontecer com cada monitor, ajustes rápidos com ícones e foco que cresce ao passar. Início traz o banner com Jogar agora e um resumo do setup atual (tela de jogo, áudio, iniciador e HDR/VRR), que abre cada escolha. (#117) - Plano de fundo do modo Console: por padrão, uma colagem com as capas dos seus jogos instalados na Steam sob um gradiente escuro (o app só lê as imagens que a Steam já baixou; nada é embutido). Dá para trocar por só o gradiente ou por uma imagem sua, em Sistema → Plano de fundo. (#117) diff --git a/README.md b/README.md index adc3967..1ab7131 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Get **[1.5.1](https://github.com/lippdev/consolemode/releases/tag/v1.5.1)**, the - **`ConsoleMode-Setup-x64.exe`** (recommended): per-user install, no admin, updates itself. - **`ConsoleMode-Portable-x64.exe`**: a single exe that keeps its data next to it. -Want to help test what comes next? **[1.6.0-alpha.2](https://github.com/lippdev/consolemode/releases/tag/v1.6.0-alpha.2)** is an early alpha of 1.6 that no longer bundles any third-party tool for displays and audio. It is not verified on many setups yet: keep 1.5.1 if you need it to just work, or turn on Settings → "Receive test versions" in 1.5.1 to get it from the app. +Want to help test what comes next? **[1.6.0-alpha.3](https://github.com/lippdev/consolemode/releases/tag/v1.6.0-alpha.3)** is an early alpha of 1.6 that no longer bundles any third-party tool for displays and audio. It is not verified on many setups yet: keep 1.5.1 if you need it to just work, or turn on Settings → "Receive test versions" in 1.5.1 to get it from the app. On 1.4.0? The app offers the update. If it fails with a timeout, download the installer once; your settings are kept. diff --git a/README.pt-BR.md b/README.pt-BR.md index 52bc716..49c3f9e 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -17,7 +17,7 @@ Baixe a **[1.5.1](https://github.com/lippdev/consolemode/releases/tag/v1.5.1)**, - **`ConsoleMode-Setup-x64.exe`** (recomendado): instala por usuário, sem admin, e se atualiza sozinho. - **`ConsoleMode-Portable-x64.exe`**: um único exe que guarda os dados ao lado dele. -Quer ajudar a testar o que vem aí? A **[1.6.0-alpha.2](https://github.com/lippdev/consolemode/releases/tag/v1.6.0-alpha.2)** é uma alpha da 1.6 que não embute mais nenhuma ferramenta de terceiros para telas e áudio. Ela ainda não foi validada em muitos setups: fique na 1.5.1 se precisa que funcione sem surpresas, ou ligue Ajustes → "Receber versões de teste" na 1.5.1 para recebê-la pelo app. +Quer ajudar a testar o que vem aí? A **[1.6.0-alpha.3](https://github.com/lippdev/consolemode/releases/tag/v1.6.0-alpha.3)** é uma alpha da 1.6 que não embute mais nenhuma ferramenta de terceiros para telas e áudio. Ela ainda não foi validada em muitos setups: fique na 1.5.1 se precisa que funcione sem surpresas, ou ligue Ajustes → "Receber versões de teste" na 1.5.1 para recebê-la pelo app. Está na 1.4.0? O app oferece a atualização. Se ela falhar por tempo esgotado, baixe o instalador uma vez; as configurações são mantidas. diff --git a/docs/TESTING.md b/docs/TESTING.md index b24fbc6..01c1347 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -154,6 +154,22 @@ Pré-condições: sessão ativa com Big Picture (ou jogo borderless) na tela; co - [ ] Jogo em **tela cheia exclusiva**: o menu não aparece por cima (limitação documentada); Select + Y não quebra nada. Resultado: ______ - [ ] `start consolemode://menu` no cmd abre o menu; fora da sessão, não faz nada. Resultado: ______ +## 1d. Explorador de arquivos (ControlFS) no menu da sessão + +Pré-condição: ControlFS instalado (https://github.com/nextestudios/ControlFS). + +- [ ] Select + Y: no topo da lateral esquerda, fora da lista que rola, há o painel "Explorador de arquivos (ControlFS)"; o restante das opções rola por baixo dele. Resultado: ______ +- [ ] Direcional para cima a partir de "Voltar ao jogo" chega nele; A abre o ControlFS em tela cheia na TV, por cima do jogo/Big Picture, e o menu fecha. Resultado: ______ +- [ ] O ControlFS abre **sempre em tela cheia** na tela da sessão (a TV), cobrindo-a por inteiro, mesmo que ele estivesse em janela ou maximizado; se já estava em tela cheia, nada muda. Resultado: ______ +- [ ] Se outra aplicação permanecer em primeiro plano porque o Windows negou o foco ao ControlFS, o F11 não é enviado a ela; a janela de outra aplicação não muda de estado. Resultado: ______ +- [ ] Abrir de novo com o ControlFS já aberto só o traz para a frente (continua um processo só no Gerenciador de Tarefas). Resultado: ______ +- [ ] O ControlFS responde ao controle logo de cara (o foco do controle fica com ele, sem o Console Mode reagir aos botões). Resultado: ______ +- [ ] Com o ControlFS já aberto: A só o traz para a frente (não abre uma segunda instância). Resultado: ______ +- [ ] A sessão **não** termina nem restaura a mesa enquanto o ControlFS está na frente; ao sair dele (Menu do ControlFS → Sair) volta-se ao jogo. Resultado: ______ +- [ ] Select + Y com o ControlFS na frente abre o menu por cima dele (saída de emergência). Resultado: ______ +- [ ] Sem o ControlFS instalado: a linha diz "Não instalado · aperte para baixar" e A abre a página de releases no navegador. Resultado: ______ +- [ ] Fora da sessão (prévia do menu) o painel funciona igual. Resultado: ______ + ## 2. Links `consolemode://` - [ ] `start consolemode://start` no `cmd` com o app **fechado**: abre e entra no modo console. Resultado: ______ diff --git a/src/ConsoleMode/ConsoleMode.csproj b/src/ConsoleMode/ConsoleMode.csproj index c6ce683..70daee4 100644 --- a/src/ConsoleMode/ConsoleMode.csproj +++ b/src/ConsoleMode/ConsoleMode.csproj @@ -17,10 +17,10 @@ true latest x64 - 1.6.0-alpha.2 + 1.6.0-alpha.3 1.6.0.0 1.6.0.0 - 1.6.0-alpha.2 + 1.6.0-alpha.3 Console Mode Console Mode lippdev diff --git a/src/ConsoleMode/Native/NativeWindows.cs b/src/ConsoleMode/Native/NativeWindows.cs index 05218c8..b5742e8 100644 --- a/src/ConsoleMode/Native/NativeWindows.cs +++ b/src/ConsoleMode/Native/NativeWindows.cs @@ -236,6 +236,21 @@ public static WindowMatch[] GetAllVisibleWindows() return [.. list]; } + /// A plain F11 to the window in front (ControlFS uses it for full screen). + public static void SendF11() + { + keybd_event(VK_F11, 0, 0, UIntPtr.Zero); + keybd_event(VK_F11, 0, KEYEVENTF_KEYUP, UIntPtr.Zero); + } + + public static bool TryGetWindowRect(nint hWnd, out int left, out int top, out int width, out int height) + { + left = top = width = height = 0; + if (hWnd == 0 || !GetWindowRect(hWnd, out var r)) return false; + left = r.Left; top = r.Top; width = r.Right - r.Left; height = r.Bottom - r.Top; + return true; + } + public static void SendWinF11() { keybd_event(VK_LWIN, 0, 0, UIntPtr.Zero); diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index 398db33..7694cf2 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -87,6 +87,10 @@ "ConsoleSettingsHint": "Press A on a row to change its value. Everything is saved right away.", "BackToPc": "Back to the PC", "CloseMenu": "Close menu", + "ControlFsCard": "File explorer (ControlFS)", + "ControlFsOpen": "Browse your files with the controller", + "ControlFsMissing": "Not installed · press to get it", + "ControlFsMissingNote": "ControlFS is not installed. Opening its download page in the browser.", "SessionWindowsCard": "Windows", "SessionWindowsCount": "{0} open", "SwitcherEmpty": "No open windows", diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json index adc5d8f..1a422da 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -87,6 +87,10 @@ "ConsoleSettingsHint": "Pulsa A en una fila para cambiar su valor. Todo se guarda al momento.", "BackToPc": "Volver al PC", "CloseMenu": "Cerrar menú", + "ControlFsCard": "Explorador de archivos (ControlFS)", + "ControlFsOpen": "Explora tus archivos con el mando", + "ControlFsMissing": "No instalado · pulsa para descargarlo", + "ControlFsMissingNote": "ControlFS no está instalado. Abriendo su página de descarga en el navegador.", "SessionWindowsCard": "Ventanas", "SessionWindowsCount": "{0} abiertas", "SwitcherEmpty": "No hay ventanas abiertas", diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json index e5811be..b70c1f7 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -87,6 +87,10 @@ "ConsoleSettingsHint": "Aperte A numa linha para trocar o valor. Tudo é salvo na hora.", "BackToPc": "Voltar ao PC", "CloseMenu": "Fechar menu", + "ControlFsCard": "Explorador de arquivos (ControlFS)", + "ControlFsOpen": "Navegue pelos seus arquivos com o controle", + "ControlFsMissing": "Não instalado · aperte para baixar", + "ControlFsMissingNote": "O ControlFS não está instalado. Abrindo a página de download no navegador.", "SessionWindowsCard": "Janelas", "SessionWindowsCount": "{0} abertas", "SwitcherEmpty": "Nenhuma janela aberta", diff --git a/src/ConsoleMode/Services/ControlFsLocator.cs b/src/ConsoleMode/Services/ControlFsLocator.cs new file mode 100644 index 0000000..2512bb4 --- /dev/null +++ b/src/ConsoleMode/Services/ControlFsLocator.cs @@ -0,0 +1,47 @@ +namespace ConsoleMode.Services; + +/// +/// Where ControlFS (the controller-first file explorer from the same team) is installed. Pure: the registry and +/// the disk are passed in, so the order of preference is testable. +/// +public static class ControlFsLocator +{ + public const string ExeName = "ControlFS.exe"; + public const string ReleasesUrl = "https://github.com/nextestudios/ControlFS/releases/latest"; + + /// Folders to look in, most reliable first, without blanks or repeats. + public static IReadOnlyList Folders(string? registryInstallLocation, string? localAppData, string? programFiles) + { + var folders = new List(); + void Add(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return; + var clean = path.Trim().Trim('"').TrimEnd('\\', '/'); + if (clean.Length > 0 && !folders.Contains(clean, StringComparer.OrdinalIgnoreCase)) folders.Add(clean); + } + Add(registryInstallLocation); + if (!string.IsNullOrWhiteSpace(localAppData)) Add(Path.Combine(localAppData, "Programs", "ControlFS")); + if (!string.IsNullOrWhiteSpace(programFiles)) Add(Path.Combine(programFiles, "ControlFS")); + return folders; + } + + /// + /// Full screen means the window is exactly the screen. A maximized window is about 16 px bigger (its frame hangs + /// over the edges), so it is not counted; a pixel of tolerance covers rounding. + /// + public static bool IsFullScreen(int left, int top, int width, int height, int screenX, int screenY, int screenWidth, int screenHeight) => + screenWidth > 0 && screenHeight > 0 + && Math.Abs(left - screenX) <= 1 && Math.Abs(top - screenY) <= 1 + && Math.Abs(width - screenWidth) <= 1 && Math.Abs(height - screenHeight) <= 1; + + /// The first folder that really has ControlFS.exe, or null (not installed). + public static string? Resolve(IEnumerable folders, Func fileExists) + { + foreach (var folder in folders) + { + var exe = Path.Combine(folder, ExeName); + if (fileExists(exe)) return exe; + } + return null; + } +} diff --git a/src/ConsoleMode/Services/ControlFsService.cs b/src/ConsoleMode/Services/ControlFsService.cs new file mode 100644 index 0000000..8b4f90e --- /dev/null +++ b/src/ConsoleMode/Services/ControlFsService.cs @@ -0,0 +1,161 @@ +using System.Diagnostics; +using ConsoleMode.Models; +using ConsoleMode.Native; +using Microsoft.Win32; + +namespace ConsoleMode.Services; + +/// +/// Opens ControlFS from the session menu. ControlFS is a separate app (installed on its own): it is found the way +/// Windows lists it, started with its own --start flag (opens it, or brings the open one to the front; it +/// runs one instance per user) and never killed from here. +/// +public static class ControlFsService +{ + public static string? FindExe() + { + try + { + var folders = ControlFsLocator.Folders( + ReadInstallLocation(), + Environment.GetEnvironmentVariable("LOCALAPPDATA"), + Environment.GetEnvironmentVariable("ProgramFiles")); + return ControlFsLocator.Resolve(folders, File.Exists); + } + catch (Exception ex) + { + AppLog.Write($"ControlFS: procurar: {ex.Message}"); + return null; + } + } + + /// Starts (or brings to the front) ControlFS. False when it isn't installed or didn't start. + public static bool Open() + { + var exe = FindExe(); + if (exe is null) return false; + try + { + // Already open: bring that window forward. Launching it again can leave a second copy running + // (seen with the installed build), so the flag is only for when there is no window yet. + var open = FindOpenWindow(); + if (open != 0) + { + NativeWindows.ShowWindow(open, 9 /* SW_RESTORE */); + NativeWindows.ForceForeground(open); + AppLog.Write("ControlFS: já estava aberto, trazido para a frente"); + return true; + } + + var psi = new ProcessStartInfo { FileName = exe, UseShellExecute = false, WorkingDirectory = Path.GetDirectoryName(exe)! }; + psi.ArgumentList.Add("--start"); + Process.Start(psi)?.Dispose(); + AppLog.Write("ControlFS: aberto"); + return true; + } + catch (Exception ex) + { + AppLog.Write($"ControlFS: abrir: {ex.Message}"); + return false; + } + } + + /// + /// ControlFS has no start-up flag for full screen (it keeps the choice in its own settings), so once its window is + /// up it is moved to the session screen and, if it isn't exactly covering it, F11 (its full-screen key) is sent. + /// Maximized is the fallback if F11 didn't take. Runs off the UI thread; never throws. + /// + public static void EnsureFullScreen(ScreenRect? screen) + { + if (screen is null || screen.Width <= 0 || screen.Height <= 0) return; + try + { + var hwnd = WaitForWindow(TimeSpan.FromSeconds(12)); + if (hwnd == 0) { AppLog.Write("ControlFS: janela não apareceu para colocar em tela cheia"); return; } + if (IsCovering(hwnd, screen)) return; + + // A restored or maximized window first goes to the session screen, so F11 makes it cover that one. + NativeWindows.ShowWindow(hwnd, 9 /* SW_RESTORE */); + NativeWindows.MoveWindowToRect(hwnd, screen.X + 80, screen.Y + 80, Math.Max(400, screen.Width - 160), Math.Max(300, screen.Height - 160)); + Thread.Sleep(300); + if (NativeWindows.GetForegroundWindow() != hwnd) NativeWindows.ForceForeground(hwnd); + Thread.Sleep(200); + if (NativeWindows.GetForegroundWindow() == hwnd) + { + NativeWindows.SendF11(); + Thread.Sleep(700); + } + else + { + AppLog.Write("ControlFS: F11 n\u00e3o enviado porque outra janela est\u00e1 em primeiro plano"); + } + if (IsCovering(hwnd, screen)) { AppLog.Write("ControlFS: em tela cheia"); return; } + + NativeWindows.ShowWindow(hwnd, 3 /* SW_MAXIMIZE */); + AppLog.Write("ControlFS: F11 não surtiu efeito, janela maximizada"); + } + catch (Exception ex) { AppLog.Write($"ControlFS: tela cheia: {ex.Message}"); } + } + + private static bool IsCovering(nint hwnd, ScreenRect screen) => + NativeWindows.TryGetWindowRect(hwnd, out var l, out var t, out var w, out var h) + && ControlFsLocator.IsFullScreen(l, t, w, h, screen.X, screen.Y, screen.Width, screen.Height); + + private static nint FindOpenWindow() + { + foreach (var process in Process.GetProcessesByName("ControlFS")) + { + using (process) + { + process.Refresh(); + var hwnd = process.MainWindowHandle; + if (hwnd != 0 && NativeWindows.IsWindowStillVisible(hwnd)) return hwnd; + } + } + return 0; + } + + private static nint WaitForWindow(TimeSpan timeout) + { + var deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds; + while (Environment.TickCount64 < deadline) + { + var hwnd = FindOpenWindow(); + if (hwnd != 0) { Thread.Sleep(500); return hwnd; } + Thread.Sleep(250); + } + return 0; + } + + /// Not installed: the release page opens in the browser. + public static void OpenDownloadPage() + { + try { Process.Start(new ProcessStartInfo(ControlFsLocator.ReleasesUrl) { UseShellExecute = true })?.Dispose(); } + catch (Exception ex) { AppLog.Write($"ControlFS: página de download: {ex.Message}"); } + } + + private static string? ReadInstallLocation() + { + const string uninstall = @"Software\Microsoft\Windows\CurrentVersion\Uninstall"; + foreach (var (hive, view) in new[] + { (RegistryHive.CurrentUser, RegistryView.Default), (RegistryHive.LocalMachine, RegistryView.Registry64), (RegistryHive.LocalMachine, RegistryView.Registry32) }) + { + try + { + using var root = RegistryKey.OpenBaseKey(hive, view).OpenSubKey(uninstall); + if (root is null) continue; + foreach (var name in root.GetSubKeyNames()) + { + using var key = root.OpenSubKey(name); + if (key?.GetValue("DisplayName") is string display + && display.Equals("ControlFS", StringComparison.OrdinalIgnoreCase) + && key.GetValue("InstallLocation") is string location + && !string.IsNullOrWhiteSpace(location)) + return location; + } + } + catch { /* this hive/view isn't readable: try the next */ } + } + return null; + } +} diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs index 4733c60..8e3543a 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -189,6 +189,9 @@ internal void NotifyAll() public string SoundsCard => LocalizationService.Get(nameof(SoundsCard)); public string SessionPreviewBadge => LocalizationService.Get(nameof(SessionPreviewBadge)); public string CloseMenu => LocalizationService.Get(nameof(CloseMenu)); + public string ControlFsCard => LocalizationService.Get(nameof(ControlFsCard)); + public string ControlFsOpen => LocalizationService.Get(nameof(ControlFsOpen)); + public string ControlFsMissing => LocalizationService.Get(nameof(ControlFsMissing)); public string SessionWindowsCard => LocalizationService.Get(nameof(SessionWindowsCard)); public string SwitcherEmpty => LocalizationService.Get(nameof(SwitcherEmpty)); public string SwitcherOpen => LocalizationService.Get(nameof(SwitcherOpen)); diff --git a/src/ConsoleMode/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml index 7c296a3..2029421 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml +++ b/src/ConsoleMode/SessionMenuWindow.xaml @@ -127,7 +127,30 @@ - + + + + + + + + diff --git a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs index 072fa96..f18c31a 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs @@ -84,6 +84,7 @@ public void ToggleSessionMenu() _sessionMenu.Closed += (_, _) => { _sessionMenu = null; IsSessionMenuOpen = false; StopSessionClock(); }; IsSessionMenuOpen = true; IsSessionPickerOpen = false; + ControlFsInstalled = ControlFsService.FindExe() is not null; RefreshSessionHeader(); StartSessionClock(); _sessionMenu.Activate(); @@ -310,6 +311,32 @@ private async Task RunSessionActionAsync(Action action) finally { _busy = false; IsSessionMenuBusy = false; } } + /// Whether ControlFS is installed; decides what the pinned row says. Read each time the menu opens. + [ObservableProperty] private bool _controlFsInstalled; + + public string ControlFsRowText => ControlFsInstalled ? Texts.ControlFsOpen : Texts.ControlFsMissing; + partial void OnControlFsInstalledChanged(bool value) => OnPropertyChanged(nameof(ControlFsRowText)); + + /// + /// The pinned "File explorer (ControlFS)" row: the menu closes and ControlFS (a separate app, controller-first) + /// opens or comes to the front. Without it installed, its download page opens instead. Outside a session it + /// works the same, so it can be tried in the preview. + /// + [RelayCommand] + private void OpenControlFs() + { + var installed = ControlFsService.FindExe() is not null; + var screen = IsConsoleActive ? Engine.State.FocusMonitorRect : Engine.Monitors.GetMonitorRect(SessionMonitorName, Engine.State); + CloseSessionMenu(); + if (installed && ControlFsService.Open()) + { + _ = Task.Run(() => ControlFsService.EnsureFullScreen(screen)); + return; + } + AppLog.Write("ControlFS: não instalado, abrindo a página de download"); + ControlFsService.OpenDownloadPage(); + } + [RelayCommand] private async Task RestoreFromMenuAsync() { diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj index 4304477..7587127 100644 --- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj +++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj @@ -37,6 +37,7 @@ + diff --git a/tests/ConsoleMode.Tests/ControlFsLocatorTests.cs b/tests/ConsoleMode.Tests/ControlFsLocatorTests.cs new file mode 100644 index 0000000..d78b728 --- /dev/null +++ b/tests/ConsoleMode.Tests/ControlFsLocatorTests.cs @@ -0,0 +1,49 @@ +using ConsoleMode.Services; + +namespace ConsoleMode.Tests; + +public class ControlFsLocatorTests +{ + [Fact] + public void The_registry_folder_comes_first_then_the_usual_ones_without_repeats() + { + var localAppData = Path.Combine(Path.GetTempPath(), "ana", "AppData", "Local"); + var registryLocation = Path.Combine(localAppData, "Programs", "ControlFS"); + var programFiles = Path.Combine(Path.GetTempPath(), "Program Files"); + var folders = ControlFsLocator.Folders(registryLocation + Path.DirectorySeparatorChar, localAppData, programFiles); + Assert.Equal( + [registryLocation, Path.Combine(programFiles, "ControlFS")], + folders); + } + + [Fact] + public void Blank_and_quoted_inputs_are_cleaned_or_dropped() + { + var folders = ControlFsLocator.Folders(" ", null, null); + Assert.Empty(folders); + var quotedPath = Path.Combine(Path.GetTempPath(), "Apps", "ControlFS"); + var quoted = $"\"{quotedPath}{Path.DirectorySeparatorChar}\""; + Assert.Equal([quotedPath], ControlFsLocator.Folders(quoted, null, null)); + } + + [Fact] + public void Full_screen_is_the_exact_screen_not_a_maximized_window() + { + Assert.True(ControlFsLocator.IsFullScreen(0, 0, 1920, 1080, 0, 0, 1920, 1080)); + Assert.True(ControlFsLocator.IsFullScreen(1920, 0, 3840, 2160, 1920, 0, 3840, 2160)); // a second screen + Assert.False(ControlFsLocator.IsFullScreen(-8, -8, 1936, 1048, 0, 0, 1920, 1080)); // maximized: the frame overhangs + Assert.False(ControlFsLocator.IsFullScreen(0, 0, 1920, 1080, 0, 0, 3840, 2160)); // on the wrong screen + Assert.False(ControlFsLocator.IsFullScreen(0, 0, 100, 100, 0, 0, 0, 0)); // no screen known + } + + [Fact] + public void Resolve_returns_the_first_folder_that_has_the_exe_and_null_when_none_does() + { + string[] folders = [Path.Combine(Path.GetTempPath(), "a"), Path.Combine(Path.GetTempPath(), "b"), Path.Combine(Path.GetTempPath(), "c")]; + var secondExe = Path.Combine(folders[1], "ControlFS.exe"); + var thirdExe = Path.Combine(folders[2], "ControlFS.exe"); + var hit = ControlFsLocator.Resolve(folders, p => p == secondExe || p == thirdExe); + Assert.Equal(secondExe, hit); + Assert.Null(ControlFsLocator.Resolve(folders, _ => false)); + } +}