From df3afe37b6e7ef742b9fa12f4028b5a9a5902d99 Mon Sep 17 00:00:00 2001 From: JelielSantos <70759312+JelielSantos@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:39:17 -0300 Subject: [PATCH 1/2] feat(light): abrir o Monky Light com duplo clique, sem linha de comando (#894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sem argumentos, o Light abre só com a bandeja: na primeira vez uma janela nativa (Win32 / NSAlert) pede o link de convite ou endereço, o apelido e a senha opcional; nas próximas conecta direto ao último servidor. A bandeja ganha `Trocar de servidor…`, que reabre a janela no mesmo processo. - Perfil próprio de aplicativo (%LOCALAPPDATA%\Monky Light\Profile ou ~/Library/Application Support/Monky Light/Profile), com MONKY_LIGHT_PROFILE para desenvolvimento e QA. - Só o endereço ws(s):// e o apelido vão para o perfil: um convite é reduzido ao endereço e a senha fica apenas em memória. - A janela confere o que o núcleo recusaria, e as recusas que ela resolve (AUTH_INVALID_PASSWORD, NICKNAME_ALREADY_EXISTS, NICKNAME_INVALID) a reabrem no campo certo, em vez de só um alerta. - Sem leitor de console, o núcleo não usa stdin/stdout e a falta deles não o encerra; no Windows o console criado pelo duplo clique é solto. - O fluxo é independente de plataforma (`runStandalone`) e tem teste próprio. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/light/CMakeLists.txt | 22 +- apps/light/QA.en.md | 9 + apps/light/QA.md | 9 + apps/light/README.en.md | 41 +++- apps/light/README.md | 41 +++- apps/light/src/core_commands.hpp | 3 + apps/light/src/headless_application.cpp | 21 +- apps/light/src/headless_application.hpp | 12 ++ apps/light/src/main.cpp | 88 +++++++- apps/light/src/profile_identity.cpp | 21 +- apps/light/src/profile_identity.hpp | 7 + apps/light/src/standalone.cpp | 127 +++++++++++ apps/light/src/standalone.hpp | 65 ++++++ apps/light/src/ui/connect.hpp | 54 +++++ apps/light/src/ui/connect_macos.mm | 101 +++++++++ apps/light/src/ui/connect_windows.cpp | 210 +++++++++++++++++++ apps/light/src/ui/tray_controller.hpp | 9 +- apps/light/src/ui/tray_macos.mm | 3 + apps/light/src/ui/tray_model.hpp | 2 + apps/light/src/ui/tray_strings.hpp | 48 +++++ apps/light/src/ui/tray_windows.cpp | 3 + apps/light/test/platform_components.test.cjs | 4 + apps/light/test/profile_identity_test.cpp | 13 +- apps/light/test/standalone_test.cpp | 200 ++++++++++++++++++ apps/light/test/tray_model_test.cpp | 12 ++ release-notes/658-light-standalone.json | 5 + 26 files changed, 1111 insertions(+), 19 deletions(-) create mode 100644 apps/light/src/standalone.cpp create mode 100644 apps/light/src/standalone.hpp create mode 100644 apps/light/src/ui/connect.hpp create mode 100644 apps/light/src/ui/connect_macos.mm create mode 100644 apps/light/src/ui/connect_windows.cpp create mode 100644 apps/light/test/standalone_test.cpp create mode 100644 release-notes/658-light-standalone.json diff --git a/apps/light/CMakeLists.txt b/apps/light/CMakeLists.txt index ba59f3e8..89e2f06a 100644 --- a/apps/light/CMakeLists.txt +++ b/apps/light/CMakeLists.txt @@ -211,6 +211,15 @@ foreach(target monky_light_platform monky-light-platform-test monky_light_loop m endif() endforeach() if(MONKY_LIGHT_WITH_RTC) + # Opening without a command line: the flow is platform-free, the window is not. + add_library(monky_light_standalone STATIC src/standalone.cpp) + target_include_directories(monky_light_standalone PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src") + target_link_libraries(monky_light_standalone PUBLIC monky_light_profile monky_light_invite monky_light_contracts) + add_executable(monky-light-standalone-test test/standalone_test.cpp) + target_link_libraries(monky-light-standalone-test PRIVATE monky_light_standalone) + set_target_properties(monky-light-standalone-test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_CURRENT_BINARY_DIR}/bin") add_library(monky_light_application STATIC src/headless_application.cpp) target_include_directories(monky_light_application PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src") target_link_libraries(monky_light_application PUBLIC @@ -224,22 +233,22 @@ if(MONKY_LIGHT_WITH_RTC) RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin" RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_CURRENT_BINARY_DIR}/bin") if(WIN32) - add_library(monky_light_ui STATIC src/ui/tray_windows.cpp) - target_link_libraries(monky_light_ui PUBLIC Shell32 User32 Gdi32) + add_library(monky_light_ui STATIC src/ui/tray_windows.cpp src/ui/connect_windows.cpp) + target_link_libraries(monky_light_ui PUBLIC Shell32 User32 Gdi32 Ole32) target_compile_definitions(monky_light_ui PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) else() find_library(MONKY_APPKIT_FRAMEWORK AppKit REQUIRED) # AXIsProcessTrusted: push-to-talk needs Accessibility to see keys. find_library(MONKY_APPLICATION_SERVICES_FRAMEWORK ApplicationServices REQUIRED) - add_library(monky_light_ui STATIC src/ui/tray_macos.mm) - set_source_files_properties(src/ui/tray_macos.mm PROPERTIES COMPILE_OPTIONS "-fobjc-arc") + add_library(monky_light_ui STATIC src/ui/tray_macos.mm src/ui/connect_macos.mm) + set_source_files_properties(src/ui/tray_macos.mm src/ui/connect_macos.mm PROPERTIES COMPILE_OPTIONS "-fobjc-arc") target_link_libraries(monky_light_ui PUBLIC "${MONKY_APPKIT_FRAMEWORK}" "${MONKY_APPLICATION_SERVICES_FRAMEWORK}") endif() target_include_directories(monky_light_ui PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src") target_link_libraries(monky_light_ui PUBLIC monky_light_json) add_executable(monky-light src/main.cpp) - target_link_libraries(monky-light PRIVATE monky_light_application monky_light_ui) + target_link_libraries(monky-light PRIVATE monky_light_application monky_light_ui monky_light_standalone) if(APPLE) set_target_properties(monky-light PROPERTIES MACOSX_BUNDLE TRUE @@ -257,7 +266,8 @@ if(MONKY_LIGHT_WITH_RTC) monky_light_profile monky-light-profile-test monky_light_fixture_audio monky-light-audio-fixture-test monky_light_session monky-light-session-test monky_light_media monky-light-media-lifecycle-test monky-light-media-loopback-test - monky_light_application monky_light_ui monky-light-tray-test monky-light monky-light-fixture) + monky_light_application monky_light_ui monky-light-tray-test monky-light monky-light-fixture + monky_light_standalone monky-light-standalone-test) if(MSVC) target_compile_options(${target} PRIVATE /W4 /WX /EHsc /utf-8 /permissive-) else() diff --git a/apps/light/QA.en.md b/apps/light/QA.en.md index 71c6377c..79685f75 100644 --- a/apps/light/QA.en.md +++ b/apps/light/QA.en.md @@ -147,3 +147,12 @@ available, so the terminal shows what each click did. | 6.28 | Open `In the call` while someone speaks | They appear with `(speaking)`; you are not listed | | | | 6.29 | Choose 0%, 200%, and 100% for a participant | Silent, louder, back to normal; nobody else notices a difference | | | | 6.30 | Restart the Light and reopen `In the call` | The chosen volume is still checked and applied | | | +| 6.31 | With `MONKY_LIGHT_PROFILE` on a disposable folder, double-click the executable | The console disappears at once, the `Connect to a server` window appears, and no terminal stays open | | | +| 6.32 | Paste an invitation link, pick a nickname, and connect | Joins the server; `monky-light-settings.json` holds only `server` (`ws(s)://`) and `nickname`, no password | | | +| 6.33 | Quit and open again | Connects straight to the same server, with no window | | | +| 6.34 | Invalid address, empty nickname, or one longer than 32 characters | The window stays open, explains why, and focuses the field to fix | | | +| 6.35 | A server with a password, with none or a wrong one | The window reopens on the password field with the explanation, keeping server and nickname | | | +| 6.36 | A nickname someone else already uses on the server | The window reopens on the nickname field with the explanation | | | +| 6.37 | `Change server…` in the tray | The Light leaves the current server, the icon disappears, and the window reopens filled with the current server, without a password | | | +| 6.38 | Open a second copy with the same profile | Profile unavailable notice; the first copy stays connected | | | +| 6.39 | Cancel the window | The Light quits without connecting and leaves no process | | | diff --git a/apps/light/QA.md b/apps/light/QA.md index de1cce40..0488299d 100644 --- a/apps/light/QA.md +++ b/apps/light/QA.md @@ -148,3 +148,12 @@ valendo, então o terminal serve para conferir o que cada clique fez. | 6.28 | Abrir `Na chamada` enquanto alguém fala | A pessoa aparece com `(falando)`; você não aparece na lista | | | | 6.29 | Escolher 0%, 200% e 100% para um participante | Some, fica mais alto, volta ao normal; os outros não percebem diferença | | | | 6.30 | Reiniciar o Light e reabrir `Na chamada` | O volume escolhido continua marcado e aplicado | | | +| 6.31 | Com `MONKY_LIGHT_PROFILE` numa pasta descartável, abrir o executável com duplo clique | O console some na hora, a janela `Conectar a um servidor` aparece e nenhum terminal fica aberto | | | +| 6.32 | Colar um link de convite, escolher o apelido e conectar | Entra no servidor; `monky-light-settings.json` tem só `server` (`ws(s)://`) e `nickname`, sem senha | | | +| 6.33 | Fechar e abrir de novo | Conecta direto ao mesmo servidor, sem janela | | | +| 6.34 | Endereço inválido, apelido vazio ou com mais de 32 caracteres | A janela continua aberta, explica o motivo e foca o campo a corrigir | | | +| 6.35 | Servidor com senha, sem senha ou com a errada | A janela reabre no campo da senha com a explicação, mantendo servidor e apelido | | | +| 6.36 | Apelido já usado por outra pessoa no servidor | A janela reabre no campo do apelido com a explicação | | | +| 6.37 | `Trocar de servidor…` na bandeja | O Light sai do servidor atual, o ícone some e a janela reabre preenchida com o servidor atual, sem senha | | | +| 6.38 | Abrir uma segunda cópia com o mesmo perfil | Aviso de perfil indisponível; a primeira cópia continua conectada | | | +| 6.39 | Cancelar a janela | O Light encerra sem conectar e sem deixar processo | | | diff --git a/apps/light/README.en.md b/apps/light/README.en.md index f90c5a9d..2d57c952 100644 --- a/apps/light/README.en.md +++ b/apps/light/README.en.md @@ -173,6 +173,44 @@ processing, and 100% costs nothing. Setting 100 again removes the entry. The profile keeps up to 256 sessions with a changed volume; beyond that, `set-volume` is rejected until one is set back to 100. +## Opening as an application + +With no arguments at all (double click, Finder, or a shortcut), the Light opens +with only the tray and no console. The first time, a small window asks for the +invitation link or server address, the nickname, and the password if the server +needs one. Later starts connect straight to the last server. `Change server…`, +in the tray, ends the current connection and reopens the window in the same +process. + +- **Profile:** `%LOCALAPPDATA%\Monky Light\Profile` on Windows and + `~/Library/Application Support/Monky Light/Profile` on macOS, apart from any + profile given with `--profile`. For development and QA, point + `MONKY_LIGHT_PROFILE` at a disposable absolute folder; never use the profile + of a real installation. +- **What is saved:** only the `ws(s)://` address and the nickname, in + `monky-light-settings.json`. An invitation link is reduced to its address: + the server key is pinned on first contact, and the password **never** reaches + the disk. On a server with a password, each start connects without it; the + server's refusal reopens the window on the password field, keeping the rest. +- **Refusals the window can fix** reopen it with the reason, instead of only a + tray alert: a wrong password (`AUTH_INVALID_PASSWORD`), a nickname in use + (`NICKNAME_ALREADY_EXISTS`) or refused (`NICKNAME_INVALID`). +- **The window checks** what the core would refuse: empty fields, an address + that is neither an invitation nor `ws://`/`wss://`, and a nickname above the + shared limit (32 units, counted as the server counts them). The reason shows + in the window itself, and the field to fix gets the focus. +- **Without a console reader**, events reach only the tray and nothing is read + from stdin; a missing console does not end the core as it does in + command-line mode. +- **Another copy open:** the profile keeps its exclusive lock. A second start + shows a notice and quits without touching the first one. +- On Windows the executable is still a console program: a double click opens a + console for an instant, which the Light lets go right away. A console shared + with the terminal that started it is kept. + +Any argument keeps the command-line behaviour documented above, even without +`--tray`. + ## Tray interface `--tray` adds the icon to the Windows notification area or the macOS status bar. @@ -201,7 +239,8 @@ rendering between activations. It carries: - `In the call`, with each participant, who is speaking, and the volume, in presets from 0 to 200%; - `Audio devices`, with input, output, and `System default`; -- `Reconnect now` and `Quit Monky Light`. +- `Reconnect now`, `Change server…` (only when opened as an application), and + `Quit Monky Light`. In a native menu an exclusive choice (channel, device) uses a radio mark and a toggle uses a check mark: these are the native equivalents of the selectable card diff --git a/apps/light/README.md b/apps/light/README.md index 0d6ca148..87b49528 100644 --- a/apps/light/README.md +++ b/apps/light/README.md @@ -174,6 +174,44 @@ não há processamento extra, e 100% não custa nada. Voltar a 100 apaga a entra O perfil guarda até 256 sessões com volume alterado; acima disso, `set-volume` é recusado até que alguma volte a 100. +## Abrir como aplicativo + +Sem nenhum argumento (duplo clique, Finder ou atalho), o Light abre só com a +bandeja, sem console. Na primeira vez, uma janela pequena pede o link de +convite ou o endereço do servidor, o apelido e, se o servidor pedir, a senha. +Nas próximas aberturas ele conecta direto ao último servidor. +`Trocar de servidor…`, na bandeja, encerra a conexão atual e reabre a janela +no mesmo processo. + +- **Perfil:** `%LOCALAPPDATA%\Monky Light\Profile` no Windows e + `~/Library/Application Support/Monky Light/Profile` no macOS, separado de + qualquer perfil passado por `--profile`. Para desenvolvimento e QA, aponte + `MONKY_LIGHT_PROFILE` para uma pasta descartável absoluta; nunca use o perfil + de uma instalação real. +- **O que é salvo:** só o endereço `ws(s)://` e o apelido, em + `monky-light-settings.json`. Um link de convite é reduzido ao endereço: a + chave do servidor fica fixada no primeiro contato, e a senha **nunca** vai + para o disco. Num servidor com senha, cada abertura conecta sem ela; ao + recusar, o servidor faz a janela reabrir no campo da senha, mantendo o resto. +- **Recusas que a janela resolve** reabrem a janela com o motivo, em vez de só + um alerta na bandeja: senha errada (`AUTH_INVALID_PASSWORD`), apelido em uso + (`NICKNAME_ALREADY_EXISTS`) ou recusado (`NICKNAME_INVALID`). +- **A janela confere** o que o núcleo recusaria: campos vazios, endereço que + não é convite nem `ws://`/`wss://` e apelido acima do limite compartilhado + (32 unidades, contadas como o servidor conta). O motivo aparece na própria + janela, e o campo a corrigir recebe o foco. +- **Sem leitor de console**, os eventos vão só para a bandeja e nada é lido do + stdin; a ausência do console não encerra o núcleo como acontece no modo de + linha de comando. +- **Outra cópia aberta:** o perfil continua com lock exclusivo. Uma segunda + abertura mostra um aviso e encerra, sem tocar na primeira. +- No Windows, o executável ainda é de console: o duplo clique abre um console + por um instante, que o Light solta logo em seguida. Um console compartilhado + com o terminal que o iniciou é mantido. + +Qualquer argumento mantém o comportamento de linha de comando documentado +acima, inclusive sem `--tray`. + ## Interface de bandeja `--tray` adiciona o ícone na área de notificação do Windows ou na barra de status @@ -202,7 +240,8 @@ renderizando entre as ativações. Ele traz: - `Na chamada`, com cada participante, quem está falando e o volume, em predefinições de 0 a 200%; - `Dispositivos de áudio`, com entrada, saída e `Padrão do sistema`; -- `Reconectar agora` e `Sair do Monky Light`. +- `Reconectar agora`, `Trocar de servidor…` (só quando aberto como aplicativo) + e `Sair do Monky Light`. Em menu nativo, escolha exclusiva (canal, dispositivo) usa marca de rádio e liga/desliga usa marca de seleção: são os equivalentes nativos do card diff --git a/apps/light/src/core_commands.hpp b/apps/light/src/core_commands.hpp index 0b9ec4c9..c59b0beb 100644 --- a/apps/light/src/core_commands.hpp +++ b/apps/light/src/core_commands.hpp @@ -12,6 +12,9 @@ namespace monky::light { struct CoreCommands { std::function command; std::function quit; + // Present only when the Light was opened without a command line: stops the + // core so the connection window can ask for another server. + std::function switchServer{}; }; // A native interface owns the process main thread, because the platform event diff --git a/apps/light/src/headless_application.cpp b/apps/light/src/headless_application.cpp index 115ce05f..a08e50c7 100644 --- a/apps/light/src/headless_application.cpp +++ b/apps/light/src/headless_application.cpp @@ -154,7 +154,7 @@ class Application final { Application(Options options, HeadlessConfiguration audio) : options_(std::move(options)), audio_(std::move(audio)), identity_(options_.profile), session_({identity_.publicKeyHex(), identity_.deviceId(), options_.nickname, - sessionPassword(options_.invitedPassword), + sessionPassword(audio_.password ? audio_.password : options_.invitedPassword), identity_.serverKey(options_.server), identity_.serverKeysExcept(options_.server), options_.invitedServerKey}, {[this](const std::string& frame) { @@ -186,6 +186,11 @@ class Application final { loop_.post([this, line = std::move(line)] { command(line); }, bytes); }, [this] { loop_.post([this] { quit(); }); }, + audio_.askAgain ? std::function([this] { + audio_.askAgain(std::nullopt); + loop_.post([this] { quit(); }); + }) + : std::function(), }; } @@ -200,7 +205,9 @@ class Application final { } int run() { - ConsoleControl control({ + // An application without a console has no control channel at all. + std::optional control; + if (audio_.console) control.emplace(ConsoleControl::Callbacks{ [this](std::string line) { const auto bytes = line.size(); loop_.post([this, line = std::move(line)] { command(line); }, bytes); @@ -269,7 +276,7 @@ class Application final { void output(Json event) { if (observer_) observer_(event); - if (outputLost_) return; + if (outputLost_ || !audio_.console) return; std::cout << event.dump() << '\n' << std::flush; if (std::cout) return; // Nobody reads the events anymore. Leave the call and the server properly, @@ -752,6 +759,14 @@ class Application final { if (event.failure) { output({{"event", "session-error"}, {"code", event.failure->code}, {"kind", static_cast(event.failure->kind)}, {"detail", event.failure->detail}}); + // Retrying cannot fix these; the connection window can. + const auto& code = event.failure->code; + if (audio_.askAgain && (code == protocol::error::AUTH_INVALID_PASSWORD || + code == protocol::error::NICKNAME_ALREADY_EXISTS || + code == protocol::error::NICKNAME_INVALID)) { + audio_.askAgain(code); + quit(); + } } break; } diff --git a/apps/light/src/headless_application.hpp b/apps/light/src/headless_application.hpp index d84d5980..8cf8c2e1 100644 --- a/apps/light/src/headless_application.hpp +++ b/apps/light/src/headless_application.hpp @@ -29,6 +29,18 @@ struct HeadlessConfiguration { // Optional native interface for --tray. Without it the core keeps the main // thread and behaves exactly as the qualified headless client. std::function(CoreCommands)> createInterface; + // False when opened as an application (no command line): nobody reads stdout + // or writes stdin, so events reach only the interface and its absence is + // never taken as a reason to stop. + bool console = true; + // Typed into the connection window. Like MONKY_LIGHT_PASSWORD, which wins + // when set, it is only held in memory. + std::optional password{}; + // Application start only, so the connection window can ask again. Called on + // the core thread, just before the core stops: with the server's error code + // when it refused what the window can fix (password, nickname), or with + // nothing for the interface's CoreCommands::switchServer. + std::function refusal)> askAgain{}; }; int runHeadless(std::vector arguments, HeadlessConfiguration configuration); diff --git a/apps/light/src/main.cpp b/apps/light/src/main.cpp index b5786c25..c099792b 100644 --- a/apps/light/src/main.cpp +++ b/apps/light/src/main.cpp @@ -1,5 +1,8 @@ #include "headless_application.hpp" #include "platform/utf8.hpp" +#include "profile_identity.hpp" +#include "standalone.hpp" +#include "ui/connect.hpp" #include "ui/tray.hpp" #include @@ -9,9 +12,16 @@ #include #endif +#include +#include +#include +#include #include +#include #include +#include #include +#include namespace { @@ -37,7 +47,7 @@ monky::light::media::AudioDeviceList enumerateAudioDevices() { return result; } -int run(std::vector arguments) { +monky::light::HeadlessConfiguration configuration() { monky::light::HeadlessConfiguration audio; audio.create = [](const webrtc::Environment& environment) { #ifdef _WIN32 @@ -56,7 +66,81 @@ int run(std::vector arguments) { #ifdef __APPLE__ audio.requestMicrophoneAccess = monky::light::requestMicrophoneAccess; #endif - return monky::light::runHeadless(std::move(arguments), std::move(audio)); + return audio; +} + +std::optional profileOverride() { +#ifdef _WIN32 + wchar_t* value = nullptr; + std::size_t length = 0; + if (_wdupenv_s(&value, &length, L"MONKY_LIGHT_PROFILE") != 0 || !value) return std::nullopt; + std::filesystem::path result(value); + std::free(value); +#else + const auto* value = std::getenv("MONKY_LIGHT_PROFILE"); + if (!value) return std::nullopt; + std::filesystem::path result(value); +#endif + if (!result.is_absolute()) throw std::invalid_argument("MONKY_LIGHT_PROFILE must be an absolute path"); + return result; +} + +// Opened like an application (a double click, Finder, a shortcut): no command +// line, no console reader, the tray as the only interface. +int runApplication() { + namespace ui = monky::light::ui; + const auto language = ui::systemLanguage(); +#ifdef _WIN32 + // A double click gives the program a console of its own; hide it. One shared + // with a terminal that started the program is left alone. + DWORD processes[2]{}; + if (GetConsoleProcessList(processes, 2) == 1) FreeConsole(); +#endif + monky::light::StandaloneHooks hooks; + try { + // Development and QA point this elsewhere, never at the installed profile. + hooks.profile = profileOverride().value_or(ui::defaultProfileDirectory()); + // The profile creates only its own folder; the one above is the Light's. + std::filesystem::create_directories(hooks.profile.parent_path()); + } catch (const std::exception& error) { + ui::showProfileUnavailable(error.what(), language); + return 1; + } + hooks.load = [](const std::filesystem::path& profile) { + monky::light::ProfileIdentity identity(profile); + try { + return identity.loadSettings(); + } catch (const std::exception&) { + // Unreadable settings are only preferences; the core reports and replaces them. + return monky::light::ProfileSettings{}; + } + }; + hooks.save = [](const std::filesystem::path& profile, const monky::light::ProfileSettings& settings) { + monky::light::ProfileIdentity identity(profile); + identity.saveSettings(settings); + }; + hooks.ask = [language](const monky::light::Connection& current, std::optional issue) { + return ui::askConnection(current, issue, language); + }; + hooks.profileUnavailable = [language](const std::string& detail) { ui::showProfileUnavailable(detail, language); }; + hooks.run = [](std::vector arguments, std::optional password, + std::function)> askAgain) { + auto audio = configuration(); + audio.console = false; + audio.password = std::move(password); + audio.askAgain = std::move(askAgain); + return monky::light::runHeadless(std::move(arguments), std::move(audio)); + }; + return monky::light::runStandalone(hooks); +} + +int run(std::vector arguments) { +#ifdef __APPLE__ + // Older macOS versions pass a process serial number to applications Finder opens. + std::erase_if(arguments, [](const std::string& value) { return value.starts_with("-psn_"); }); +#endif + if (arguments.empty()) return runApplication(); + return monky::light::runHeadless(std::move(arguments), configuration()); } } // namespace diff --git a/apps/light/src/profile_identity.cpp b/apps/light/src/profile_identity.cpp index 46cd61ba..d2574ca8 100644 --- a/apps/light/src/profile_identity.cpp +++ b/apps/light/src/profile_identity.cpp @@ -537,6 +537,16 @@ std::optional settingsDevice(const Json& data, const char* field) { return found->get(); } +std::optional settingsText(const Json& data, const char* field, std::size_t limit) { + const auto found = data.find(field); + if (found == data.end() || found->is_null()) return std::nullopt; + if (!found->is_string() || found->get_ref().empty() || + found->get_ref().size() > limit) { + throw std::runtime_error("Profile settings contain an invalid saved connection"); + } + return found->get(); +} + std::optional settingsKey(const Json& data) { const auto found = data.find("pushToTalkKey"); if (found == data.end() || found->is_null()) return std::nullopt; @@ -596,7 +606,8 @@ ProfileSettings parseSettings(const std::string& contents) { throw std::runtime_error("Profile settings contain an invalid input mode"); } return {settingsDevice(data, "inputDeviceId"), settingsDevice(data, "outputDeviceId"), mode == kPushToTalk, - settingsVolumes(data), settingsKey(data)}; + settingsVolumes(data), settingsKey(data), settingsText(data, "server", kMaxSavedServerBytes), + settingsText(data, "nickname", kMaxSavedNicknameBytes)}; } std::string encodeSettings(const ProfileSettings& settings) { @@ -605,6 +616,10 @@ std::string encodeSettings(const ProfileSettings& settings) { throw std::invalid_argument("Audio device ID must be a nonempty string of at most 1024 bytes"); } } + if ((settings.server && (settings.server->empty() || settings.server->size() > kMaxSavedServerBytes)) || + (settings.nickname && (settings.nickname->empty() || settings.nickname->size() > kMaxSavedNicknameBytes))) { + throw std::invalid_argument("The saved server and nickname must be nonempty and bounded"); + } if (settings.pushToTalkKey && !parsePushToTalkKey(*settings.pushToTalkKey)) { throw std::invalid_argument("Unknown push-to-talk key"); } @@ -626,7 +641,9 @@ std::string encodeSettings(const ProfileSettings& settings) { {"outputDeviceId", optional(settings.outputDeviceId)}, {"inputMode", settings.pushToTalk ? kPushToTalk : kVoiceActivity}, {"userVolumes", Json(settings.userVolumes)}, - {"pushToTalkKey", optional(settings.pushToTalkKey)}}.dump(2) + "\n"; + {"pushToTalkKey", optional(settings.pushToTalkKey)}, + {"server", optional(settings.server)}, + {"nickname", optional(settings.nickname)}}.dump(2) + "\n"; } catch (const Json::exception&) { throw std::invalid_argument("Audio device and session IDs must be valid UTF-8"); } diff --git a/apps/light/src/profile_identity.hpp b/apps/light/src/profile_identity.hpp index 4301dbf7..086d3976 100644 --- a/apps/light/src/profile_identity.hpp +++ b/apps/light/src/profile_identity.hpp @@ -23,8 +23,15 @@ struct ProfileSettings { std::map userVolumes{}; // A name accepted by parsePushToTalkKey; nullopt until the user picks one. std::optional pushToTalkKey{}; + // The last server joined without a command line, as a ws(s):// address (an + // invitation's password is never stored), and the nickname used there. + std::optional server{}; + std::optional nickname{}; }; +constexpr std::size_t kMaxSavedServerBytes = 2048; +constexpr std::size_t kMaxSavedNicknameBytes = 256; + constexpr int kDefaultUserVolume = 100; constexpr int kMaxUserVolume = 200; // Bounds the settings file; setting a volume back to 100 frees its entry. diff --git a/apps/light/src/standalone.cpp b/apps/light/src/standalone.cpp new file mode 100644 index 00000000..16a5bc15 --- /dev/null +++ b/apps/light/src/standalone.cpp @@ -0,0 +1,127 @@ +#include "standalone.hpp" + +#include "platform/websocket.hpp" +#include "protocol.hpp" +#include "server_invite.hpp" + +#include +#include +#include +#include + +namespace monky::light { +namespace { + +std::string trimmed(const std::string& value) { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + const auto last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1); +} + +// The shared limit counts JavaScript string units (UTF-16), as the server does. +std::size_t utf16Length(const std::string& value) { + std::size_t result = 0; + for (const auto byte : value) { + const auto unit = static_cast(byte); + if ((unit & 0xC0u) == 0x80u) continue; // Continuation byte. + result += unit >= 0xF0u ? 2 : 1; // Four-byte sequences are surrogate pairs. + } + return result; +} + +} // namespace + +std::optional checkConnection(const Connection& connection) { + const auto server = trimmed(connection.server); + if (server.empty()) return ConnectionIssue::missingServer; + try { + websocket_detail::validateUrl(savedServer(server)); + } catch (const std::exception&) { + return ConnectionIssue::invalidServer; + } + const auto nickname = trimmed(connection.nickname); + if (nickname.empty()) return ConnectionIssue::missingNickname; + if (utf16Length(nickname) > static_cast(protocol::limits::MAX_NICKNAME_LENGTH)) { + return ConnectionIssue::nicknameTooLong; + } + return std::nullopt; +} + +std::string savedServer(const std::string& server) { + const auto value = trimmed(server); + return looksLikeServerInvite(value) ? parseServerInviteLink(value).serverUrl() : value; +} + +std::optional refusalIssue(const std::string& code) { + if (code == protocol::error::AUTH_INVALID_PASSWORD) return ConnectionIssue::wrongPassword; + if (code == protocol::error::NICKNAME_ALREADY_EXISTS) return ConnectionIssue::nicknameTaken; + if (code == protocol::error::NICKNAME_INVALID) return ConnectionIssue::nicknameRefused; + return std::nullopt; +} + +int runStandalone(const StandaloneHooks& hooks) { + bool ask = false; + std::optional issue; + std::optional previous; + for (;;) { + ProfileSettings settings; + try { + settings = hooks.load(hooks.profile); + } catch (const std::exception& error) { + hooks.profileUnavailable(error.what()); + return 1; + } + // A refusal keeps what was typed, so only the refused field needs fixing. + Connection connection = previous.value_or(Connection{settings.server.value_or(""), + settings.nickname.value_or(""), std::nullopt}); + if (ask || !settings.server || !settings.nickname) { + for (;;) { + auto answer = hooks.ask(connection, issue); + if (!answer) return 0; + connection = std::move(*answer); + issue = checkConnection(connection); + if (!issue) break; + } + connection.server = trimmed(connection.server); + connection.nickname = trimmed(connection.nickname); + settings.server = savedServer(connection.server); + settings.nickname = connection.nickname; + try { + hooks.save(hooks.profile, settings); + } catch (const std::exception& error) { + hooks.profileUnavailable(error.what()); + return 1; + } + } + // The first start after the window passes the typed text, so an invitation + // still pins its key and supplies its password; later starts use the + // saved address, whose key is pinned by then. + const auto profile = hooks.profile.u8string(); + // Written by the core or interface thread, read once the core has stopped. + std::mutex mutex; + bool again = false; + std::optional refusal; + const auto result = hooks.run({"--tray", "--profile", std::string(profile.begin(), profile.end()), + "--server", connection.server, "--nickname", connection.nickname}, + connection.password, [&](std::optional code) { + std::lock_guard lock(mutex); + again = true; + refusal = std::move(code); + }); + std::lock_guard lock(mutex); + if (!again) return result; + ask = true; + issue = refusal ? refusalIssue(*refusal) : std::nullopt; + if (issue) { + // Retyping the password is the fix, so the refused one is not offered again. + connection.password.reset(); + previous = std::move(connection); + } else { + // Changing server starts from the saved one, without the old password. + previous.reset(); + } + } +} + +} // namespace monky::light diff --git a/apps/light/src/standalone.hpp b/apps/light/src/standalone.hpp new file mode 100644 index 00000000..f5296100 --- /dev/null +++ b/apps/light/src/standalone.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include "profile_identity.hpp" + +#include +#include +#include +#include +#include + +namespace monky::light { + +// What the connection window asks for. The password is optional and only +// travels to the core in memory, never into the profile. +struct Connection { + std::string server; + std::string nickname; + std::optional password; +}; + +enum class ConnectionIssue { + missingServer, invalidServer, missingNickname, nicknameTooLong, + // Refused by the server; only the window can fix them. + wrongPassword, nicknameTaken, nicknameRefused +}; + +// The issue for a server error code the window can fix, or nullopt. +std::optional refusalIssue(const std::string& code); + +// The window refuses what the core would refuse anyway, so the user fixes it +// there instead of reading a failure in the tray. The server stays the +// authority on nicknames: only emptiness and the shared length limit are +// checked here. +std::optional checkConnection(const Connection& connection); + +// The ws(s):// address to remember: an invitation is reduced to its server, +// because its password must not be stored and its key is pinned on first use. +std::string savedServer(const std::string& server); + +// Platform pieces of an application start, injected so the flow below can be +// exercised without windows, profiles or a network. +struct StandaloneHooks { + std::filesystem::path profile; + // Both throw when the profile cannot be opened, which includes another + // running Monky Light holding its lock. + std::function load; + std::function save; + // Shows the window filled with `current` and the issue of the last attempt. + // Returns nullopt when the user cancels. + std::function(const Connection& current, std::optional issue)> ask; + // A blocking message, for failures that end the start. + std::function profileUnavailable; + // Runs the core with the tray. `askAgain` may be called from any thread + // before the core stops: with the server's refusal code, or with nothing for + // the tray's "change server". + std::function arguments, std::optional password, + std::function refusal)> askAgain)> + run; +}; + +// Opened without a command line: reuses the saved server, or asks for one, and +// runs the core with the tray until it quits for good. +int runStandalone(const StandaloneHooks& hooks); + +} // namespace monky::light diff --git a/apps/light/src/ui/connect.hpp b/apps/light/src/ui/connect.hpp new file mode 100644 index 00000000..c74b4446 --- /dev/null +++ b/apps/light/src/ui/connect.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include "standalone.hpp" +#include "ui/tray_strings.hpp" + +#include +#include +#include + +namespace monky::light::ui { + +// Main thread only, while no core runs: the window has its own modal loop. +// Returns nullopt when the user cancels or closes it. +std::optional askConnection(const Connection& current, std::optional issue, + Language language); + +// A blocking notice for a start that cannot go on, with the system's detail. +void showProfileUnavailable(const std::string& detail, Language language); + +// The profile of an application start, apart from any development profile: +// %LOCALAPPDATA%\Monky Light\Profile or ~/Library/Application Support/Monky Light/Profile. +std::filesystem::path defaultProfileDirectory(); + +// The field an issue is about, so the window can focus it. +enum class Field { server, nickname, password }; + +inline Field fieldFor(std::optional issue, const Connection& current) { + if (!issue) return current.server.empty() ? Field::server : Field::nickname; + switch (*issue) { + case ConnectionIssue::missingServer: + case ConnectionIssue::invalidServer: return Field::server; + case ConnectionIssue::wrongPassword: return Field::password; + case ConnectionIssue::missingNickname: + case ConnectionIssue::nicknameTooLong: + case ConnectionIssue::nicknameTaken: + case ConnectionIssue::nicknameRefused: return Field::nickname; + } + return Field::server; +} + +inline const char* issueText(ConnectionIssue issue, const Strings& text) { + switch (issue) { + case ConnectionIssue::missingServer: return text.missingServer; + case ConnectionIssue::invalidServer: return text.invalidServer; + case ConnectionIssue::missingNickname: return text.missingNickname; + case ConnectionIssue::nicknameTooLong: return text.nicknameTooLong; + case ConnectionIssue::wrongPassword: return text.wrongPassword; + case ConnectionIssue::nicknameTaken: return text.nicknameTaken; + case ConnectionIssue::nicknameRefused: return text.nicknameRefused; + } + return text.invalidServer; +} + +} // namespace monky::light::ui diff --git a/apps/light/src/ui/connect_macos.mm b/apps/light/src/ui/connect_macos.mm new file mode 100644 index 00000000..8fbfbb58 --- /dev/null +++ b/apps/light/src/ui/connect_macos.mm @@ -0,0 +1,101 @@ +#include "ui/connect.hpp" + +#import +#import + +#include +#include +#include + +namespace monky::light::ui { +namespace { + +NSString* text(const std::string& value) { + NSString* result = [NSString stringWithUTF8String:value.c_str()]; + return result ?: @""; +} + +std::string utf8(NSString* value) { + const char* bytes = value ? value.UTF8String : nullptr; + return bytes ? std::string(bytes) : std::string(); +} + +NSTextField* field(NSTextField* control, NSString* placeholder, NSString* value, CGFloat y) { + control.frame = NSMakeRect(0, y, 340, 24); + control.placeholderString = placeholder; + control.stringValue = value; + // Placeholders disappear once typed, so the purpose stays readable to VoiceOver. + control.accessibilityLabel = placeholder; + return control; +} + +// An accessory application is not active on its own; a window it shows would +// open behind the frontmost application. +void bringForward() { + NSApplication* application = [NSApplication sharedApplication]; + [application setActivationPolicy:NSApplicationActivationPolicyAccessory]; + [application activateIgnoringOtherApps:YES]; +} + +} // namespace + +std::optional askConnection(const Connection& current, std::optional issue, + Language language) { + const auto& strings = textFor(language); + @autoreleasepool { + bringForward(); + NSAlert* alert = [[NSAlert alloc] init]; + alert.messageText = text(strings.connectTitle); + alert.informativeText = text(issue ? issueText(*issue, strings) : strings.connectPrompt); + if (issue) alert.alertStyle = NSAlertStyleWarning; + [alert addButtonWithTitle:text(strings.connect)]; + [alert addButtonWithTitle:text(strings.cancel)]; + NSView* fields = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 340, 88)]; + NSTextField* server = field([[NSTextField alloc] init], text(strings.serverField), text(current.server), 64); + NSTextField* nickname = field([[NSTextField alloc] init], text(strings.nicknameField), text(current.nickname), 32); + NSTextField* password = field([[NSSecureTextField alloc] init], text(strings.passwordField), + text(current.password.value_or("")), 0); + [fields addSubview:server]; + [fields addSubview:nickname]; + [fields addSubview:password]; + // Return moves through the fields in reading order, then connects. + server.nextKeyView = nickname; + nickname.nextKeyView = password; + alert.accessoryView = fields; + [alert layout]; + const auto target = fieldFor(issue, current); + alert.window.initialFirstResponder = target == Field::password ? password + : target == Field::nickname ? nickname + : server; + if ([alert runModal] != NSAlertFirstButtonReturn) return std::nullopt; + auto secret = utf8(password.stringValue); + return Connection{utf8(server.stringValue), utf8(nickname.stringValue), + secret.empty() ? std::nullopt : std::optional(std::move(secret))}; + } +} + +void showProfileUnavailable(const std::string& detail, Language language) { + const auto& strings = textFor(language); + @autoreleasepool { + bringForward(); + NSAlert* alert = [[NSAlert alloc] init]; + alert.alertStyle = NSAlertStyleWarning; + alert.messageText = text(strings.application); + alert.informativeText = text(std::string(strings.profileUnavailable) + "\n\n" + detail); + [alert runModal]; + } +} + +std::filesystem::path defaultProfileDirectory() { + @autoreleasepool { + NSArray* folders = [[NSFileManager defaultManager] URLsForDirectory:NSApplicationSupportDirectory + inDomains:NSUserDomainMask]; + NSURL* folder = folders.firstObject; + if (!folder || !folder.fileSystemRepresentation) { + throw std::runtime_error("Could not find the Application Support folder"); + } + return std::filesystem::path(folder.fileSystemRepresentation) / "Monky Light" / "Profile"; + } +} + +} // namespace monky::light::ui diff --git a/apps/light/src/ui/connect_windows.cpp b/apps/light/src/ui/connect_windows.cpp new file mode 100644 index 00000000..be0c2358 --- /dev/null +++ b/apps/light/src/ui/connect_windows.cpp @@ -0,0 +1,210 @@ +#include "platform/utf8.hpp" // Defines NOMINMAX and WIN32_LEAN_AND_MEAN before . +#include "ui/connect.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +namespace monky::light::ui { +namespace { + +constexpr wchar_t kClassName[] = L"MonkyLightConnect"; +constexpr int kServer = 101; +constexpr int kNickname = 102; +constexpr int kPassword = 103; + +std::wstring wide(const std::string& value) { + try { + return wideFromUtf8(value); + } catch (const std::exception&) { + return {}; + } +} + +std::string text(HWND control) { + const auto length = GetWindowTextLengthW(control); + if (length <= 0) return {}; + std::wstring value(static_cast(length) + 1, L'\0'); + const auto copied = GetWindowTextW(control, value.data(), length + 1); + value.resize(static_cast(std::max(copied, 0))); + try { + return utf8FromWide(value); + } catch (const std::exception&) { + return {}; + } +} + +// Lives only while the window is open; the class is registered for that time. +class ConnectWindow final { + public: + ConnectWindow(const Connection& current, std::optional issue, const Strings& strings) + : text_(strings) { + NONCLIENTMETRICSW metrics{}; + metrics.cbSize = sizeof(metrics); + if (SystemParametersInfoW(SPI_GETNONCLIENTMETRICS, sizeof(metrics), &metrics, 0)) { + font_ = CreateFontIndirectW(&metrics.lfMessageFont); + } + const auto dpi = static_cast(GetDpiForSystem()); + const auto scale = [dpi](int value) { return MulDiv(value, dpi, 96); }; + instance_ = GetModuleHandleW(nullptr); + WNDCLASSEXW description{}; + description.cbSize = sizeof(description); + description.lpfnWndProc = &ConnectWindow::proc; + description.hInstance = instance_; + // IDC_ARROW is an ANSI resource name in this build, which defines no UNICODE. + description.hCursor = LoadCursorA(nullptr, IDC_ARROW); + description.hbrBackground = reinterpret_cast(COLOR_BTNFACE + 1); + description.lpszClassName = kClassName; + if (!RegisterClassExW(&description)) throw std::runtime_error("Could not register the connection window"); + const int width = scale(420); + const int margin = scale(14); + const int field = scale(24); + const int label = scale(18); + const int inner = width - 2 * margin; + RECT frame{0, 0, width, scale(330)}; + const auto style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU; + AdjustWindowRectEx(&frame, style, FALSE, WS_EX_DLGMODALFRAME); + const auto outerWidth = frame.right - frame.left; + const auto outerHeight = frame.bottom - frame.top; + window_ = CreateWindowExW(WS_EX_DLGMODALFRAME, kClassName, wide(text_.connectTitle).c_str(), style, + (GetSystemMetrics(SM_CXSCREEN) - outerWidth) / 2, + (GetSystemMetrics(SM_CYSCREEN) - outerHeight) / 2, outerWidth, outerHeight, + nullptr, nullptr, instance_, this); + if (!window_) { + UnregisterClassW(kClassName, instance_); + throw std::runtime_error("Could not create the connection window"); + } + int y = margin; + add(L"STATIC", wide(text_.connectPrompt), WS_VISIBLE | WS_CHILD, margin, y, inner, scale(36), 0); + y += scale(42); + add(L"STATIC", wide(text_.serverField), WS_VISIBLE | WS_CHILD, margin, y, inner, label, 0); + y += label; + server_ = add(L"EDIT", wide(current.server), WS_VISIBLE | WS_CHILD | WS_TABSTOP | WS_BORDER | ES_AUTOHSCROLL, + margin, y, inner, field, kServer); + y += field + scale(10); + add(L"STATIC", wide(text_.nicknameField), WS_VISIBLE | WS_CHILD, margin, y, inner, label, 0); + y += label; + nickname_ = add(L"EDIT", wide(current.nickname), + WS_VISIBLE | WS_CHILD | WS_TABSTOP | WS_BORDER | ES_AUTOHSCROLL, margin, y, inner, field, + kNickname); + y += field + scale(10); + add(L"STATIC", wide(text_.passwordField), WS_VISIBLE | WS_CHILD, margin, y, inner, label, 0); + y += label; + password_ = add(L"EDIT", wide(current.password.value_or("")), + WS_VISIBLE | WS_CHILD | WS_TABSTOP | WS_BORDER | ES_AUTOHSCROLL | ES_PASSWORD, margin, y, + inner, field, kPassword); + y += field + scale(8); + // The reason the last attempt was refused, read by screen readers as text. + add(L"STATIC", issue ? wide(issueText(*issue, text_)) : std::wstring(), WS_VISIBLE | WS_CHILD, margin, y, inner, + scale(36), 0); + const int button = scale(96); + const int height = scale(28); + const int bottom = scale(330) - margin - height; + add(L"BUTTON", wide(text_.cancel), WS_VISIBLE | WS_CHILD | WS_TABSTOP | BS_PUSHBUTTON, + width - margin - button, bottom, button, height, IDCANCEL); + add(L"BUTTON", wide(text_.connect), WS_VISIBLE | WS_CHILD | WS_TABSTOP | BS_DEFPUSHBUTTON, + width - margin - 2 * button - scale(8), bottom, button, height, IDOK); + // Focus where the fix is: the field the issue is about, else the first to fill. + const auto target = fieldFor(issue, current); + const auto focus = target == Field::password ? password_ : target == Field::nickname ? nickname_ : server_; + ShowWindow(window_, SW_SHOWNORMAL); + SetForegroundWindow(window_); + SetFocus(focus); + SendMessageW(focus, EM_SETSEL, 0, -1); + } + + ~ConnectWindow() { + if (window_) DestroyWindow(window_); + UnregisterClassW(kClassName, instance_); + if (font_) DeleteObject(font_); + } + + ConnectWindow(const ConnectWindow&) = delete; + ConnectWindow& operator=(const ConnectWindow&) = delete; + + std::optional run() { + MSG message{}; + while (!done_ && GetMessageW(&message, nullptr, 0, 0) > 0) { + // Tab moves between fields, Enter connects and Escape cancels. + if (IsDialogMessageW(window_, &message)) continue; + TranslateMessage(&message); + DispatchMessageW(&message); + } + if (!accepted_) return std::nullopt; + auto password = text(password_); + return Connection{text(server_), text(nickname_), + password.empty() ? std::nullopt : std::optional(std::move(password))}; + } + + private: + HWND add(const wchar_t* type, const std::wstring& label, DWORD style, int x, int y, int width, int height, + int identifier) { + const auto control = CreateWindowExW(0, type, label.c_str(), style, x, y, width, height, window_, + reinterpret_cast(static_cast(identifier)), instance_, + nullptr); + if (control && font_) SendMessageW(control, WM_SETFONT, reinterpret_cast(font_), TRUE); + return control; + } + + static LRESULT CALLBACK proc(HWND window, UINT message, WPARAM first, LPARAM second) { + if (message == WM_NCCREATE) { + const auto* creation = reinterpret_cast(second); + SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast(creation->lpCreateParams)); + } + auto* self = reinterpret_cast(GetWindowLongPtrW(window, GWLP_USERDATA)); + if (self) { + if (message == WM_COMMAND && (LOWORD(first) == IDOK || LOWORD(first) == IDCANCEL)) { + self->accepted_ = LOWORD(first) == IDOK; + self->done_ = true; + return 0; + } + if (message == WM_CLOSE) { + self->done_ = true; + return 0; + } + } + return DefWindowProcW(window, message, first, second); + } + + const Strings& text_; + HINSTANCE instance_ = nullptr; + HWND window_ = nullptr; + HWND server_ = nullptr; + HWND nickname_ = nullptr; + HWND password_ = nullptr; + HFONT font_ = nullptr; + bool done_ = false; + bool accepted_ = false; +}; + +} // namespace + +std::optional askConnection(const Connection& current, std::optional issue, + Language language) { + ConnectWindow window(current, issue, textFor(language)); + return window.run(); +} + +void showProfileUnavailable(const std::string& detail, Language language) { + const auto& text = textFor(language); + const auto message = wide(std::string(text.profileUnavailable) + "\n\n" + detail); + MessageBoxW(nullptr, message.c_str(), wide(text.application).c_str(), MB_OK | MB_ICONWARNING | MB_SETFOREGROUND); +} + +std::filesystem::path defaultProfileDirectory() { + PWSTR folder = nullptr; + if (FAILED(SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_CREATE, nullptr, &folder))) { + if (folder) CoTaskMemFree(folder); + throw std::runtime_error("Could not find the local application data folder"); + } + std::filesystem::path result(folder); + CoTaskMemFree(folder); + return result / L"Monky Light" / L"Profile"; +} + +} // namespace monky::light::ui diff --git a/apps/light/src/ui/tray_controller.hpp b/apps/light/src/ui/tray_controller.hpp index 2c555a0b..e8e46fbc 100644 --- a/apps/light/src/ui/tray_controller.hpp +++ b/apps/light/src/ui/tray_controller.hpp @@ -37,7 +37,9 @@ class TrayController final { // Interface thread. Presentation presentation() const { std::lock_guard lock(mutex_); - return model_.presentation(); + auto result = model_.presentation(); + result.canSwitchServer = static_cast(core_.switchServer); + return result; } std::optional takeAlert() { @@ -100,6 +102,11 @@ class TrayController final { void quit() { core_.quit(); } + // Stops this core; the application start then shows the connection window. + void switchServer() { + if (core_.switchServer) core_.switchServer(); + } + private: void selectDevice(const char* command, const std::string& deviceId) { core_.command(Json{{"command", command}, diff --git a/apps/light/src/ui/tray_macos.mm b/apps/light/src/ui/tray_macos.mm index 290faf2e..9df9a2a9 100644 --- a/apps/light/src/ui/tray_macos.mm +++ b/apps/light/src/ui/tray_macos.mm @@ -28,6 +28,7 @@ constexpr NSInteger kOutput = 8; constexpr NSInteger kPushToTalk = 9; constexpr NSInteger kVolume = 10; +constexpr NSInteger kSwitchServer = 11; // Device-dependent modifier bits (NX_DEVICER*KEYMASK): a right-hand modifier // arrives as flagsChanged, and only these bits tell it from the left one. @@ -171,6 +172,7 @@ void build(NSMenu* menu) { submenu(menu, text_.audioDevices, devices, true); [menu addItem:[NSMenuItem separatorItem]]; add(menu, text_.reconnectNow, kReconnect, true, false, nil); + if (view.canSwitchServer) add(menu, text_.switchServer, kSwitchServer, true, false, nil); add(menu, text_.quit, kQuit, true, false, nil); } @@ -189,6 +191,7 @@ void activate(NSMenuItem* item) { case kDeafen: controller_.setDeafened(wanted); break; case kReconnect: controller_.reconnect(); break; case kPushToTalk: controller_.setPushToTalk(wanted); break; + case kSwitchServer: controller_.switchServer(); break; case kJoin: controller_.join(identifier); break; case kInput: controller_.selectInput(identifier); break; case kOutput: controller_.selectOutput(identifier); break; diff --git a/apps/light/src/ui/tray_model.hpp b/apps/light/src/ui/tray_model.hpp index 467f1b5a..62324e35 100644 --- a/apps/light/src/ui/tray_model.hpp +++ b/apps/light/src/ui/tray_model.hpp @@ -65,6 +65,8 @@ struct Presentation { // global hook costs nothing when it is not installed. std::optional hotkey; std::vector people; + // Only when opened as an application, where the window can ask again. + bool canSwitchServer = false; }; // Rebuilds the interface state from the events the console consumer already diff --git a/apps/light/src/ui/tray_strings.hpp b/apps/light/src/ui/tray_strings.hpp index 223011e1..4c799408 100644 --- a/apps/light/src/ui/tray_strings.hpp +++ b/apps/light/src/ui/tray_strings.hpp @@ -52,6 +52,22 @@ struct Strings { const char* volume; // macOS only lets a trusted application see keys pressed in other applications. const char* accessibilityRequired; + const char* switchServer; + const char* connectTitle; + const char* connectPrompt; + const char* serverField; + const char* nicknameField; + const char* passwordField; + const char* connect; + const char* cancel; + const char* missingServer; + const char* invalidServer; + const char* missingNickname; + const char* nicknameTooLong; + const char* wrongPassword; + const char* nicknameTaken; + const char* nicknameRefused; + const char* profileUnavailable; }; inline constexpr Strings kEnglish{ @@ -95,6 +111,22 @@ inline constexpr Strings kEnglish{ "speaking", "Volume", "Push-to-talk cannot see the key yet. Allow Monky Light in System Settings › Privacy & Security › Accessibility.", + "Change server…", + "Connect to a server", + "Paste the invitation link someone sent you, or the server address.", + "Invitation link or server address", + "Nickname", + "Password (only if the server asks)", + "Connect", + "Cancel", + "Paste an invitation link or a server address.", + "This is not an invitation link nor a ws:// or wss:// address.", + "Choose a nickname.", + "The nickname can have at most 32 characters.", + "The server refused the password. Type the password of this server.", + "Someone on this server already uses this nickname. Choose another one.", + "The server did not accept this nickname. Choose another one.", + "Monky Light could not open its profile. It may already be running: look for its icon near the clock.", }; inline constexpr Strings kPortuguese{ @@ -138,6 +170,22 @@ inline constexpr Strings kPortuguese{ "falando", "Volume", "O apertar para falar ainda não vê a tecla. Permita o Monky Light em Ajustes do Sistema › Privacidade e Segurança › Acessibilidade.", + "Trocar de servidor…", + "Conectar a um servidor", + "Cole o link de convite que te mandaram, ou o endereço do servidor.", + "Link de convite ou endereço do servidor", + "Apelido", + "Senha (só se o servidor pedir)", + "Conectar", + "Cancelar", + "Cole um link de convite ou o endereço de um servidor.", + "Isto não é um link de convite nem um endereço ws:// ou wss://.", + "Escolha um apelido.", + "O apelido pode ter no máximo 32 caracteres.", + "O servidor recusou a senha. Digite a senha deste servidor.", + "Alguém neste servidor já usa este apelido. Escolha outro.", + "O servidor não aceitou este apelido. Escolha outro.", + "O Monky Light não conseguiu abrir o perfil. Ele talvez já esteja aberto: procure o ícone perto do relógio.", }; inline constexpr const Strings& textFor(Language language) { diff --git a/apps/light/src/ui/tray_windows.cpp b/apps/light/src/ui/tray_windows.cpp index 2fa5de14..13d2ce5b 100644 --- a/apps/light/src/ui/tray_windows.cpp +++ b/apps/light/src/ui/tray_windows.cpp @@ -30,6 +30,7 @@ constexpr UINT kMute = 3; constexpr UINT kDeafen = 4; constexpr UINT kReconnect = 5; constexpr UINT kPushToTalk = 6; +constexpr UINT kSwitchServer = 7; constexpr UINT kRange = 0x0400; constexpr UINT kChannels = 0x1000; constexpr UINT kInputs = kChannels + kRange; @@ -432,6 +433,7 @@ class WindowsTray final : public CoreInterface { } separator(menu); append(menu, kReconnect, text_.reconnectNow, false, true, false); + if (view.canSwitchServer) append(menu, kSwitchServer, text_.switchServer, false, true, false); append(menu, kQuit, text_.quit, false, true, false); POINT cursor{}; GetCursorPos(&cursor); @@ -455,6 +457,7 @@ class WindowsTray final : public CoreInterface { else if (identifier == kDeafen) controller_.setDeafened(!view.deafenChecked); else if (identifier == kReconnect) controller_.reconnect(); else if (identifier == kPushToTalk) controller_.setPushToTalk(!view.pushToTalkChecked); + else if (identifier == kSwitchServer) controller_.switchServer(); else if (identifier >= kPeople) { const auto person = static_cast((identifier - kPeople) / kPresets); const auto preset = static_cast((identifier - kPeople) % kPresets); diff --git a/apps/light/test/platform_components.test.cjs b/apps/light/test/platform_components.test.cjs index 5a013b48..bb1f211f 100644 --- a/apps/light/test/platform_components.test.cjs +++ b/apps/light/test/platform_components.test.cjs @@ -32,6 +32,10 @@ test('native invitation links decode like the shared parser', () => { run('monky-light-invite-test', []); }); +test('opening without a command line reuses or asks for the server and never stores a password', () => { + run('monky-light-standalone-test', []); +}); + test('native application loop waits for events and enforces deadlines and queue bounds', () => { run('monky-light-loop-test', []); }); diff --git a/apps/light/test/profile_identity_test.cpp b/apps/light/test/profile_identity_test.cpp index 47fea23a..f472df57 100644 --- a/apps/light/test/profile_identity_test.cpp +++ b/apps/light/test/profile_identity_test.cpp @@ -404,7 +404,8 @@ void settingsBesideIdentity(const fs::path& root) { "Missing settings did not yield defaults"); identity.saveSettings({std::string("input-\xc3\xa1"), std::nullopt}); identity.saveSettings({std::string("input-\xc3\xa1"), std::string("output"), true, - {{"user:device-a", 0}, {"user:device-b", 200}}, std::string("RightControl")}); + {{"user:device-a", 0}, {"user:device-b", 200}}, std::string("RightControl"), + std::string("wss://casa.example:443"), std::string("Ana \xc3\xa1")}); require(!fs::exists(profile.path / "monky-light-settings.json.pending"), "Settings write left its marker"); requireFailure([&] { identity.saveSettings({std::string(), std::nullopt}); }, "An empty device ID was saved"); @@ -426,6 +427,8 @@ void settingsBesideIdentity(const fs::path& root) { "Unbounded participant volumes were saved"); requireFailure([&] { identity.saveSettings({std::nullopt, std::nullopt, false, {}, std::string("CapsLock")}); }, "An unknown push-to-talk key was saved"); + requireFailure([&] { identity.saveSettings({std::nullopt, std::nullopt, false, {}, std::nullopt, std::string()}); }, + "An empty saved server was stored"); } const auto saved = readFile(settingsPath); // An interrupted settings write is not identity state and must not block the profile. @@ -437,7 +440,9 @@ void settingsBesideIdentity(const fs::path& root) { require(loaded.inputDeviceId == std::optional("input-\xc3\xa1") && loaded.outputDeviceId == std::optional("output") && loaded.pushToTalk && loaded.userVolumes == std::map{{"user:device-a", 0}, {"user:device-b", 200}} && - loaded.pushToTalkKey == std::optional("RightControl"), + loaded.pushToTalkKey == std::optional("RightControl") && + loaded.server == std::optional("wss://casa.example:443") && + loaded.nickname == std::optional("Ana \xc3\xa1"), "Settings did not round-trip"); identity.saveSettings({std::nullopt, std::string("output")}); require(!fs::exists(profile.path / "monky-light-settings.json.pending"), @@ -463,7 +468,9 @@ void settingsBesideIdentity(const fs::path& root) { "{\"format\":\"monky-light-settings\",\"version\":1,\"userVolumes\":{\"s\":{}}}", "{\"format\":\"monky-light-settings\",\"version\":1,\"other\":{}}", "{\"format\":\"monky-light-settings\",\"version\":1,\"pushToTalkKey\":\"Esc\"}", - "{\"format\":\"monky-light-settings\",\"version\":1,\"pushToTalkKey\":13}"}) { + "{\"format\":\"monky-light-settings\",\"version\":1,\"pushToTalkKey\":13}", + "{\"format\":\"monky-light-settings\",\"version\":1,\"server\":\"\"}", + "{\"format\":\"monky-light-settings\",\"version\":1,\"nickname\":7}"}) { writeFile(settingsPath, invalid); ProfileIdentity identity(profile.path); require(identity.publicKeyHex() == publicKey, "Invalid settings affected identity validation"); diff --git a/apps/light/test/standalone_test.cpp b/apps/light/test/standalone_test.cpp new file mode 100644 index 00000000..4bd5cc09 --- /dev/null +++ b/apps/light/test/standalone_test.cpp @@ -0,0 +1,200 @@ +#include "standalone.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using monky::light::Connection; +using monky::light::ConnectionIssue; +using monky::light::ProfileSettings; +using monky::light::StandaloneHooks; + +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +// The profile, the window and the core, recorded instead of shown or started. +struct Fake { + std::optional stored = ProfileSettings{}; + std::vector saves; + std::deque> answers; + std::vector>> asked; + std::vector unavailable; + struct Run { + std::vector arguments; + std::optional password; + }; + std::vector runs; + // What each run asks before quitting: a server refusal code, or nothing for + // "change server". Runs beyond the list just quit. + std::deque> again; + + StandaloneHooks hooks() { + StandaloneHooks result; + result.profile = std::filesystem::path(u8"/perfil/Monky Light/Profile-á"); + result.load = [this](const std::filesystem::path&) { + if (!stored) throw std::runtime_error("profile locked by another Monky Light"); + return *stored; + }; + result.save = [this](const std::filesystem::path&, const ProfileSettings& settings) { + saves.push_back(settings); + stored = settings; + }; + result.ask = [this](const Connection& current, std::optional issue) { + asked.push_back({current, issue}); + if (answers.empty()) throw std::runtime_error("The window was asked more often than expected"); + auto answer = std::move(answers.front()); + answers.pop_front(); + return answer; + }; + result.profileUnavailable = [this](const std::string& detail) { unavailable.push_back(detail); }; + result.run = [this](std::vector arguments, std::optional password, + std::function)> askAgain) { + runs.push_back({std::move(arguments), std::move(password)}); + if (again.empty()) return 0; + askAgain(std::move(again.front())); + again.pop_front(); + return 1; + }; + return result; + } +}; + +std::string argument(const std::vector& arguments, const char* flag) { + for (std::size_t index = 0; index + 1 < arguments.size(); ++index) { + if (arguments[index] == flag) return arguments[index + 1]; + } + throw std::runtime_error("A core argument is missing"); +} + +void firstStartAsksAndRemembersWithoutThePassword() { + Fake fake; + fake.answers.push_back(Connection{"", "Ana", std::nullopt}); + fake.answers.push_back(Connection{"monky://#~Aw", " ", std::nullopt}); + fake.answers.push_back(Connection{" monky://#~Aw ", " Ana ", std::string("segredo")}); + require(monky::light::runStandalone(fake.hooks()) == 0, "A normal quit must end with success"); + require(fake.asked.size() == 3, "Each refused answer must reopen the window"); + require(!fake.asked[0].second, "The first window shows no issue"); + require(fake.asked[1].second == ConnectionIssue::missingServer, "An empty server must be explained"); + require(fake.asked[2].second == ConnectionIssue::missingNickname, "A blank nickname must be explained"); + require(fake.asked[2].first.server == "monky://#~Aw", "A refused answer must stay in the window"); + require(fake.saves.size() == 1, "Only an accepted connection is saved"); + require(fake.saves[0].server == std::optional("ws://localhost:3000"), + "An invitation must be saved as its server address only"); + require(fake.saves[0].nickname == std::optional("Ana"), "The nickname is saved trimmed"); + require(fake.runs.size() == 1, "The core runs once"); + const auto& run = fake.runs[0]; + require(argument(run.arguments, "--server") == "monky://#~Aw", + "The first start passes the invitation, so its key and password are used"); + require(argument(run.arguments, "--nickname") == "Ana", "The core gets the trimmed nickname"); + const auto profile = fake.hooks().profile.u8string(); + require(argument(run.arguments, "--profile") == std::string(profile.begin(), profile.end()), + "The profile path must reach the core as UTF-8"); + require(run.arguments.front() == "--tray", "Without a console the tray is the only interface"); + require(run.password == std::optional("segredo"), "The typed password reaches the core in memory"); +} + +void laterStartsReuseTheSavedServer() { + Fake fake; + ProfileSettings saved; + saved.server = "ws://10.0.0.2:8080"; + saved.nickname = "Bia"; + fake.stored = saved; + require(monky::light::runStandalone(fake.hooks()) == 0, "A saved connection must start directly"); + require(fake.asked.empty() && fake.saves.empty(), "A saved connection must not open the window"); + require(argument(fake.runs.at(0).arguments, "--server") == "ws://10.0.0.2:8080", "The saved server is used"); + require(!fake.runs.at(0).password, "No password is ever read back from the profile"); +} + +void changingServerReopensTheWindowWithoutTheOldPassword() { + Fake fake; + fake.again.push_back(std::nullopt); + fake.answers.push_back(Connection{"ws://a.example:1", "Ana", std::string("velha")}); + fake.answers.push_back(Connection{"wss://b.example:443", "Ana", std::nullopt}); + require(monky::light::runStandalone(fake.hooks()) == 0, "Changing server must not end the application"); + require(fake.runs.size() == 2, "The core must start again on the new server"); + require(fake.asked.size() == 2 && fake.asked[1].first.server == "ws://a.example:1", + "The window reopens filled with the current server"); + require(!fake.asked[1].first.password, "The previous password is never offered again"); + require(argument(fake.runs[1].arguments, "--server") == "wss://b.example:443", "The new server is used"); + require(fake.stored->server == std::optional("wss://b.example:443"), "The new server is saved"); +} + +void aRefusedPasswordReopensTheWindowOnThatField() { + Fake fake; + ProfileSettings saved; + saved.server = "ws://casa.example:8080"; + saved.nickname = "Ana"; + fake.stored = saved; + fake.again.push_back(std::string("AUTH_INVALID_PASSWORD")); + fake.again.push_back(std::string("NICKNAME_ALREADY_EXISTS")); + fake.answers.push_back(Connection{"ws://casa.example:8080", "Ana", std::string("certa")}); + fake.answers.push_back(Connection{"ws://casa.example:8080", "Ana 2", std::string("certa")}); + require(monky::light::runStandalone(fake.hooks()) == 0, "A fixed connection must end normally"); + require(fake.runs.size() == 3 && !fake.runs[0].password, "The saved connection starts without a password"); + require(fake.asked.size() == 2 && fake.asked[0].second == ConnectionIssue::wrongPassword, + "A refused password must reopen the window with the reason"); + require(fake.asked[0].first.server == "ws://casa.example:8080" && !fake.asked[0].first.password, + "The window keeps the server and does not offer the refused password"); + require(fake.runs[1].password == std::optional("certa"), "The new password is used"); + require(fake.asked[1].second == ConnectionIssue::nicknameTaken && fake.asked[1].first.nickname == "Ana", + "A taken nickname must be explained with the typed nickname kept"); + require(argument(fake.runs[2].arguments, "--nickname") == "Ana 2", "The new nickname is used"); + require(fake.stored->nickname == std::optional("Ana 2"), "The accepted nickname is saved"); + require(monky::light::refusalIssue("AUTH_FAILED") == std::nullopt, + "Only refusals the window can fix reopen it"); +} + +void cancellingOrALockedProfileEndsTheStart() { + Fake cancelled; + cancelled.answers.push_back(std::nullopt); + require(monky::light::runStandalone(cancelled.hooks()) == 0 && cancelled.runs.empty(), + "Cancelling the window quits without starting the core"); + Fake locked; + locked.stored.reset(); + require(monky::light::runStandalone(locked.hooks()) == 1 && locked.runs.empty() && locked.unavailable.size() == 1, + "A profile held by another Light must be reported, not replaced"); +} + +void connectionsAreCheckedLikeTheCore() { + using monky::light::checkConnection; + require(checkConnection({"http://host:1", "Ana", std::nullopt}) == ConnectionIssue::invalidServer, + "Only ws:// and wss:// addresses are servers"); + require(checkConnection({"monky://#~not-an-invite", "Ana", std::nullopt}) == ConnectionIssue::invalidServer, + "A broken invitation must be refused in the window"); + require(!checkConnection({"wss://host:443", "Ana", std::nullopt}), "A secure address is accepted"); + require(!checkConnection({"ws://host:1", std::string(32, 'a'), std::nullopt}), "32 units is the limit"); + require(checkConnection({"ws://host:1", std::string(33, 'a'), std::nullopt}) == ConnectionIssue::nicknameTooLong, + "Longer nicknames are refused like the server would"); + // An emoji is one code point but two UTF-16 units, as the server counts it. + std::string emojis; + for (int index = 0; index < 16; ++index) emojis += "\xF0\x9F\x90\x92"; + require(!checkConnection({"ws://host:1", emojis, std::nullopt}), "16 emoji are 32 units"); + require(checkConnection({"ws://host:1", emojis + "a", std::nullopt}) == ConnectionIssue::nicknameTooLong, + "Emoji count as two units each"); +} + +} // namespace + +int main() { + try { + firstStartAsksAndRemembersWithoutThePassword(); + laterStartsReuseTheSavedServer(); + changingServerReopensTheWindowWithoutTheOldPassword(); + aRefusedPasswordReopensTheWindowOnThatField(); + cancellingOrALockedProfileEndsTheStart(); + connectionsAreCheckedLikeTheCore(); + std::cout << "Standalone start scenarios passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/apps/light/test/tray_model_test.cpp b/apps/light/test/tray_model_test.cpp index 17a88015..53dd9b2e 100644 --- a/apps/light/test/tray_model_test.cpp +++ b/apps/light/test/tray_model_test.cpp @@ -430,6 +430,17 @@ void theControllerGatesPushToTalk() { "A volume row must carry its session and preset"); } +void changingServerExistsOnlyForAnApplicationStart() { + TrayController console({[](Json) {}, [] {}}, Language::english); + require(!console.presentation().canSwitchServer, "A command-line start has no window to ask again"); + console.switchServer(); + bool switched = false; + TrayController application({[](Json) {}, [] {}, [&] { switched = true; }}, Language::english); + require(application.presentation().canSwitchServer, "An application start offers to change server"); + application.switchServer(); + require(switched, "Changing server must reach the application start"); +} + void pushToTalkKeysAreNamedForBothPlatforms() { using monky::light::parsePushToTalkKey; require(!parsePushToTalkKey("CapsLock") && !parsePushToTalkKey("f13") && !parsePushToTalkKey(""), @@ -467,6 +478,7 @@ int main() { pushToTalkWatchesTheKeyOnlyWhileItCanTransmit(); peopleCarrySpeechAndVolume(); theControllerGatesPushToTalk(); + changingServerExistsOnlyForAnApplicationStart(); pushToTalkKeysAreNamedForBothPlatforms(); malformedEventsAreIgnored(); std::cout << "Tray interface scenarios passed\n"; diff --git a/release-notes/658-light-standalone.json b/release-notes/658-light-standalone.json new file mode 100644 index 00000000..00540374 --- /dev/null +++ b/release-notes/658-light-standalone.json @@ -0,0 +1,5 @@ +{ + "group": "novidades", + "pt-BR": "O Monky Light agora abre com duplo clique, sem linha de comando: na primeira vez uma janela pede o link de convite e o apelido, e nas próximas ele conecta direto ao último servidor. A bandeja ganhou Trocar de servidor.", + "en": "Monky Light now opens with a double click, without a command line: the first time a window asks for the invitation link and nickname, and later it connects straight to the last server. The tray gained Change server." +} From 599ffe2951286b6c73ba23a237aa81ff0914b48d Mon Sep 17 00:00:00 2001 From: JelielSantos <70759312+JelielSantos@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:39:27 -0300 Subject: [PATCH 2/2] feat(light): iniciar o Monky Light com o sistema, pela bandeja (#895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item `Iniciar com o sistema`, desligado por padrão, só quando o Light foi aberto como aplicativo. Registra este executável sem argumentos para abrir no login: valor em HKCU\...\CurrentVersion\Run no Windows e LaunchAgent em ~/Library/LaunchAgents no macOS (desde o macOS 12, sem SMAppService). O item lê o sistema ao abrir o menu e só aparece marcado quando o registro aponta para este mesmo executável. Não é oferecido com MONKY_LIGHT_PROFILE, para um build de desenvolvimento nunca se registrar com o perfil real. Uma recusa do sistema aparece como alerta. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/light/CMakeLists.txt | 10 +-- apps/light/QA.en.md | 1 + apps/light/QA.md | 1 + apps/light/README.en.md | 13 +++- apps/light/README.md | 13 +++- apps/light/src/main.cpp | 17 ++++- apps/light/src/ui/startup.hpp | 22 ++++++ apps/light/src/ui/startup_macos.mm | 71 +++++++++++++++++++ apps/light/src/ui/startup_windows.cpp | 61 ++++++++++++++++ apps/light/src/ui/tray.hpp | 5 +- apps/light/src/ui/tray_controller.hpp | 27 ++++++- apps/light/src/ui/tray_macos.mm | 14 ++-- apps/light/src/ui/tray_model.hpp | 3 + apps/light/src/ui/tray_strings.hpp | 3 + apps/light/src/ui/tray_windows.cpp | 14 ++-- apps/light/test/tray_model_test.cpp | 24 +++++++ .../658-light-start-with-system.json | 5 ++ 17 files changed, 282 insertions(+), 22 deletions(-) create mode 100644 apps/light/src/ui/startup.hpp create mode 100644 apps/light/src/ui/startup_macos.mm create mode 100644 apps/light/src/ui/startup_windows.cpp create mode 100644 release-notes/658-light-start-with-system.json diff --git a/apps/light/CMakeLists.txt b/apps/light/CMakeLists.txt index 89e2f06a..2a66383b 100644 --- a/apps/light/CMakeLists.txt +++ b/apps/light/CMakeLists.txt @@ -233,15 +233,17 @@ if(MONKY_LIGHT_WITH_RTC) RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin" RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_CURRENT_BINARY_DIR}/bin") if(WIN32) - add_library(monky_light_ui STATIC src/ui/tray_windows.cpp src/ui/connect_windows.cpp) - target_link_libraries(monky_light_ui PUBLIC Shell32 User32 Gdi32 Ole32) + add_library(monky_light_ui STATIC src/ui/tray_windows.cpp src/ui/connect_windows.cpp + src/ui/startup_windows.cpp) + target_link_libraries(monky_light_ui PUBLIC Shell32 User32 Gdi32 Ole32 Advapi32) target_compile_definitions(monky_light_ui PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) else() find_library(MONKY_APPKIT_FRAMEWORK AppKit REQUIRED) # AXIsProcessTrusted: push-to-talk needs Accessibility to see keys. find_library(MONKY_APPLICATION_SERVICES_FRAMEWORK ApplicationServices REQUIRED) - add_library(monky_light_ui STATIC src/ui/tray_macos.mm src/ui/connect_macos.mm) - set_source_files_properties(src/ui/tray_macos.mm src/ui/connect_macos.mm PROPERTIES COMPILE_OPTIONS "-fobjc-arc") + add_library(monky_light_ui STATIC src/ui/tray_macos.mm src/ui/connect_macos.mm src/ui/startup_macos.mm) + set_source_files_properties(src/ui/tray_macos.mm src/ui/connect_macos.mm src/ui/startup_macos.mm + PROPERTIES COMPILE_OPTIONS "-fobjc-arc") target_link_libraries(monky_light_ui PUBLIC "${MONKY_APPKIT_FRAMEWORK}" "${MONKY_APPLICATION_SERVICES_FRAMEWORK}") endif() diff --git a/apps/light/QA.en.md b/apps/light/QA.en.md index 79685f75..f702806d 100644 --- a/apps/light/QA.en.md +++ b/apps/light/QA.en.md @@ -156,3 +156,4 @@ available, so the terminal shows what each click did. | 6.37 | `Change server…` in the tray | The Light leaves the current server, the icon disappears, and the window reopens filled with the current server, without a password | | | | 6.38 | Open a second copy with the same profile | Profile unavailable notice; the first copy stays connected | | | | 6.39 | Cancel the window | The Light quits without connecting and leaves no process | | | +| 6.40 | Turn `Start with the system` on, sign out of the system and back in | The Light opens by itself, tray only, and connects to the last server; turning it off removes the registration and it no longer opens | | | diff --git a/apps/light/QA.md b/apps/light/QA.md index 0488299d..3398c35c 100644 --- a/apps/light/QA.md +++ b/apps/light/QA.md @@ -157,3 +157,4 @@ valendo, então o terminal serve para conferir o que cada clique fez. | 6.37 | `Trocar de servidor…` na bandeja | O Light sai do servidor atual, o ícone some e a janela reabre preenchida com o servidor atual, sem senha | | | | 6.38 | Abrir uma segunda cópia com o mesmo perfil | Aviso de perfil indisponível; a primeira cópia continua conectada | | | | 6.39 | Cancelar a janela | O Light encerra sem conectar e sem deixar processo | | | +| 6.40 | Ligar `Iniciar com o sistema`, sair da sessão do sistema e entrar de novo | O Light abre sozinho, só com a bandeja, e conecta ao último servidor; desligar remove o registro e ele não abre mais | | | diff --git a/apps/light/README.en.md b/apps/light/README.en.md index 2d57c952..6accb92f 100644 --- a/apps/light/README.en.md +++ b/apps/light/README.en.md @@ -208,6 +208,15 @@ process. console for an instant, which the Light lets go right away. A console shared with the terminal that started it is kept. +- **Start with the system**, off by default, registers this executable with no + arguments to open at sign-in: a value under + `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` on Windows and a + LaunchAgent `~/Library/LaunchAgents/org.monky.light.plist` on macOS + (effective from the next login; works from macOS 12). The row is checked only + when the registration points at this very executable, and it is not offered + with `MONKY_LIGHT_PROFILE`, so a development build never registers itself + with the real profile. + Any argument keeps the command-line behaviour documented above, even without `--tray`. @@ -239,8 +248,8 @@ rendering between activations. It carries: - `In the call`, with each participant, who is speaking, and the volume, in presets from 0 to 200%; - `Audio devices`, with input, output, and `System default`; -- `Reconnect now`, `Change server…` (only when opened as an application), and - `Quit Monky Light`. +- `Reconnect now`, `Change server…` and `Start with the system` (both only when + opened as an application), and `Quit Monky Light`. In a native menu an exclusive choice (channel, device) uses a radio mark and a toggle uses a check mark: these are the native equivalents of the selectable card diff --git a/apps/light/README.md b/apps/light/README.md index 87b49528..71e25276 100644 --- a/apps/light/README.md +++ b/apps/light/README.md @@ -209,6 +209,15 @@ no mesmo processo. por um instante, que o Light solta logo em seguida. Um console compartilhado com o terminal que o iniciou é mantido. +- **Iniciar com o sistema**, desligado por padrão, registra este executável + sem argumentos para abrir no login: um valor em + `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` no Windows e um + LaunchAgent `~/Library/LaunchAgents/org.monky.light.plist` no macOS (vale a + partir do próximo login; funciona desde o macOS 12). O item só aparece + marcado quando o registro aponta para este mesmo executável, e não é + oferecido com `MONKY_LIGHT_PROFILE`, para um build de desenvolvimento nunca + se registrar com o perfil real. + Qualquer argumento mantém o comportamento de linha de comando documentado acima, inclusive sem `--tray`. @@ -240,8 +249,8 @@ renderizando entre as ativações. Ele traz: - `Na chamada`, com cada participante, quem está falando e o volume, em predefinições de 0 a 200%; - `Dispositivos de áudio`, com entrada, saída e `Padrão do sistema`; -- `Reconectar agora`, `Trocar de servidor…` (só quando aberto como aplicativo) - e `Sair do Monky Light`. +- `Reconectar agora`, `Trocar de servidor…` e `Iniciar com o sistema` (os dois + só quando aberto como aplicativo) e `Sair do Monky Light`. Em menu nativo, escolha exclusiva (canal, dispositivo) usa marca de rádio e liga/desliga usa marca de seleção: são os equivalentes nativos do card diff --git a/apps/light/src/main.cpp b/apps/light/src/main.cpp index c099792b..e13f29ff 100644 --- a/apps/light/src/main.cpp +++ b/apps/light/src/main.cpp @@ -3,6 +3,7 @@ #include "profile_identity.hpp" #include "standalone.hpp" #include "ui/connect.hpp" +#include "ui/startup.hpp" #include "ui/tray.hpp" #include @@ -97,9 +98,14 @@ int runApplication() { if (GetConsoleProcessList(processes, 2) == 1) FreeConsole(); #endif monky::light::StandaloneHooks hooks; + // A build started with a development or QA profile must not register itself + // to open at sign-in, where it would use the real profile instead. + bool developmentProfile = false; try { // Development and QA point this elsewhere, never at the installed profile. - hooks.profile = profileOverride().value_or(ui::defaultProfileDirectory()); + const auto override = profileOverride(); + developmentProfile = override.has_value(); + hooks.profile = override ? *override : ui::defaultProfileDirectory(); // The profile creates only its own folder; the one above is the Light's. std::filesystem::create_directories(hooks.profile.parent_path()); } catch (const std::exception& error) { @@ -123,9 +129,14 @@ int runApplication() { return ui::askConnection(current, issue, language); }; hooks.profileUnavailable = [language](const std::string& detail) { ui::showProfileUnavailable(detail, language); }; - hooks.run = [](std::vector arguments, std::optional password, - std::function)> askAgain) { + hooks.run = [developmentProfile, language](std::vector arguments, std::optional password, + std::function)> askAgain) { auto audio = configuration(); + if (!developmentProfile) { + audio.createInterface = [language](monky::light::CoreCommands core) { + return ui::createTray(std::move(core), language, ui::systemStartup()); + }; + } audio.console = false; audio.password = std::move(password); audio.askAgain = std::move(askAgain); diff --git a/apps/light/src/ui/startup.hpp b/apps/light/src/ui/startup.hpp new file mode 100644 index 00000000..3ed1042c --- /dev/null +++ b/apps/light/src/ui/startup.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace monky::light::ui { + +// Starting with the system, for an application start only. Both functions run +// on the interface thread; `set` throws when the system refuses the change. +struct Startup { + std::function enabled; + std::function set; +}; + +// Registers this executable, with no arguments, to open at sign-in: a value +// under HKCU\...\CurrentVersion\Run on Windows, a LaunchAgent in +// ~/Library/LaunchAgents on macOS. Reported as enabled only when the +// registration points at this very executable, so another copy's entry is +// never shown as ours. +Startup systemStartup(); + +} // namespace monky::light::ui diff --git a/apps/light/src/ui/startup_macos.mm b/apps/light/src/ui/startup_macos.mm new file mode 100644 index 00000000..4a41144e --- /dev/null +++ b/apps/light/src/ui/startup_macos.mm @@ -0,0 +1,71 @@ +#include "ui/startup.hpp" + +#import + +#include + +namespace monky::light::ui { +namespace { + +// A per-user LaunchAgent works from macOS 12; SMAppService needs macOS 13. +NSString* const kLabel = @"org.monky.light"; + +NSURL* agentFile() { + NSURL* library = [[NSFileManager defaultManager] URLsForDirectory:NSLibraryDirectory + inDomains:NSUserDomainMask].firstObject; + if (!library) throw std::runtime_error("Could not find the Library folder"); + return [[library URLByAppendingPathComponent:@"LaunchAgents" isDirectory:YES] + URLByAppendingPathComponent:[kLabel stringByAppendingString:@".plist"]]; +} + +NSString* executable() { + NSString* path = [NSBundle mainBundle].executablePath; + if (!path) throw std::runtime_error("Could not find the Monky Light executable"); + return path; +} + +bool enabled() { + @autoreleasepool { + NSDictionary* agent = [NSDictionary dictionaryWithContentsOfURL:agentFile() error:nil]; + NSArray* arguments = agent[@"ProgramArguments"]; + return [arguments isKindOfClass:[NSArray class]] && arguments.count == 1 && + [arguments.firstObject isEqual:executable()]; + } +} + +void set(bool wanted) { + @autoreleasepool { + NSURL* file = agentFile(); + NSError* error = nil; + if (!wanted) { + if (![[NSFileManager defaultManager] removeItemAtURL:file error:&error] && + !(error.domain == NSCocoaErrorDomain && error.code == NSFileNoSuchFileError)) { + throw std::runtime_error("macOS refused to stop starting Monky Light at login"); + } + return; + } + if (![[NSFileManager defaultManager] createDirectoryAtURL:file.URLByDeletingLastPathComponent + withIntermediateDirectories:YES + attributes:nil + error:&error]) { + throw std::runtime_error("Could not create the LaunchAgents folder"); + } + // Takes effect at the next login; nothing is started now. + NSDictionary* agent = @{ + @"Label" : kLabel, + @"ProgramArguments" : @[ executable() ], + @"RunAtLoad" : @YES, + @"ProcessType" : @"Interactive", + @"LimitLoadToSessionType" : @"Aqua", + }; + if (![agent writeToURL:file error:&error]) { + throw std::runtime_error("macOS refused to start Monky Light at login"); + } + } +} + +} // namespace + +Startup systemStartup() { return {&enabled, &set}; } + +} // namespace monky::light::ui diff --git a/apps/light/src/ui/startup_windows.cpp b/apps/light/src/ui/startup_windows.cpp new file mode 100644 index 00000000..fee9c940 --- /dev/null +++ b/apps/light/src/ui/startup_windows.cpp @@ -0,0 +1,61 @@ +#include "platform/utf8.hpp" // Defines NOMINMAX and WIN32_LEAN_AND_MEAN before . +#include "ui/startup.hpp" + +#include +#include +#include + +namespace monky::light::ui { +namespace { + +constexpr wchar_t kRunKey[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Run"; +constexpr wchar_t kValue[] = L"Monky Light"; + +// The quoted path of this executable and nothing else: no arguments is an +// application start, which reuses the saved server. +std::wstring command() { + std::vector path(MAX_PATH); + for (;;) { + const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast(path.size())); + if (length == 0) throw std::runtime_error("Could not find the Monky Light executable"); + if (length < path.size()) return L"\"" + std::wstring(path.data(), length) + L"\""; + path.resize(path.size() * 2); + } +} + +bool enabled() { + DWORD size = 0; + if (RegGetValueW(HKEY_CURRENT_USER, kRunKey, kValue, RRF_RT_REG_SZ, nullptr, nullptr, &size) != ERROR_SUCCESS || + size == 0) { + return false; + } + std::wstring value(size / sizeof(wchar_t), L'\0'); + if (RegGetValueW(HKEY_CURRENT_USER, kRunKey, kValue, RRF_RT_REG_SZ, nullptr, value.data(), &size) != + ERROR_SUCCESS) { + return false; + } + value.resize(size / sizeof(wchar_t)); + while (!value.empty() && value.back() == L'\0') value.pop_back(); + return CompareStringOrdinal(value.c_str(), -1, command().c_str(), -1, TRUE) == CSTR_EQUAL; +} + +void set(bool wanted) { + if (wanted) { + const auto value = command(); + const auto bytes = static_cast((value.size() + 1) * sizeof(wchar_t)); + if (RegSetKeyValueW(HKEY_CURRENT_USER, kRunKey, kValue, REG_SZ, value.c_str(), bytes) != ERROR_SUCCESS) { + throw std::runtime_error("Windows refused to start Monky Light with the system"); + } + return; + } + const auto result = RegDeleteKeyValueW(HKEY_CURRENT_USER, kRunKey, kValue); + if (result != ERROR_SUCCESS && result != ERROR_FILE_NOT_FOUND) { + throw std::runtime_error("Windows refused to stop starting Monky Light with the system"); + } +} + +} // namespace + +Startup systemStartup() { return {&enabled, &set}; } + +} // namespace monky::light::ui diff --git a/apps/light/src/ui/tray.hpp b/apps/light/src/ui/tray.hpp index 317578f5..ea091308 100644 --- a/apps/light/src/ui/tray.hpp +++ b/apps/light/src/ui/tray.hpp @@ -1,6 +1,7 @@ #pragma once #include "core_commands.hpp" +#include "ui/startup.hpp" #include "ui/tray_strings.hpp" #include @@ -10,7 +11,9 @@ namespace monky::light::ui { // Creates the platform tray. It must be called on the process main thread, // before the core thread starts, because the returned interface is observed from // the core thread. Throws when the platform interface cannot be created. -std::unique_ptr createTray(CoreCommands core, Language language); +// `startup` is offered only to an application start (see systemStartup()). +std::unique_ptr createTray(CoreCommands core, Language language, + std::optional startup = std::nullopt); // The operating system interface language, falling back to English. Language systemLanguage(); diff --git a/apps/light/src/ui/tray_controller.hpp b/apps/light/src/ui/tray_controller.hpp index e8e46fbc..5ff78d35 100644 --- a/apps/light/src/ui/tray_controller.hpp +++ b/apps/light/src/ui/tray_controller.hpp @@ -1,8 +1,10 @@ #pragma once #include "core_commands.hpp" +#include "ui/startup.hpp" #include "ui/tray_model.hpp" +#include #include #include #include @@ -16,8 +18,8 @@ namespace monky::light::ui { // the same command-accepted/command-error answers. class TrayController final { public: - TrayController(CoreCommands core, Language language) - : core_(std::move(core)), model_(language) {} + TrayController(CoreCommands core, Language language, std::optional startup = std::nullopt) + : core_(std::move(core)), model_(language), startup_(std::move(startup)) {} // Core thread. Returns true when the interface must redraw or notify. bool observe(const Json& event) { @@ -39,14 +41,33 @@ class TrayController final { std::lock_guard lock(mutex_); auto result = model_.presentation(); result.canSwitchServer = static_cast(core_.switchServer); + result.startupAvailable = startup_.has_value(); + // Read when the menu is built, so a change made elsewhere is never hidden. + try { + result.startupChecked = startup_ && startup_->enabled(); + } catch (const std::exception&) { + result.startupChecked = false; + } return result; } std::optional takeAlert() { std::lock_guard lock(mutex_); + if (startupFailure_) return std::exchange(startupFailure_, std::nullopt); return model_.takeAlert(); } + // Interface thread. A refusal is shown like any other failure. + void setStartup(bool enabled) { + if (!startup_) return; + try { + startup_->set(enabled); + } catch (const std::exception& error) { + std::lock_guard lock(mutex_); + startupFailure_ = error.what(); + } + } + void join(const std::string& channelId) { core_.command(Json{{"command", "join"}, {"channelId", channelId}}); } @@ -117,6 +138,8 @@ class TrayController final { mutable std::mutex mutex_; TrayModel model_; bool pressed_ = false; + std::optional startup_; + std::optional startupFailure_; }; } // namespace monky::light::ui diff --git a/apps/light/src/ui/tray_macos.mm b/apps/light/src/ui/tray_macos.mm index 9df9a2a9..061c7eec 100644 --- a/apps/light/src/ui/tray_macos.mm +++ b/apps/light/src/ui/tray_macos.mm @@ -29,6 +29,7 @@ constexpr NSInteger kPushToTalk = 9; constexpr NSInteger kVolume = 10; constexpr NSInteger kSwitchServer = 11; +constexpr NSInteger kStartup = 12; // Device-dependent modifier bits (NX_DEVICER*KEYMASK): a right-hand modifier // arrives as flagsChanged, and only these bits tell it from the left one. @@ -62,8 +63,8 @@ - (instancetype)initWithOwner:(void*)owner; class MacTray final : public CoreInterface { public: - MacTray(CoreCommands core, Language language) - : controller_(std::move(core), language), text_(textFor(language)) { + MacTray(CoreCommands core, Language language, std::optional startup) + : controller_(std::move(core), language, std::move(startup)), text_(textFor(language)) { @autoreleasepool { NSApplication* application = [NSApplication sharedApplication]; // An accessory application has no Dock tile and no window; the status item @@ -173,6 +174,7 @@ void build(NSMenu* menu) { [menu addItem:[NSMenuItem separatorItem]]; add(menu, text_.reconnectNow, kReconnect, true, false, nil); if (view.canSwitchServer) add(menu, text_.switchServer, kSwitchServer, true, false, nil); + if (view.startupAvailable) add(menu, text_.startWithSystem, kStartup, true, view.startupChecked, nil); add(menu, text_.quit, kQuit, true, false, nil); } @@ -192,6 +194,10 @@ void activate(NSMenuItem* item) { case kReconnect: controller_.reconnect(); break; case kPushToTalk: controller_.setPushToTalk(wanted); break; case kSwitchServer: controller_.switchServer(); break; + case kStartup: + controller_.setStartup(wanted); + refresh(); // A refusal arrives as an alert. + break; case kJoin: controller_.join(identifier); break; case kInput: controller_.selectInput(identifier); break; case kOutput: controller_.selectOutput(identifier); break; @@ -314,8 +320,8 @@ void unhook() { std::optional alert_; }; -std::unique_ptr createTray(CoreCommands core, Language language) { - return std::make_unique(std::move(core), language); +std::unique_ptr createTray(CoreCommands core, Language language, std::optional startup) { + return std::make_unique(std::move(core), language, std::move(startup)); } Language systemLanguage() { diff --git a/apps/light/src/ui/tray_model.hpp b/apps/light/src/ui/tray_model.hpp index 62324e35..f7488f29 100644 --- a/apps/light/src/ui/tray_model.hpp +++ b/apps/light/src/ui/tray_model.hpp @@ -67,6 +67,9 @@ struct Presentation { std::vector people; // Only when opened as an application, where the window can ask again. bool canSwitchServer = false; + // Same condition, minus development and QA profiles: see systemStartup(). + bool startupAvailable = false; + bool startupChecked = false; }; // Rebuilds the interface state from the events the console consumer already diff --git a/apps/light/src/ui/tray_strings.hpp b/apps/light/src/ui/tray_strings.hpp index 4c799408..21544216 100644 --- a/apps/light/src/ui/tray_strings.hpp +++ b/apps/light/src/ui/tray_strings.hpp @@ -68,6 +68,7 @@ struct Strings { const char* nicknameTaken; const char* nicknameRefused; const char* profileUnavailable; + const char* startWithSystem; }; inline constexpr Strings kEnglish{ @@ -127,6 +128,7 @@ inline constexpr Strings kEnglish{ "Someone on this server already uses this nickname. Choose another one.", "The server did not accept this nickname. Choose another one.", "Monky Light could not open its profile. It may already be running: look for its icon near the clock.", + "Start with the system", }; inline constexpr Strings kPortuguese{ @@ -186,6 +188,7 @@ inline constexpr Strings kPortuguese{ "Alguém neste servidor já usa este apelido. Escolha outro.", "O servidor não aceitou este apelido. Escolha outro.", "O Monky Light não conseguiu abrir o perfil. Ele talvez já esteja aberto: procure o ícone perto do relógio.", + "Iniciar com o sistema", }; inline constexpr const Strings& textFor(Language language) { diff --git a/apps/light/src/ui/tray_windows.cpp b/apps/light/src/ui/tray_windows.cpp index 13d2ce5b..25861e91 100644 --- a/apps/light/src/ui/tray_windows.cpp +++ b/apps/light/src/ui/tray_windows.cpp @@ -31,6 +31,7 @@ constexpr UINT kDeafen = 4; constexpr UINT kReconnect = 5; constexpr UINT kPushToTalk = 6; constexpr UINT kSwitchServer = 7; +constexpr UINT kStartup = 8; constexpr UINT kRange = 0x0400; constexpr UINT kChannels = 0x1000; constexpr UINT kInputs = kChannels + kRange; @@ -139,8 +140,8 @@ void copyInto(WCHAR* destination, std::size_t capacity, const std::string& value class WindowsTray final : public CoreInterface { public: - WindowsTray(CoreCommands core, Language language) - : controller_(std::move(core), language), text_(textFor(language)) { + WindowsTray(CoreCommands core, Language language, std::optional startup) + : controller_(std::move(core), language, std::move(startup)), text_(textFor(language)) { WNDCLASSEXW description{}; description.cbSize = sizeof(description); description.lpfnWndProc = &WindowsTray::proc; @@ -434,6 +435,7 @@ class WindowsTray final : public CoreInterface { separator(menu); append(menu, kReconnect, text_.reconnectNow, false, true, false); if (view.canSwitchServer) append(menu, kSwitchServer, text_.switchServer, false, true, false); + if (view.startupAvailable) append(menu, kStartup, text_.startWithSystem, view.startupChecked, true, false); append(menu, kQuit, text_.quit, false, true, false); POINT cursor{}; GetCursorPos(&cursor); @@ -458,6 +460,10 @@ class WindowsTray final : public CoreInterface { else if (identifier == kReconnect) controller_.reconnect(); else if (identifier == kPushToTalk) controller_.setPushToTalk(!view.pushToTalkChecked); else if (identifier == kSwitchServer) controller_.switchServer(); + else if (identifier == kStartup) { + controller_.setStartup(!view.startupChecked); + refresh(); // A refusal arrives as an alert. + } else if (identifier >= kPeople) { const auto person = static_cast((identifier - kPeople) / kPresets); const auto preset = static_cast((identifier - kPeople) % kPresets); @@ -546,8 +552,8 @@ class WindowsTray final : public CoreInterface { } // namespace -std::unique_ptr createTray(CoreCommands core, Language language) { - return std::make_unique(std::move(core), language); +std::unique_ptr createTray(CoreCommands core, Language language, std::optional startup) { + return std::make_unique(std::move(core), language, std::move(startup)); } Language systemLanguage() { diff --git a/apps/light/test/tray_model_test.cpp b/apps/light/test/tray_model_test.cpp index 53dd9b2e..06a0d1b7 100644 --- a/apps/light/test/tray_model_test.cpp +++ b/apps/light/test/tray_model_test.cpp @@ -441,6 +441,29 @@ void changingServerExistsOnlyForAnApplicationStart() { require(switched, "Changing server must reach the application start"); } +void startingWithTheSystemFollowsTheRegistration() { + TrayController none({[](Json) {}, [] {}}, Language::english); + require(!none.presentation().startupAvailable, "Without a registration the row is not offered"); + bool registered = false; + bool refuse = false; + monky::light::ui::Startup startup{[&] { return registered; }, [&](bool wanted) { + if (refuse) throw std::runtime_error("registry is read-only"); + registered = wanted; + }}; + TrayController controller({[](Json) {}, [] {}}, Language::english, startup); + auto view = controller.presentation(); + require(view.startupAvailable && !view.startupChecked, "An unregistered start is offered unchecked"); + controller.setStartup(true); + require(registered && controller.presentation().startupChecked, "Checking the row registers the start"); + registered = false; + require(!controller.presentation().startupChecked, "The row reads the system each time, not a copy"); + refuse = true; + controller.setStartup(true); + const auto alert = controller.takeAlert(); + require(alert && *alert == "registry is read-only", "A refused registration must be shown"); + require(!controller.presentation().startupChecked, "A refused registration stays unchecked"); +} + void pushToTalkKeysAreNamedForBothPlatforms() { using monky::light::parsePushToTalkKey; require(!parsePushToTalkKey("CapsLock") && !parsePushToTalkKey("f13") && !parsePushToTalkKey(""), @@ -479,6 +502,7 @@ int main() { peopleCarrySpeechAndVolume(); theControllerGatesPushToTalk(); changingServerExistsOnlyForAnApplicationStart(); + startingWithTheSystemFollowsTheRegistration(); pushToTalkKeysAreNamedForBothPlatforms(); malformedEventsAreIgnored(); std::cout << "Tray interface scenarios passed\n"; diff --git a/release-notes/658-light-start-with-system.json b/release-notes/658-light-start-with-system.json new file mode 100644 index 00000000..ebf0ff2b --- /dev/null +++ b/release-notes/658-light-start-with-system.json @@ -0,0 +1,5 @@ +{ + "group": "novidades", + "pt-BR": "A bandeja do Monky Light ganhou Iniciar com o sistema, desligado por padrão: com ele ligado, o Light abre sozinho ao entrar no computador e conecta ao último servidor.", + "en": "The Monky Light tray gained Start with the system, off by default: when on, the Light opens by itself when you sign in and connects to the last server." +}