diff --git a/apps/light/CMakeLists.txt b/apps/light/CMakeLists.txt index ba59f3e8..2a66383b 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,24 @@ 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 + 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) - 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 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() 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 +268,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..f702806d 100644 --- a/apps/light/QA.en.md +++ b/apps/light/QA.en.md @@ -147,3 +147,13 @@ 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 | | | +| 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 de1cce40..3398c35c 100644 --- a/apps/light/QA.md +++ b/apps/light/QA.md @@ -148,3 +148,13 @@ 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 | | | +| 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 f90c5a9d..6accb92f 100644 --- a/apps/light/README.en.md +++ b/apps/light/README.en.md @@ -173,6 +173,53 @@ 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. + +- **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`. + ## Tray interface `--tray` adds the icon to the Windows notification area or the macOS status bar. @@ -201,7 +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` 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 0d6ca148..71e25276 100644 --- a/apps/light/README.md +++ b/apps/light/README.md @@ -174,6 +174,53 @@ 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. + +- **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`. + ## Interface de bandeja `--tray` adiciona o ícone na área de notificação do Windows ou na barra de status @@ -202,7 +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` 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/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..e13f29ff 100644 --- a/apps/light/src/main.cpp +++ b/apps/light/src/main.cpp @@ -1,5 +1,9 @@ #include "headless_application.hpp" #include "platform/utf8.hpp" +#include "profile_identity.hpp" +#include "standalone.hpp" +#include "ui/connect.hpp" +#include "ui/startup.hpp" #include "ui/tray.hpp" #include @@ -9,9 +13,16 @@ #include #endif +#include +#include +#include +#include #include +#include #include +#include #include +#include namespace { @@ -37,7 +48,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 +67,91 @@ 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; + // 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. + 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) { + 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 = [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); + 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/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 2c555a0b..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) { @@ -37,14 +39,35 @@ 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); + 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}}); } @@ -100,6 +123,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}, @@ -110,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 290faf2e..061c7eec 100644 --- a/apps/light/src/ui/tray_macos.mm +++ b/apps/light/src/ui/tray_macos.mm @@ -28,6 +28,8 @@ constexpr NSInteger kOutput = 8; 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. @@ -61,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 @@ -171,6 +173,8 @@ 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); + if (view.startupAvailable) add(menu, text_.startWithSystem, kStartup, true, view.startupChecked, nil); add(menu, text_.quit, kQuit, true, false, nil); } @@ -189,6 +193,11 @@ 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 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; @@ -311,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 467f1b5a..f7488f29 100644 --- a/apps/light/src/ui/tray_model.hpp +++ b/apps/light/src/ui/tray_model.hpp @@ -65,6 +65,11 @@ 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; + // 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 223011e1..21544216 100644 --- a/apps/light/src/ui/tray_strings.hpp +++ b/apps/light/src/ui/tray_strings.hpp @@ -52,6 +52,23 @@ 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; + const char* startWithSystem; }; inline constexpr Strings kEnglish{ @@ -95,6 +112,23 @@ 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.", + "Start with the system", }; inline constexpr Strings kPortuguese{ @@ -138,6 +172,23 @@ 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.", + "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 2fa5de14..25861e91 100644 --- a/apps/light/src/ui/tray_windows.cpp +++ b/apps/light/src/ui/tray_windows.cpp @@ -30,6 +30,8 @@ constexpr UINT kMute = 3; 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; @@ -138,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; @@ -432,6 +434,8 @@ 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); @@ -455,6 +459,11 @@ 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 == 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); @@ -543,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/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..06a0d1b7 100644 --- a/apps/light/test/tray_model_test.cpp +++ b/apps/light/test/tray_model_test.cpp @@ -430,6 +430,40 @@ 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 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(""), @@ -467,6 +501,8 @@ int main() { pushToTalkWatchesTheKeyOnlyWhileItCanTransmit(); peopleCarrySpeechAndVolume(); theControllerGatesPushToTalk(); + changingServerExistsOnlyForAnApplicationStart(); + startingWithTheSystemFollowsTheRegistration(); 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." +} 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." +}