From 2e93a93281d61cd9d0af39c29fdc6fd246305b38 Mon Sep 17 00:00:00 2001 From: nextestudios <47460003+nextestudios@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:46:48 -0300 Subject: [PATCH] fix: automation stop asks before cancelling operations, portable leaves no registry trace, relaunch waits for the old instance Follow-up to #277 review. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 2 +- CHANGELOG.md | 2 +- build/ControlFS.iss | 2 +- docs/GUIDE.md | 4 +- docs/GUIDE.pt-BR.md | 4 +- docs/PRIVACY.md | 2 + docs/TESTING.md | 4 +- src/ControlFS.App/App.cs | 3 +- src/ControlFS.App/Views/MainWindow.cs | 2 +- src/ControlFS.Application/AppController.cs | 16 ++++--- src/ControlFS.Core/Automation/AppProtocol.cs | 6 +++ .../Automation/SingleInstanceCoordinator.cs | 11 +++++ .../Application/JourneyTests.cs | 28 ++++++++++++ .../SingleInstanceIntegrationTests.cs | 45 +++++++++++++++++++ 14 files changed, 116 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index b6f874b7..f1615fab 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,7 +4,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [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. +- **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; with copies or other operations running it doesn't cancel them by itself and shows the exit confirmation. The protocol is registered by the installer; the portable version writes nothing to the registry (the command-line flags work there). After an update, the app relaunched by the installer waits for the old instance to leave. ### 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 bfd58ba3..645c011d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [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. +- **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; havendo cópias ou outras operações em andamento, ele não as cancela sozinho e mostra a confirmação de saída. O protocolo é registrado pelo instalador; a versão portátil não escreve nada no registro (as opções de linha de comando funcionam nela). Depois de uma atualização, o app reaberto pelo instalador espera a instância antiga sair. ### 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/build/ControlFS.iss b/build/ControlFS.iss index 20bee228..3039fe34 100644 --- a/build/ControlFS.iss +++ b/build/ControlFS.iss @@ -89,7 +89,7 @@ Root: HKCU; Subkey: "Software\Classes\controlfs\shell\open\command"; ValueType: [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. -Filename: "{app}\{#AppExe}"; Flags: nowait; Check: ShouldRelaunch +Filename: "{app}\{#AppExe}"; Parameters: "--relaunch"; Flags: nowait; Check: ShouldRelaunch [UninstallDelete] ; Somente o cache de downloads de atualização do próprio app; preferências do usuário são preservadas. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 903e8678..80292d3d 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -303,9 +303,9 @@ ControlFS can be opened, brought to the front, or closed from external tools, sc - `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. +- `controlfs://stop` (or `ControlFS.exe --stop`, `--close`): cleanly closes the running instance and saves settings. If file operations are running, it does **not** cancel them by itself: the window comes to the front with the usual "Sair do ControlFS?" confirmation (starting on Cancel). 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. +The `controlfs://` scheme is registered in Windows by the installer (and re-registered on startup if the install folder moved). The portable version writes nothing to the registry, so `controlfs://` links need the installed version; the command-line flags work everywhere. Only one instance of ControlFS runs per user session; launching a second instance signals the active window and exits immediately. ## Screen readers diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index 8c48596c..26d59c55 100644 --- a/docs/GUIDE.pt-BR.md +++ b/docs/GUIDE.pt-BR.md @@ -303,9 +303,9 @@ O ControlFS pode ser aberto, trazido para a frente ou encerrado por ferramentas - `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. +- `controlfs://stop` (ou `ControlFS.exe --stop`, `--close`): encerra a instância aberta de forma limpa e salva as preferências. Se houver operações de arquivo em andamento, **não** as cancela sozinho: a janela vem para a frente com a confirmação "Sair do ControlFS?" de sempre (começando em Cancelar). 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. +O esquema `controlfs://` é registrado no Windows pelo instalador (e de novo ao iniciar, se a pasta de instalação mudou). A versão portátil não escreve nada no registro, então os links `controlfs://` pedem a versão instalada; as opções de linha de comando funcionam em qualquer uma. 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 diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index e476bd86..40ec2d9b 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -18,6 +18,7 @@ the user or the person installing or operating it. QR code; no cloud service, account or relay is involved and nothing is stored. The phone only sends navigation actions and the text you type; the PC only sends back whether a text field is open. It stops listening when you disconnect, when the phone disconnects or when ControlFS closes. Windows Firewall may ask for permission the first time. +- **`controlfs://` links and command-line flags (automation):** the installer registers the `controlfs://` scheme for the current Windows user so launchers and scripts can open, show or close the app. Nothing is sent anywhere; `controlfs://stop` never cancels running file operations by itself (it asks first). The portable version writes nothing to the registry. - **"Mais da equipe" (More from the team):** one screen, shown once, with two of the team's apps (NextBoost PRO, Console Mode). Their logos ship inside the package and nothing is downloaded. A link (`https://nextboost.pro/`, `https://github.com/lippdev/consolemode`) opens in your default browser only when you choose it; those sites then see your @@ -43,6 +44,7 @@ especificamente pelo usuário ou por quem o instala ou opera. na nuvem, conta nem retransmissor, e nada fica guardado. O celular só manda ações de navegação e o texto que você digita; o PC só responde se há um campo de texto aberto. Para de escutar ao desconectar, quando o celular desconecta ou ao fechar o ControlFS. Na primeira vez o Firewall do Windows pode pedir permissão. +- **Links `controlfs://` e opções de linha de comando (automação):** o instalador registra o esquema `controlfs://` para o usuário atual do Windows, para que launchers e scripts abram, mostrem ou fechem o app. Nada é enviado a lugar nenhum; `controlfs://stop` nunca cancela sozinho operações de arquivo em andamento (ele pergunta antes). A versão portátil não escreve nada no registro. - **"Mais da equipe":** uma tela, mostrada uma vez, com dois aplicativos da equipe (NextBoost PRO e Console Mode). Os logos vêm dentro do pacote e nada é baixado. Um link (`https://nextboost.pro/`, `https://github.com/lippdev/consolemode`) só abre no navegador padrão quando você escolhe; esses sites então veem a sua visita como em qualquer acesso à web. O ControlFS diff --git a/docs/TESTING.md b/docs/TESTING.md index a0a1f08a..389890de 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -38,7 +38,9 @@ versão, Windows, controle e conexão. Resultados de controles vão para `contro - [ ] `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: ______ +- [ ] Versão **portátil**: abrir e fechar não cria `HKCU\Software\Classes\controlfs`; `ControlFS-Portable-x64.exe --show` com o app aberto traz a janela. Resultado: ______ +- [ ] `controlfs://stop` com uma cópia grande em andamento: a janela vem para a frente com "Sair do ControlFS?" em Cancelar; a cópia continua. Resultado: ______ +- [ ] Atualização "Instalar e reiniciar": o app volta a abrir sozinho depois da instalação. Resultado: ______ ## Narrador (#40) diff --git a/src/ControlFS.App/App.cs b/src/ControlFS.App/App.cs index a855c1e5..11080572 100644 --- a/src/ControlFS.App/App.cs +++ b/src/ControlFS.App/App.cs @@ -37,7 +37,8 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) return; } - ProtocolRegistration.EnsureRegistered(AppLog.Info); + // Só o instalado registra controlfs:// (o instalador já o faz; aqui cobre o caminho mudado). O portátil não deixa rastros no registro. + if (AppPaths.IsInstalled) ProtocolRegistration.EnsureRegistered(AppLog.Info); AppLog.Info("OnLaunched: criando janela"); _window = new MainWindow(); diff --git a/src/ControlFS.App/Views/MainWindow.cs b/src/ControlFS.App/Views/MainWindow.cs index e99ae475..bc5bb89e 100644 --- a/src/ControlFS.App/Views/MainWindow.cs +++ b/src/ControlFS.App/Views/MainWindow.cs @@ -1245,7 +1245,7 @@ public void BringToForeground() /// public void RequestAutomationExit() { - _app.RequestAutomationExit(); + if (!_app.RequestAutomationExit()) BringToForeground(); // há operações: a confirmação precisa ser vista } private static int IndexOf(IReadOnlyList items, FileEntry entry) diff --git a/src/ControlFS.Application/AppController.cs b/src/ControlFS.Application/AppController.cs index 184797c8..b9b1224d 100644 --- a/src/ControlFS.Application/AppController.cs +++ b/src/ControlFS.Application/AppController.cs @@ -696,13 +696,19 @@ private void ShowExitDialog() } /// - /// Encerramento solicitado por automação externa (protocolo controlfs://stop ou argumento --stop). - /// Cancela operações ativas e fecha a aplicação de maneira limpa. + /// Encerramento solicitado por automação externa (protocolo controlfs://stop ou argumento --stop). Sem operações em + /// andamento, fecha na hora. Com operações, um link ou script nunca cancela o trabalho do usuário sozinho: abre a + /// confirmação de saída de sempre (começa em "Cancelar") e devolve false para o chamador trazer a janela à frente. /// - public void RequestAutomationExit() + public bool RequestAutomationExit() { - foreach (var op in Operations.Items.Where(o => o.IsActive).ToList()) Operations.Cancel(op); - RequestExit(); + if (Operations.ActiveCount == 0) + { + RequestExit(); + return true; + } + if (TopModal is not DialogModal { Title: "Sair do ControlFS?" }) ShowExitDialog(); + return false; } // ---------- Infra interna ---------- diff --git a/src/ControlFS.Core/Automation/AppProtocol.cs b/src/ControlFS.Core/Automation/AppProtocol.cs index bdc83d02..6439a4ce 100644 --- a/src/ControlFS.Core/Automation/AppProtocol.cs +++ b/src/ControlFS.Core/Automation/AppProtocol.cs @@ -67,6 +67,12 @@ public static class AppProtocol }; } + /// Argumento que o instalador passa ao reabrir o app depois de uma atualização: espera a instância antiga sair. + public const string RelaunchFlag = "--relaunch"; + + public static bool IsRelaunch(IEnumerable? args) => + args?.Any(a => a.Equals(RelaunchFlag, StringComparison.OrdinalIgnoreCase)) == true; + /// /// Localiza a primeira ação de protocolo ou linha de comando válida dentro dos argumentos passados. /// diff --git a/src/ControlFS.Infrastructure.Windows/Automation/SingleInstanceCoordinator.cs b/src/ControlFS.Infrastructure.Windows/Automation/SingleInstanceCoordinator.cs index 3e15c0c8..7ae63097 100644 --- a/src/ControlFS.Infrastructure.Windows/Automation/SingleInstanceCoordinator.cs +++ b/src/ControlFS.Infrastructure.Windows/Automation/SingleInstanceCoordinator.cs @@ -14,6 +14,9 @@ public static class SingleInstanceCoordinator public const string ShowSignalName = @"Local\ControlFS.Show"; public const string CloseSignalName = @"Local\ControlFS.Close"; + /// Quanto tempo uma reabertura pós-atualização espera a instância antiga sair. + public static TimeSpan RelaunchWait { get; set; } = TimeSpan.FromSeconds(20); + /// /// 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. @@ -40,6 +43,14 @@ public static bool HandleLaunch(string[] rawArgs, out Mutex? instanceMutex, bool isFirstInstance = true; } + if (!isFirstInstance && AppProtocol.IsRelaunch(args)) + { + // Reabertura depois de uma atualização: a instância antiga ainda está saindo. Espera ela soltar o mutex em vez de + // sinalizá-la (ela fecharia e o app não voltaria). + try { isFirstInstance = instanceMutex!.WaitOne(RelaunchWait); } + catch (AbandonedMutexException) { isFirstInstance = true; } + } + if (!isFirstInstance) { // Instância já rodando: avisa a instância existente. diff --git a/tests/ControlFS.UnitTests/Application/JourneyTests.cs b/tests/ControlFS.UnitTests/Application/JourneyTests.cs index 3ed05b52..702b7e8c 100644 --- a/tests/ControlFS.UnitTests/Application/JourneyTests.cs +++ b/tests/ControlFS.UnitTests/Application/JourneyTests.cs @@ -202,6 +202,34 @@ public void Archive_is_browsed_read_only_and_back_returns_to_disk() => UiContext Assert.Equal("arq.zip", app.Browser.List.Focused?.Name); // foco restaurado no item de origem }); + [Fact] + public void Automation_stop_exits_at_once_when_idle_but_asks_first_when_an_operation_is_running() => UiContext.Run(async () => + { + var (d, _) = Boot(); + var app = d.App; + var exited = false; + app.ExitRequested += () => exited = true; + + Assert.True(app.RequestAutomationExit()); + Assert.True(exited); + + exited = false; + var op = app.Operations.Enqueue("Copiar teste", OperationKind.Copy, async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct); + return new OperationResult(OperationState.Completed, []); + }); + Assert.False(app.RequestAutomationExit()); // um link não cancela o trabalho do usuário sozinho + Assert.False(exited); + Assert.True(op.IsActive); + var dialog = await d.WaitDialog("Sair do ControlFS?"); + Assert.Equal("Cancelar", dialog.Options[dialog.FocusIndex].Label); + Assert.False(app.RequestAutomationExit()); // repetir não empilha outro diálogo + d.ChooseOption(dialog, "Sair"); + Assert.True(exited); + await UiContext.WaitUntil(() => !op.IsActive, "operação cancelada ao confirmar"); + }); + [Fact] public void Back_semantics_selection_then_history_then_home_then_confirmed_exit() => UiContext.Run(async () => { diff --git a/tests/ControlFS.WindowsIntegrationTests/SingleInstanceIntegrationTests.cs b/tests/ControlFS.WindowsIntegrationTests/SingleInstanceIntegrationTests.cs index cc2c6796..0ea023f2 100644 --- a/tests/ControlFS.WindowsIntegrationTests/SingleInstanceIntegrationTests.cs +++ b/tests/ControlFS.WindowsIntegrationTests/SingleInstanceIntegrationTests.cs @@ -33,6 +33,51 @@ public void HandleLaunch_first_instance_with_stop_returns_true_without_starting( Assert.Null(mutex); } + [Fact] + public void A_relaunch_after_an_update_waits_for_the_old_instance_to_leave_instead_of_signalling_it() + { + 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."); + } + + var held = new ManualResetEventSlim(false); + var release = new ManualResetEventSlim(false); + var old = new Thread(() => + { + using var mutex = new Mutex(true, SingleInstanceCoordinator.InstanceMutexName); + held.Set(); + release.Wait(); + mutex.ReleaseMutex(); + }); + old.Start(); + Assert.True(held.Wait(TimeSpan.FromSeconds(3))); + using var closeSignal = new EventWaitHandle(false, EventResetMode.AutoReset, SingleInstanceCoordinator.CloseSignalName); + using var showSignal = new EventWaitHandle(false, EventResetMode.AutoReset, SingleInstanceCoordinator.ShowSignalName); + var previous = SingleInstanceCoordinator.RelaunchWait; + SingleInstanceCoordinator.RelaunchWait = TimeSpan.FromSeconds(10); + try + { + _ = Task.Run(async () => { await Task.Delay(400); release.Set(); }); + var exit = SingleInstanceCoordinator.HandleLaunch(["ControlFS.exe", "--relaunch"], out var mutex); + try + { + Assert.False(exit); // virou a primeira instância e segue abrindo + Assert.NotNull(mutex); + Assert.False(showSignal.WaitOne(0)); // a instância antiga não foi sinalizada + } + finally { mutex?.Dispose(); } + } + finally + { + SingleInstanceCoordinator.RelaunchWait = previous; + release.Set(); + old.Join(); + } + } + [Fact] public void Second_instance_signals_show_and_close_to_listener() {