From 446cadf998013f5e584d0040d1316bb5828b89d0 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 30 Sep 2026 13:37:44 -0300 Subject: [PATCH] feat: automation with controlfs:// protocol links and command-line flags --- CHANGELOG.en-US.md | 3 + CHANGELOG.md | 3 + README.md | 1 + README.pt-BR.md | 1 + build/ControlFS.iss | 7 ++ docs/GUIDE.md | 10 ++ docs/GUIDE.pt-BR.md | 10 ++ docs/TESTING.md | 8 ++ src/ControlFS.App/App.cs | 4 + src/ControlFS.App/Program.cs | 26 +++-- src/ControlFS.App/Views/MainWindow.cs | 45 ++++++++- src/ControlFS.Application/AppController.cs | 10 ++ src/ControlFS.Core/Automation/AppProtocol.cs | 82 ++++++++++++++++ .../Automation/SingleInstanceCoordinator.cs | 98 +++++++++++++++++++ .../Automation/SingleInstanceListener.cs | 35 +++++++ .../Shell/ProtocolRegistration.cs | 79 +++++++++++++++ .../Shell/WindowActivation.cs | 59 +++++++++++ .../Core/AppProtocolTests.cs | 88 +++++++++++++++++ .../ProtocolRegistrationIntegrationTests.cs | 33 +++++++ .../SingleInstanceIntegrationTests.cs | 69 +++++++++++++ 20 files changed, 662 insertions(+), 9 deletions(-) create mode 100644 src/ControlFS.Core/Automation/AppProtocol.cs create mode 100644 src/ControlFS.Infrastructure.Windows/Automation/SingleInstanceCoordinator.cs create mode 100644 src/ControlFS.Infrastructure.Windows/Automation/SingleInstanceListener.cs create mode 100644 src/ControlFS.Infrastructure.Windows/Shell/ProtocolRegistration.cs create mode 100644 src/ControlFS.Infrastructure.Windows/Shell/WindowActivation.cs create mode 100644 tests/ControlFS.UnitTests/Core/AppProtocolTests.cs create mode 100644 tests/ControlFS.WindowsIntegrationTests/ProtocolRegistrationIntegrationTests.cs create mode 100644 tests/ControlFS.WindowsIntegrationTests/SingleInstanceIntegrationTests.cs diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index bac31f2f..b6f874b7 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -3,6 +3,9 @@ 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] +### What's new +- **Automation and `controlfs://` protocol**: support for `controlfs://start`, `controlfs://stop`, and `controlfs://show` links, as well as command-line arguments (`--start`, `--stop`, `--show`, `--close`), enabling seamless external integration with game launchers and frontends (such as Console Mode), Stream Deck buttons, and automation scripts. Invoking `controlfs://start` when the app is already open restores and brings the window to the foreground without launching duplicate instances; `controlfs://stop` cleanly exits the app. The protocol is registered automatically by the installer and on portable runs. + ### 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb6263d..bfd58ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,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]` **nos dois arquivos**. O workflow de release usa a seção da tag e falha se faltar alguma. ## [Unreleased] +### Novidades +- **Automação e protocolo `controlfs://`**: suporte aos links `controlfs://start`, `controlfs://stop` e `controlfs://show`, bem como argumentos de linha de comando (`--start`, `--stop`, `--show`, `--close`), facilitando a integração externa com launchers e frontends (como o Console Mode), botões de Stream Deck e scripts. Acionar `controlfs://start` quando o app já está aberto restaura e traz a janela para o primeiro plano sem abrir instâncias duplicadas; `controlfs://stop` encerra o app de forma limpa. O protocolo é registrado automaticamente pelo instalador e também na execução portátil. + ### 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. diff --git a/README.md b/README.md index 82d6ca39..d05fba00 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Buttons follow **physical position**, so a Nintendo layout doesn't flip confirm - **Welcome and guided tutorial** on first launch: the real buttons of your controller and an interactive, skippable walkthrough that never touches files (Menu → Ajuda e tutorial) - **More from the team:** a one-time screen with the team's other apps, NextBoost PRO and Console Mode; it can be opened again from Menu → Ajuda e tutorial - **Light in the background:** minimized or behind a game, ControlFS drops to low priority and Windows efficiency mode, and gives its memory back +- **Automation:** `controlfs://start`, `controlfs://stop`, `controlfs://show` links and `--start` / `--stop` flags for game launchers (such as Console Mode), Stream Deck buttons, and scripts; single-instance management prevents duplicates and switches cleanly ## Roadmap diff --git a/README.pt-BR.md b/README.pt-BR.md index 210bab7f..fdcefa8c 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -121,6 +121,7 @@ Os botões seguem a **posição física**, então um controle Nintendo não inve - **Boas-vindas e tutorial guiado** na primeira vez: os botões de verdade do seu controle e um passo a passo interativo, que dá para pular e nunca mexe em arquivos (Menu → Ajuda e tutorial) - **Mais da equipe:** uma tela, uma única vez, com os outros apps da equipe, NextBoost PRO e Console Mode; dá para abrir de novo em Menu → Ajuda e tutorial - **Leve em segundo plano:** minimizado ou atrás de um jogo, o ControlFS baixa a prioridade, entra no modo de eficiência do Windows e devolve memória +- **Automação:** links `controlfs://start`, `controlfs://stop`, `controlfs://show` e opções de linha de comando `--start` / `--stop` para launchers (como o Console Mode), botões do Stream Deck e scripts; instância única evita duplicatas e traz o app para frente ## Roadmap diff --git a/build/ControlFS.iss b/build/ControlFS.iss index 7da78ffc..20bee228 100644 --- a/build/ControlFS.iss +++ b/build/ControlFS.iss @@ -79,6 +79,13 @@ Source: "ControlFS.installed"; DestDir: "{app}"; Flags: ignoreversion Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExe}"; IconFilename: "{app}\controlfs.ico" Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExe}"; IconFilename: "{app}\controlfs.ico"; Tasks: desktopicon +[Registry] +; Links controlfs://start|stop|show para automação (ProtocolRegistration registra as mesmas chaves ao iniciar). +Root: HKCU; Subkey: "Software\Classes\controlfs"; ValueType: string; ValueData: "URL:ControlFS"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\controlfs"; ValueType: string; ValueName: "URL Protocol"; ValueData: "" +Root: HKCU; Subkey: "Software\Classes\controlfs\DefaultIcon"; ValueType: string; ValueData: """{app}\{#AppExe}"",0" +Root: HKCU; Subkey: "Software\Classes\controlfs\shell\open\command"; ValueType: string; ValueData: """{app}\{#AppExe}"" ""%1""" + [Run] Filename: "{app}\{#AppExe}"; Description: "{cm:LaunchApp}"; Flags: nowait postinstall skipifsilent ; Atualização "instalar e reiniciar" (o app passa /RELAUNCH=1): reabre o app depois da instalação silenciosa. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 03f279db..903e8678 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -297,6 +297,16 @@ The **installed** version updates itself: Menu → Configurações → **Updates**: check now, automatic check on/off, install on quit on/off, pre-releases (automatic / yes / no). The **portable** version only tells you a new version exists; download it from the release page. +## Automation + +ControlFS can be opened, brought to the front, or closed from external tools, scripts, game launchers/frontends (such as **Console Mode**), and Stream Deck buttons using the `controlfs://` protocol scheme or command-line arguments: + +- `controlfs://start` (or `ControlFS.exe --start`): opens the app, or brings the existing window to the foreground if already running. +- `controlfs://show` (or `ControlFS.exe --show`): restores and brings the window to the front. +- `controlfs://stop` (or `ControlFS.exe --stop`, `--close`): cleanly closes the running instance, canceling any active operations and saving settings. If no instance is open, it exits immediately. + +The `controlfs://` scheme is registered in Windows automatically by the installer and on startup in portable mode. Only one instance of ControlFS runs per user session; launching a second instance signals the active window and exits immediately. + ## Screen readers With Narrator (or another UI Automation screen reader) on, the app announces where the focus is and the focused item as you move with the controller or keyboard: the home screen, folder, menu, dialog or on-screen keyboard when you enter it, then just the item as you move (name, type, size and position such as "3 of 20"). States are spoken in words: marked, cut, blocked (with the reason), password-protected, unavailable (with the reason). Notices (the pop-up in the bottom-right corner) and the line above the list are read without moving the focus. Nothing depends on sound, vibration or color alone. diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index 7d4a6985..8c48596c 100644 --- a/docs/GUIDE.pt-BR.md +++ b/docs/GUIDE.pt-BR.md @@ -297,6 +297,16 @@ A versão **instalada** se atualiza sozinha: Menu → Configurações → **Atualizações**: verificar agora, verificação automática sim/não, instalar ao sair sim/não, pré-lançamentos (automático / sim / não). A versão **portátil** só avisa que existe versão nova; baixe-a na página da release. +## Automação + +O ControlFS pode ser aberto, trazido para a frente ou encerrado por ferramentas externas, scripts, frontends de jogos (como o **Console Mode**) e botões do Stream Deck através do protocolo `controlfs://` ou por argumentos de linha de comando: + +- `controlfs://start` (ou `ControlFS.exe --start`): abre o aplicativo ou restaura e traz a janela existente para o primeiro plano se já estiver em execução. +- `controlfs://show` (ou `ControlFS.exe --show`): restaura e traz a janela para a frente. +- `controlfs://stop` (ou `ControlFS.exe --stop`, `--close`): encerra a instância aberta de forma limpa, cancelando operações em andamento e salvando preferências. Se nada estiver aberto, encerra imediatamente. + +O esquema `controlfs://` é registrado no Windows automaticamente pelo instalador e ao iniciar na versão portátil. Apenas uma instância do ControlFS roda por sessão de usuário; abrir uma segunda chamada sinaliza a janela ativa e encerra o novo processo imediatamente. + ## Leitores de tela Com o Narrador (ou outro leitor de tela com UI Automation) ligado, o app anuncia onde está o foco e o item focado enquanto você anda com o controle ou o teclado: a tela inicial, a pasta, o menu, o diálogo ou o teclado virtual ao entrar, depois só o item a cada movimento (nome, tipo, tamanho e posição, como "3 de 20"). Estados são ditos por extenso: marcado, recortado, bloqueado (com o motivo), com senha, indisponível (com o motivo). Os avisos (no canto inferior direito) e a linha acima da lista são lidos sem mover o foco. Nada depende só de som, vibração ou cor. diff --git a/docs/TESTING.md b/docs/TESTING.md index 78539637..a0a1f08a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -32,6 +32,14 @@ versão, Windows, controle e conexão. Resultados de controles vão para `contro - [ ] Cursor do teclado virtual visível a 3 m e em 4K; renomear "ControlFS" para "Control-FS" com LT, RB e Sul; LT/RT num nome longo; o Narrador lê a posição do cursor. - [ ] Seleção no teclado virtual (#44): ao renomear `example-file.zip`, `example-file` aparece destacado e sublinhado, visível a 3 m e em 4K; digitar `novo` resulta em `novo.zip`; `Sel. tudo` na página `…` e Ctrl+A selecionam tudo; o Narrador lê "N de M caracteres selecionados". +## Links `controlfs://` e automação + +- [ ] `start controlfs://start` no `cmd` com o app fechado: abre e exibe a janela. Resultado: ______ +- [ ] `controlfs://start` com o app aberto: restaura e traz a janela para frente sem abrir uma segunda instância (apenas um `ControlFS.exe` no Gerenciador de Tarefas). Resultado: ______ +- [ ] `controlfs://stop` ou `ControlFS.exe --stop` com o app aberto: encerra o app de forma limpa. Resultado: ______ +- [ ] `controlfs://show`: traz a janela para frente. Resultado: ______ +- [ ] Versão **portátil** movida de pasta: ao abrir, o registro passa a apontar para o novo caminho (log `Protocolo: controlfs:// registrado`). Resultado: ______ + ## Narrador (#40) Com o Narrador ligado (Ctrl+Win+Enter), usando só o controle: diff --git a/src/ControlFS.App/App.cs b/src/ControlFS.App/App.cs index 8ae0d9a4..a855c1e5 100644 --- a/src/ControlFS.App/App.cs +++ b/src/ControlFS.App/App.cs @@ -1,5 +1,6 @@ using ControlFS.App.Diagnostics; using ControlFS.App.Views; +using ControlFS.Infrastructure.Windows.Shell; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Markup; @@ -35,6 +36,9 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) _ = ScreenRenderer.RunAsync(renderTo, Environment.GetCommandLineArgs()); return; } + + ProtocolRegistration.EnsureRegistered(AppLog.Info); + AppLog.Info("OnLaunched: criando janela"); _window = new MainWindow(); AppLog.Info("OnLaunched: ativando janela"); diff --git a/src/ControlFS.App/Program.cs b/src/ControlFS.App/Program.cs index add60ed9..1ece4a44 100644 --- a/src/ControlFS.App/Program.cs +++ b/src/ControlFS.App/Program.cs @@ -1,3 +1,5 @@ +using ControlFS.App.Diagnostics; +using ControlFS.Infrastructure.Windows.Automation; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; @@ -15,16 +17,26 @@ public static void Main() { if (e.ExceptionObject is Exception ex) AppLog.Crash(ex, "AppDomain.UnhandledException"); }; + + var args = Environment.GetCommandLineArgs(); + if (SingleInstanceCoordinator.HandleLaunch(args, out var instanceMutex, bypass: ScreenRenderer.OutputDirectory(args) is not null, log: AppLog.Info)) + { + return; + } + try { - WinRT.ComWrappersSupport.InitializeComWrappers(); - Microsoft.UI.Xaml.Application.Start(_callbackParams => + using (instanceMutex) { - // Continuações assíncronas e o AppController rodam na thread de UI. - SynchronizationContext.SetSynchronizationContext(new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread())); - _ = new App(); - }); - AppLog.Info("Encerrado normalmente"); + WinRT.ComWrappersSupport.InitializeComWrappers(); + Microsoft.UI.Xaml.Application.Start(_callbackParams => + { + // Continuações assíncronas e o AppController rodam na thread de UI. + SynchronizationContext.SetSynchronizationContext(new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread())); + _ = new App(); + }); + AppLog.Info("Encerrado normalmente"); + } } catch (Exception ex) { diff --git a/src/ControlFS.App/Views/MainWindow.cs b/src/ControlFS.App/Views/MainWindow.cs index 5f86dad7..e99ae475 100644 --- a/src/ControlFS.App/Views/MainWindow.cs +++ b/src/ControlFS.App/Views/MainWindow.cs @@ -11,6 +11,7 @@ using ControlFS.Infrastructure.Archives; using ControlFS.Infrastructure.Updates; using ControlFS.Infrastructure.Windows.FileSystem; +using ControlFS.Infrastructure.Windows.Automation; using ControlFS.Infrastructure.Windows.Shell; using ControlFS.Infrastructure.Windows.Settings; using ControlFS.Core.Text; @@ -31,12 +32,14 @@ namespace ControlFS.App.Views; /// Janela única: cabeçalho (local + estado), lista virtualizada, rodapé de comandos contextuais /// e camada modal. Toda interação vira InputAction no AppController. /// -[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA1001", Justification = "InputHost, o serviço de atualização, o canal do celular, os ícones e o monitor de unidades são descartados no evento Closed da janela.")] +[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA1001", Justification = "InputHost, o serviço de atualização, o canal do celular, os ícones, o monitor de unidades e o ouvinte de instância única são descartados no evento Closed da janela.")] public sealed class MainWindow : Window { private readonly AppController _app; private readonly InputHost _input; private readonly GitHubReleaseUpdateService? _updates; + /// Ouvinte de instância única e automação (sinais Show e Close). Null nas capturas. + private readonly SingleInstanceListener? _singleInstanceListener; /// Celular como controle (#223). Só escuta na rede durante uma sessão que o usuário abriu; null nas capturas. private readonly Infrastructure.Remote.PhoneLinkServer? _phone; @@ -158,7 +161,13 @@ internal MainWindow(string? dataDirectory) } var inputStarted = startup.ElapsedMilliseconds; _input = new InputHost(_app, DispatcherQueue); - if (dataDirectory is null) _background = new BackgroundMode(_app, DispatcherQueue, _drives); + if (dataDirectory is null) + { + _background = new BackgroundMode(_app, DispatcherQueue, _drives); + _singleInstanceListener = new SingleInstanceListener( + () => DispatcherQueue.TryEnqueue(BringToForeground), + () => DispatcherQueue.TryEnqueue(RequestAutomationExit)); + } AppLog.Info($"MainWindow: serviços em {inputStarted} ms; entrada (SDL) em {startup.ElapsedMilliseconds - inputStarted} ms"); _icons = new IconLoader(_iconProvider); // Caches por tamanho: ícones grandes custam mais por entrada (144 px no dobro da escala ≈ 330 KB), então guardam menos. @@ -252,6 +261,7 @@ internal MainWindow(string? dataDirectory) AppWindow.Changed += (_, _) => UpdateForeground(); Closed += (_, _) => { + _singleInstanceListener?.Dispose(); _app.ReleaseMediaForShutdown(); _app.PrepareShutdown(); // instala em silêncio uma atualização verificada, se o usuário deixou ligado _input.Dispose(); @@ -1208,6 +1218,36 @@ private void ApplyLayout() internal void OnTitleBarInsetChanged() => _header.Padding = new Thickness(Theme.SpaceL + Theme.SpaceXs, Theme.SpaceM, Theme.SpaceL + _titleBar.ReservedWidth, Theme.SpaceM); + /// + /// Restaura a janela se estiver minimizada e traz para o primeiro plano (ativação por sinal ou automação externa). + /// + public void BringToForeground() + { + try + { + if (AppWindow.Presenter is OverlappedPresenter overlapped && overlapped.State == OverlappedPresenterState.Minimized) + { + overlapped.Restore(); + } + Activate(); + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + WindowActivation.BringToForeground(hwnd); + _root.Focus(FocusState.Programmatic); + } + catch (Exception ex) + { + AppLog.Info($"MainWindow.BringToForeground: {ex.Message}"); + } + } + + /// + /// Encerramento limpo solicitado por automação (controlfs://stop, --stop, etc.). + /// + public void RequestAutomationExit() + { + _app.RequestAutomationExit(); + } + private static int IndexOf(IReadOnlyList items, FileEntry entry) { for (var i = 0; i < items.Count; i++) @@ -1215,3 +1255,4 @@ private static int IndexOf(IReadOnlyList items, FileEntry entry) return -1; } } + diff --git a/src/ControlFS.Application/AppController.cs b/src/ControlFS.Application/AppController.cs index bddbe8ab..184797c8 100644 --- a/src/ControlFS.Application/AppController.cs +++ b/src/ControlFS.Application/AppController.cs @@ -695,6 +695,16 @@ private void ShowExitDialog() PushModal(dialog); } + /// + /// Encerramento solicitado por automação externa (protocolo controlfs://stop ou argumento --stop). + /// Cancela operações ativas e fecha a aplicação de maneira limpa. + /// + public void RequestAutomationExit() + { + foreach (var op in Operations.Items.Where(o => o.IsActive).ToList()) Operations.Cancel(op); + RequestExit(); + } + // ---------- Infra interna ---------- internal void Track(Task task) diff --git a/src/ControlFS.Core/Automation/AppProtocol.cs b/src/ControlFS.Core/Automation/AppProtocol.cs new file mode 100644 index 00000000..bdc83d02 --- /dev/null +++ b/src/ControlFS.Core/Automation/AppProtocol.cs @@ -0,0 +1,82 @@ +namespace ControlFS.Core.Automation; + +/// +/// Ações suportadas pelos links do esquema controlfs:// e argumentos de linha de comando. +/// Permite automação externa (Console Mode, Stream Deck, scripts, atalhos). +/// +public static class AppProtocol +{ + public const string Scheme = "controlfs"; + + public const string StartAction = "start"; + public const string StopAction = "stop"; + public const string ShowAction = "show"; + + /// + /// Interpreta o argumento passado (URI controlfs://... ou flag de CLI como --stop, --start, --close). + /// Retorna a ação normalizada ("start", "stop", "show") ou null se não for reconhecido. + /// + public static string? ParseAction(string? argument) + { + if (string.IsNullOrWhiteSpace(argument)) return null; + + var trimmed = argument.Trim(); + + // Linha de comando: --stop, --close, --quit + if (trimmed.Equals("--stop", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals("--close", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals("--quit", StringComparison.OrdinalIgnoreCase)) + { + return StopAction; + } + + // Linha de comando: --start, --open + if (trimmed.Equals("--start", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals("--open", StringComparison.OrdinalIgnoreCase)) + { + return StartAction; + } + + // Linha de comando: --show + if (trimmed.Equals("--show", StringComparison.OrdinalIgnoreCase)) + { + return ShowAction; + } + + // Esquema controlfs://... ou controlfs:... + if (!trimmed.StartsWith(Scheme + ":", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Exemplos aceitos: + // controlfs://start, controlfs://start/, controlfs:start, controlfs://start?foo=1#bar + // controlfs://stop, controlfs://close, controlfs://quit + // controlfs://show + // controlfs://open + var rest = trimmed[(Scheme.Length + 1)..].TrimStart('/'); + var end = rest.IndexOfAny(['/', '?', '#']); + var action = (end < 0 ? rest : rest[..end]).Trim().ToLowerInvariant(); + + return action switch + { + StartAction or "open" => StartAction, + StopAction or "close" or "quit" => StopAction, + ShowAction => ShowAction, + _ => null + }; + } + + /// + /// Localiza a primeira ação de protocolo ou linha de comando válida dentro dos argumentos passados. + /// + public static string? ParseActionFromArgs(IEnumerable? args) + { + if (args is null) return null; + foreach (var arg in args) + { + if (ParseAction(arg) is { } action) return action; + } + return null; + } +} diff --git a/src/ControlFS.Infrastructure.Windows/Automation/SingleInstanceCoordinator.cs b/src/ControlFS.Infrastructure.Windows/Automation/SingleInstanceCoordinator.cs new file mode 100644 index 00000000..3e15c0c8 --- /dev/null +++ b/src/ControlFS.Infrastructure.Windows/Automation/SingleInstanceCoordinator.cs @@ -0,0 +1,98 @@ +using System.Runtime.Versioning; +using ControlFS.Core.Automation; + +namespace ControlFS.Infrastructure.Windows.Automation; + +/// +/// Coordena a inicialização única (single-instance) e sinalização entre processos para automação. +/// Permite que links controlfs:// e argumentos de linha de comando acionem a instância já em execução. +/// +[SupportedOSPlatform("windows")] +public static class SingleInstanceCoordinator +{ + public const string InstanceMutexName = @"Local\ControlFS.Instance"; + public const string ShowSignalName = @"Local\ControlFS.Show"; + public const string CloseSignalName = @"Local\ControlFS.Close"; + + /// + /// Avalia a linha de comando e determina se o processo deve continuar ou repassar a ação à instância existente. + /// Retorna true se o processo atual deve ser finalizado imediatamente. + /// Retorna false se este processo é a primeira instância e deve abrir a aplicação. + /// + public static bool HandleLaunch(string[] rawArgs, out Mutex? instanceMutex, bool bypass = false, Action? log = null) + { + instanceMutex = null; + if (!OperatingSystem.IsWindows() || bypass) + { + return false; + } + + var args = rawArgs.Skip(1).ToList(); + var action = AppProtocol.ParseActionFromArgs(args); + + bool isFirstInstance; + try + { + instanceMutex = new Mutex(true, InstanceMutexName, out isFirstInstance); + } + catch (AbandonedMutexException) + { + isFirstInstance = true; + } + + if (!isFirstInstance) + { + // Instância já rodando: avisa a instância existente. + if (action == AppProtocol.StopAction) + { + log?.Invoke("Avisando instância em execução para fechar (Close)"); + Signal(CloseSignalName, log); + } + else + { + log?.Invoke("Avisando instância em execução para exibir (Show)"); + Signal(ShowSignalName, log); + } + + instanceMutex?.Dispose(); + instanceMutex = null; + return true; + } + + // Primeira instância: + if (action == AppProtocol.StopAction) + { + // Pedido de parada quando nada está aberto: não abre a interface. + log?.Invoke("Comando de parada recebido (--stop ou controlfs://stop), mas nenhuma instância estava aberta."); + instanceMutex?.Dispose(); + instanceMutex = null; + return true; + } + + return false; + } + + public static void Signal(string signalName, Action? log = null) + { + if (!OperatingSystem.IsWindows()) return; + + try + { + if (EventWaitHandle.TryOpenExisting(signalName, out var handle)) + { + using (handle) + { + handle.Set(); + } + } + else + { + log?.Invoke($"Sinal '{signalName}' não encontrado ao tentar avisar instância existente."); + } + } + catch (Exception ex) + { + log?.Invoke($"Erro ao enviar sinal '{signalName}': {ex.Message}"); + } + } +} diff --git a/src/ControlFS.Infrastructure.Windows/Automation/SingleInstanceListener.cs b/src/ControlFS.Infrastructure.Windows/Automation/SingleInstanceListener.cs new file mode 100644 index 00000000..2af751a6 --- /dev/null +++ b/src/ControlFS.Infrastructure.Windows/Automation/SingleInstanceListener.cs @@ -0,0 +1,35 @@ +using System.Runtime.Versioning; + +namespace ControlFS.Infrastructure.Windows.Automation; + +/// +/// Escuta os sinais emitidos por novas invocações do aplicativo (via protocolo ou linha de comando) +/// e despacha as ações configuradas. +/// +[SupportedOSPlatform("windows")] +public sealed class SingleInstanceListener : IDisposable +{ + private readonly EventWaitHandle? _showSignal; + private readonly EventWaitHandle? _closeSignal; + private readonly RegisteredWaitHandle? _showWait; + private readonly RegisteredWaitHandle? _closeWait; + + public SingleInstanceListener(Action onShow, Action onClose) + { + if (!OperatingSystem.IsWindows()) return; + + _showSignal = new EventWaitHandle(false, EventResetMode.AutoReset, SingleInstanceCoordinator.ShowSignalName); + _closeSignal = new EventWaitHandle(false, EventResetMode.AutoReset, SingleInstanceCoordinator.CloseSignalName); + + _showWait = ThreadPool.RegisterWaitForSingleObject(_showSignal, (_, _) => onShow(), null, Timeout.Infinite, executeOnlyOnce: false); + _closeWait = ThreadPool.RegisterWaitForSingleObject(_closeSignal, (_, _) => onClose(), null, Timeout.Infinite, executeOnlyOnce: false); + } + + public void Dispose() + { + _showWait?.Unregister(null); + _closeWait?.Unregister(null); + _showSignal?.Dispose(); + _closeSignal?.Dispose(); + } +} diff --git a/src/ControlFS.Infrastructure.Windows/Shell/ProtocolRegistration.cs b/src/ControlFS.Infrastructure.Windows/Shell/ProtocolRegistration.cs new file mode 100644 index 00000000..7ff5ac9f --- /dev/null +++ b/src/ControlFS.Infrastructure.Windows/Shell/ProtocolRegistration.cs @@ -0,0 +1,79 @@ +using System.Runtime.Versioning; +using ControlFS.Core.Automation; +using Microsoft.Win32; + +namespace ControlFS.Infrastructure.Windows.Shell; + +/// +/// Registra o esquema de URL controlfs:// no registro do usuário atual (HKCU). +/// Garante que links executem o executável atual mesmo em versões portáteis movidas de pasta. +/// +public static class ProtocolRegistration +{ + public const string Scheme = AppProtocol.Scheme; + private const string ClassKey = @"Software\Classes\" + Scheme; + + /// + /// Confere se o esquema está registrado e apontando para o executável atual. + /// Se não estiver ou se apontar para outro caminho, atualiza o registro. + /// + [SupportedOSPlatform("windows")] + public static void EnsureRegistered(Action? log = null) + { + if (!OperatingSystem.IsWindows()) return; + + try + { + var exe = Environment.ProcessPath; + if (string.IsNullOrEmpty(exe)) return; + var command = $"\"{exe}\" \"%1\""; + + using var existing = Registry.CurrentUser.OpenSubKey(ClassKey + @"\shell\open\command"); + if (existing?.GetValue(null) is string current && string.Equals(current, command, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + using var key = Registry.CurrentUser.CreateSubKey(ClassKey, writable: true); + key.SetValue(null, "URL:ControlFS"); + key.SetValue("URL Protocol", string.Empty); + using (var icon = key.CreateSubKey("DefaultIcon")) + { + icon.SetValue(null, $"\"{exe}\",0"); + } + using (var open = key.CreateSubKey(@"shell\open\command")) + { + open.SetValue(null, command); + } + log?.Invoke($"Protocolo: {Scheme}:// registrado para {exe}"); + } + catch (Exception ex) + { + log?.Invoke($"Protocolo: não foi possível registrar {Scheme}://: {ex.Message}"); + } + } + + /// + /// Verifica se a chave de protocolo aponta para o executável especificado (ou o atual se omitido). + /// + [SupportedOSPlatform("windows")] + public static bool IsRegistered(string? exePath = null) + { + if (!OperatingSystem.IsWindows()) return false; + + try + { + exePath ??= Environment.ProcessPath; + if (string.IsNullOrEmpty(exePath)) return false; + var expectedCommand = $"\"{exePath}\" \"%1\""; + + using var existing = Registry.CurrentUser.OpenSubKey(ClassKey + @"\shell\open\command"); + return existing?.GetValue(null) is string current && + string.Equals(current, expectedCommand, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } +} diff --git a/src/ControlFS.Infrastructure.Windows/Shell/WindowActivation.cs b/src/ControlFS.Infrastructure.Windows/Shell/WindowActivation.cs new file mode 100644 index 00000000..84e3a89a --- /dev/null +++ b/src/ControlFS.Infrastructure.Windows/Shell/WindowActivation.cs @@ -0,0 +1,59 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace ControlFS.Infrastructure.Windows.Shell; + +/// +/// Métodos nativos de ativação e foco de janela no Windows. +/// Garante que o app venha ao primeiro plano ao receber comandos de automação ou links externos. +/// +[SupportedOSPlatform("windows")] +public static partial class WindowActivation +{ + private const int SwRestore = 9; + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetForegroundWindow(nint hWnd); + + [LibraryImport("user32.dll")] + private static partial nint GetForegroundWindow(); + + [LibraryImport("user32.dll")] + private static partial uint GetWindowThreadProcessId(nint hWnd, nint processId); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach); + + [LibraryImport("kernel32.dll")] + private static partial uint GetCurrentThreadId(); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool ShowWindow(nint hWnd, int nCmdShow); + + /// + /// Restaura a janela se estiver minimizada e traz para a frente anexando temporariamente à fila da thread de foco. + /// + public static bool BringToForeground(nint hwnd) + { + if (!OperatingSystem.IsWindows() || hwnd == 0) return false; + + ShowWindow(hwnd, SwRestore); + var current = GetForegroundWindow(); + if (current == hwnd) return true; + + var ourThread = GetCurrentThreadId(); + var theirThread = current == 0 ? 0 : GetWindowThreadProcessId(current, 0); + var attached = theirThread != 0 && theirThread != ourThread && AttachThreadInput(ourThread, theirThread, true); + try + { + return SetForegroundWindow(hwnd); + } + finally + { + if (attached) AttachThreadInput(ourThread, theirThread, false); + } + } +} diff --git a/tests/ControlFS.UnitTests/Core/AppProtocolTests.cs b/tests/ControlFS.UnitTests/Core/AppProtocolTests.cs new file mode 100644 index 00000000..9781b2c0 --- /dev/null +++ b/tests/ControlFS.UnitTests/Core/AppProtocolTests.cs @@ -0,0 +1,88 @@ +using ControlFS.Core.Automation; + +namespace ControlFS.UnitTests.Core; + +public class AppProtocolTests +{ + [Theory] + [InlineData("controlfs://start", "start")] + [InlineData("controlfs://start/", "start")] + [InlineData("CONTROLFS://Start", "start")] + [InlineData("controlfs:start", "start")] + [InlineData("controlfs://start?foo=bar", "start")] + [InlineData("controlfs://start#section", "start")] + [InlineData("controlfs://open", "start")] + [InlineData("controlfs://open/", "start")] + [InlineData("controlfs:open", "start")] + [InlineData("--start", "start")] + [InlineData("--open", "start")] + [InlineData(" --START ", "start")] + public void Start_actions_are_parsed_correctly(string input, string expected) + { + Assert.Equal(expected, AppProtocol.ParseAction(input)); + } + + [Theory] + [InlineData("controlfs://stop", "stop")] + [InlineData("controlfs://stop/", "stop")] + [InlineData("CONTROLFS://Stop", "stop")] + [InlineData("controlfs:stop", "stop")] + [InlineData("controlfs://stop?force=1", "stop")] + [InlineData("controlfs://close", "stop")] + [InlineData("controlfs://close/", "stop")] + [InlineData("controlfs:close", "stop")] + [InlineData("controlfs://quit", "stop")] + [InlineData("controlfs://quit/", "stop")] + [InlineData("controlfs:quit", "stop")] + [InlineData("--stop", "stop")] + [InlineData("--close", "stop")] + [InlineData("--quit", "stop")] + [InlineData(" --STOP ", "stop")] + public void Stop_actions_are_parsed_correctly(string input, string expected) + { + Assert.Equal(expected, AppProtocol.ParseAction(input)); + } + + [Theory] + [InlineData("controlfs://show", "show")] + [InlineData("controlfs://show/", "show")] + [InlineData("CONTROLFS://Show", "show")] + [InlineData("controlfs:show", "show")] + [InlineData("controlfs://show?target=1", "show")] + [InlineData("--show", "show")] + [InlineData(" --SHOW ", "show")] + public void Show_actions_are_parsed_correctly(string input, string expected) + { + Assert.Equal(expected, AppProtocol.ParseAction(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("controlfs://")] + [InlineData("controlfs:")] + [InlineData("controlfs://unknown")] + [InlineData("controlfs://invalid_action")] + [InlineData("consolemode://start")] + [InlineData("http://controlfs://start")] + [InlineData("steam://rungameid/123")] + [InlineData("--unknown-flag")] + [InlineData("C:\\Some\\Path\\File.txt")] + public void Invalid_or_unsupported_inputs_return_null(string? input) + { + Assert.Null(AppProtocol.ParseAction(input)); + } + + [Fact] + public void ParseActionFromArgs_finds_first_matching_action() + { + Assert.Null(AppProtocol.ParseActionFromArgs(null)); + Assert.Null(AppProtocol.ParseActionFromArgs([])); + Assert.Null(AppProtocol.ParseActionFromArgs(["--no-onboarding", "--unknown"])); + + Assert.Equal("stop", AppProtocol.ParseActionFromArgs(["--no-onboarding", "--stop"])); + Assert.Equal("start", AppProtocol.ParseActionFromArgs(["--no-onboarding", "controlfs://start"])); + Assert.Equal("show", AppProtocol.ParseActionFromArgs(["controlfs://show", "--stop"])); + } +} diff --git a/tests/ControlFS.WindowsIntegrationTests/ProtocolRegistrationIntegrationTests.cs b/tests/ControlFS.WindowsIntegrationTests/ProtocolRegistrationIntegrationTests.cs new file mode 100644 index 00000000..292154cb --- /dev/null +++ b/tests/ControlFS.WindowsIntegrationTests/ProtocolRegistrationIntegrationTests.cs @@ -0,0 +1,33 @@ +using ControlFS.Infrastructure.Windows.Shell; +using Microsoft.Win32; +using Xunit; + +namespace ControlFS.WindowsIntegrationTests; + +public class ProtocolRegistrationIntegrationTests +{ + private const string ProtocolKey = @"Software\Classes\controlfs"; + + [Fact] + public void EnsureRegistered_registers_scheme_in_current_user_registry() + { + if (!OperatingSystem.IsWindows()) Assert.Skip("Requer Windows."); + + var messages = new List(); + ProtocolRegistration.EnsureRegistered(messages.Add); + + using var key = Registry.CurrentUser.OpenSubKey(ProtocolKey); + Assert.NotNull(key); + Assert.Equal("URL:ControlFS", key.GetValue(null)); + Assert.Equal(string.Empty, key.GetValue("URL Protocol")); + + using var cmdKey = Registry.CurrentUser.OpenSubKey(ProtocolKey + @"\shell\open\command"); + Assert.NotNull(cmdKey); + var command = cmdKey.GetValue(null) as string; + Assert.NotNull(command); + Assert.Contains(Environment.ProcessPath!, command, StringComparison.OrdinalIgnoreCase); + Assert.EndsWith("\"%1\"", command, StringComparison.OrdinalIgnoreCase); + + Assert.True(ProtocolRegistration.IsRegistered()); + } +} diff --git a/tests/ControlFS.WindowsIntegrationTests/SingleInstanceIntegrationTests.cs b/tests/ControlFS.WindowsIntegrationTests/SingleInstanceIntegrationTests.cs new file mode 100644 index 00000000..cc2c6796 --- /dev/null +++ b/tests/ControlFS.WindowsIntegrationTests/SingleInstanceIntegrationTests.cs @@ -0,0 +1,69 @@ +using ControlFS.Infrastructure.Windows.Automation; +using Xunit; + +namespace ControlFS.WindowsIntegrationTests; + +public class SingleInstanceIntegrationTests +{ + [Fact] + public void HandleLaunch_with_bypass_returns_false_without_mutex() + { + if (!OperatingSystem.IsWindows()) Assert.Skip("Requer Windows."); + + var shouldExit = SingleInstanceCoordinator.HandleLaunch(["ControlFS.exe"], out var mutex, bypass: true); + Assert.False(shouldExit); + Assert.Null(mutex); + } + + [Fact] + public void HandleLaunch_first_instance_with_stop_returns_true_without_starting() + { + if (!OperatingSystem.IsWindows()) Assert.Skip("Requer Windows."); + + // Se nenhuma instância estiver aberta e alguém passar --stop ou controlfs://stop, não abre o app. + // Simulamos usando bypass falso apenas se o mutex não estiver já ocupado por outro processo de verdade. + if (Mutex.TryOpenExisting(SingleInstanceCoordinator.InstanceMutexName, out var existing)) + { + existing.Dispose(); + Assert.Skip("Outra instância real do ControlFS está rodando no momento."); + } + + var shouldExit = SingleInstanceCoordinator.HandleLaunch(["ControlFS.exe", "controlfs://stop"], out var mutex); + Assert.True(shouldExit); + Assert.Null(mutex); + } + + [Fact] + public void Second_instance_signals_show_and_close_to_listener() + { + if (!OperatingSystem.IsWindows()) Assert.Skip("Requer Windows."); + + if (Mutex.TryOpenExisting(SingleInstanceCoordinator.InstanceMutexName, out var existing)) + { + existing.Dispose(); + Assert.Skip("Outra instância real do ControlFS está rodando no momento."); + } + + // Criamos o mutex da primeira instância para simular a instância ativa + using var firstInstanceMutex = new Mutex(true, SingleInstanceCoordinator.InstanceMutexName); + + var showTriggered = new ManualResetEventSlim(false); + var closeTriggered = new ManualResetEventSlim(false); + + using var listener = new SingleInstanceListener( + onShow: () => showTriggered.Set(), + onClose: () => closeTriggered.Set()); + + // Segunda instância envia sinal Show + var exitForShow = SingleInstanceCoordinator.HandleLaunch(["ControlFS.exe", "controlfs://start"], out var showMutex); + Assert.True(exitForShow); + Assert.Null(showMutex); + Assert.True(showTriggered.Wait(TimeSpan.FromSeconds(3)), "O sinal Show deveria ter sido recebido pelo listener."); + + // Segunda instância envia sinal Close + var exitForClose = SingleInstanceCoordinator.HandleLaunch(["ControlFS.exe", "--stop"], out var closeMutex); + Assert.True(exitForClose); + Assert.Null(closeMutex); + Assert.True(closeTriggered.Wait(TimeSpan.FromSeconds(3)), "O sinal Close deveria ter sido recebido pelo listener."); + } +}