From 927e18e6e134ff8a8cc62601b5e4c008681b3bf9 Mon Sep 17 00:00:00 2001 From: Lucas Borges <31556507+lucastaonline@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:59:01 -0300 Subject: [PATCH 1/4] feat: resolve as issues do backlog (#877, #874, #881, #878, #890, #861, #872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aviso quando o servidor hospedado não é alcançável de fora (#877) - Ajuda para servidores seguros na versão web (#874) - Cartão de senha do servidor na aba de segurança (#881) - Desfoque da câmera já na primeira prévia (#878) - Formato de vídeo e AV1 no compartilhamento nativo (#890) - Armazenamento de miniapps na versão web (#861) e documentação (#872) - Erro de microfone amigável - Live de placas AMD no formato Compatível volta a ser assistida - Aviso claro em máquinas sem placa de vídeo compatível Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.en.md | 2 + README.md | 2 + .../screen-share/src/capture/contract.h | 12 +- .../screen-share/src/capture/contractTest.cpp | 11 + .../native/screen-share/src/capture/host.cpp | 4 +- .../rtc/inputs/abi/adapter_policy_probe.cc | 14 + .../src/rtc/inputs/adapters/mf_rtc_policy.cc | 6 +- apps/client/package.json | 6 +- apps/client/src/main/botScreenDocuments.ts | 33 +- .../src/main/botStorage/BotDeviceStorage.ts | 417 +++------------- apps/client/src/main/botStorage/ipc.ts | 71 +-- .../src/main/nativeScreen/encoderDiscovery.ts | 4 +- apps/client/src/main/nativeScreenSharing.ts | 27 +- apps/client/src/main/screenEncodingCache.ts | 6 +- apps/client/src/main/screenEncodingPolicy.ts | 17 +- .../src/renderer/core/AudioDeviceService.ts | 9 + .../client/src/renderer/core/NetworkClient.ts | 13 +- .../core/cameraEffects/RvmPersonMatting.ts | 49 +- .../src/renderer/core/hostedServerStart.ts | 8 + .../src/renderer/core/screenEncoding.ts | 2 +- .../src/renderer/core/serverConnection.ts | 16 +- apps/client/src/renderer/i18n/locales/en.ts | 46 +- .../client/src/renderer/i18n/locales/pt-BR.ts | 48 +- .../renderer/platform/web/webBotScreens.ts | 24 +- .../renderer/platform/web/webBotStorage.ts | 130 +++++ .../src/renderer/platform/web/webPlatform.ts | 16 +- .../web/webScreen/webScreenRuntime.ts | 4 +- .../src/renderer/platform/web/webStorage.ts | 6 + .../src/renderer/stores/settingsStore.ts | 17 +- apps/client/src/renderer/styles/theme.css | 12 + apps/client/src/renderer/utils/docsLinks.ts | 23 + .../client/src/renderer/utils/hostedServer.ts | 26 + .../src/renderer/utils/screenShareFailure.ts | 30 +- .../client/src/renderer/utils/secureServer.ts | 26 + .../src/renderer/utils/secureServerHelp.ts | 20 + apps/client/src/renderer/views/Dialog.ts | 29 +- apps/client/src/renderer/views/InviteModal.ts | 5 +- .../src/renderer/views/JoinInviteModal.ts | 4 +- .../renderer/views/ScreenEncodingControls.ts | 51 +- .../src/renderer/views/ServerRailView.ts | 7 + .../src/renderer/views/ServerSettingsModal.ts | 50 +- .../src/renderer/views/SettingsModal.ts | 18 +- .../views/addServer/AddServerModal.ts | 4 + .../serverSettingsImmediate.css | 86 ++++ .../serverSettings/tabs/ServerSecurityTab.ts | 235 ++++++++- .../renderer/views/settings/tabs/AboutTab.ts | 18 + .../views/settings/tabs/LocalToolsTab.ts | 15 +- .../views/settings/tabs/QualityTab.ts | 15 +- apps/client/test/audioDeviceSmoke.cjs | 15 + apps/client/test/cameraEffectsSmoke.cjs | 46 +- apps/client/test/fixtures/cameraEffects.html | 3 +- apps/client/test/hostedServerReach.test.ts | 66 +++ apps/client/test/modalShellDom.test.cjs | 21 + apps/client/test/nativeScreenIpc.test.cjs | 51 +- apps/client/test/networkClient.test.ts | 10 +- apps/client/test/qualitySettingsSmoke.cjs | 40 +- .../test/screenEncodingAdjustment.test.cjs | 22 + apps/client/test/screenShareFailures.test.ts | 8 +- apps/client/test/serverSettingsDom.test.cjs | 96 +++- apps/client/test/webBotStorage.test.ts | 106 ++++ docs-site/administrar-servidor.md | 9 +- docs-site/arquitetura.md | 27 +- docs-site/bots-armazenamento.md | 4 + docs-site/bots-desenvolvimento.md | 4 +- docs-site/configuracoes.md | 15 +- docs-site/en/administrar-servidor.md | 9 +- docs-site/en/arquitetura.md | 27 +- docs-site/en/bots-armazenamento.md | 4 + docs-site/en/bots-desenvolvimento.md | 4 +- docs-site/en/configuracoes.md | 15 +- docs-site/en/faq.md | 8 + docs-site/faq.md | 7 + packages/shared/package.json | 2 +- packages/shared/src/botDeviceStorageEngine.ts | 457 ++++++++++++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/nativeScreenIpc.ts | 4 +- packages/shared/src/screenShareFailures.ts | 4 +- .../test/botDeviceStorageEngine.test.ts | 237 +++++++++ release-notes/camera-blur-first-preview.json | 5 + release-notes/friendly-microphone-error.json | 5 + .../hosted-server-reach-warning.json | 5 + .../native-screen-amf-compatible.json | 5 + release-notes/native-screen-av1.json | 5 + .../native-screen-no-gpu-message.json | 5 + release-notes/server-password-card.json | 5 + release-notes/web-miniapp-storage.json | 5 + release-notes/web-secure-server-help.json | 5 + 87 files changed, 2351 insertions(+), 710 deletions(-) create mode 100644 apps/client/src/renderer/platform/web/webBotStorage.ts create mode 100644 apps/client/src/renderer/utils/docsLinks.ts create mode 100644 apps/client/src/renderer/utils/secureServer.ts create mode 100644 apps/client/src/renderer/utils/secureServerHelp.ts create mode 100644 apps/client/test/hostedServerReach.test.ts create mode 100644 apps/client/test/webBotStorage.test.ts create mode 100644 packages/shared/src/botDeviceStorageEngine.ts create mode 100644 packages/shared/test/botDeviceStorageEngine.test.ts create mode 100644 release-notes/camera-blur-first-preview.json create mode 100644 release-notes/friendly-microphone-error.json create mode 100644 release-notes/hosted-server-reach-warning.json create mode 100644 release-notes/native-screen-amf-compatible.json create mode 100644 release-notes/native-screen-av1.json create mode 100644 release-notes/native-screen-no-gpu-message.json create mode 100644 release-notes/server-password-card.json create mode 100644 release-notes/web-miniapp-storage.json create mode 100644 release-notes/web-secure-server-help.json diff --git a/README.en.md b/README.en.md index cf33ab96..91302682 100644 --- a/README.en.md +++ b/README.en.md @@ -41,6 +41,8 @@ Download the latest version from [github.com/MonkyOrg/Monky/releases/latest](htt If Windows/macOS shows a security warning, see [Download](https://monkyorg.github.io/Monky/en/download). For checksums and signatures, see [Verify Releases](https://monkyorg.github.io/Monky/en/verificar-releases). +To try it without installing anything, use the web version at [monkyweb.vercel.app](https://monkyweb.vercel.app/). It only joins servers with a secure connection (`wss://`, see [`monky tls`](https://monkyorg.github.io/Monky/en/cli#monky-tls)); hosting a server, the overlay and global shortcuts stay in the app. + ## 📚 Documentation Full usage and hosting manual at **[monkyorg.github.io/Monky/en](https://monkyorg.github.io/Monky/en/)** — installation, getting started, creating a server, hosting on a VPS and troubleshooting. ([Português](https://monkyorg.github.io/Monky/)) diff --git a/README.md b/README.md index 2de86c7e..4048dfb0 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Baixe a versão mais recente em [github.com/MonkyOrg/Monky/releases/latest](http Se o Windows/macOS mostrar aviso de segurança, veja [Download](https://monkyorg.github.io/Monky/download). Para checksums e assinatura, veja [Verificar Releases](https://monkyorg.github.io/Monky/verificar-releases). +Para experimentar sem instalar nada, use a versão web em [monkyweb.vercel.app](https://monkyweb.vercel.app/). Ela só entra em servidores com conexão segura (`wss://`, veja [`monky tls`](https://monkyorg.github.io/Monky/cli#monky-tls)); hospedar servidor, overlay e atalhos globais continuam só no app. + ## 📚 Documentação Manual completo de uso e hospedagem em **[monkyorg.github.io/Monky](https://monkyorg.github.io/Monky/)** — instalação, primeiros passos, criar servidor, hospedar em VPS e solução de problemas. ([English](https://monkyorg.github.io/Monky/en/)) diff --git a/apps/client/native/screen-share/src/capture/contract.h b/apps/client/native/screen-share/src/capture/contract.h index bf24d02e..30f67cfd 100644 --- a/apps/client/native/screen-share/src/capture/contract.h +++ b/apps/client/native/screen-share/src/capture/contract.h @@ -953,16 +953,20 @@ inline AmfColorNormalization NormalizeAmfBt709(std::span byt // A Baseline encoder (AMF and NVENC "baseline", x264) emits no slice groups or redundant pictures, which is // Constrained Baseline (H.264 A.2.1); some leave constraint_set1 clear. The compatible format (#858) declares it, -// so receivers that admit only Constrained Baseline accept the stream. The flag byte follows profile_idc (never -// zero), so no emulation prevention depends on it. Empty means no rewrite. +// so receivers that admit only Constrained Baseline accept the stream. AMF also sets constraint_set5, which has no +// meaning for Baseline (7.4.2.1.1) but takes the SPS out of WebRTC's Constrained Baseline table, so receivers +// refused it; constraint_set4/5 are cleared. The flag byte follows profile_idc (never zero), so no emulation +// prevention depends on it. Empty means no rewrite. inline std::vector NormalizeConstrainedBaseline(std::span bytes, const VideoConfiguration& video) { std::vector result; if (!video.constrainedBaseline) return result; VisitAnnexB(bytes, [&](std::span nal, std::size_t begin, std::size_t) { - if ((nal.front() & 0x1fu) != 7 || nal.size() < 4 || nal[1] != 66 || (nal[2] & 0x40u)) return; + if ((nal.front() & 0x1fu) != 7 || nal.size() < 4 || nal[1] != 66) return; + const auto declared = static_cast((nal[2] | 0x40u) & ~0x0cu); + if (declared == nal[2]) return; if (result.empty()) result.assign(bytes.begin(), bytes.end()); - result[begin + 2] = static_cast(result[begin + 2] | 0x40u); + result[begin + 2] = declared; }); return result; } diff --git a/apps/client/native/screen-share/src/capture/contractTest.cpp b/apps/client/native/screen-share/src/capture/contractTest.cpp index c4eda0fb..2aff1e15 100644 --- a/apps/client/native/screen-share/src/capture/contractTest.cpp +++ b/apps/client/native/screen-share/src/capture/contractTest.cpp @@ -523,6 +523,17 @@ int main(int argc, char** argv) { TestSpsOptions flagged = plain; flagged.constraints = 0xc0; check(NormalizeConstrainedBaseline(ParameterSets(baseline, flagged), baseline).empty()); + // AMF baseline sets constraint_set5, which WebRTC receivers refuse as Constrained Baseline. + constexpr std::pair amfFlags[]{{0x04, 0x40}, {0x44, 0x40}, {0x48, 0x40}, {0xcc, 0xc0}}; + for (const auto& [emitted, declared] : amfFlags) { + TestSpsOptions amf = plain; + amf.constraints = emitted; + const auto emittedSets = ParameterSets(baseline, amf); + const auto declaredSets = NormalizeConstrainedBaseline(emittedSets, baseline); + check(declaredSets.size() == emittedSets.size() && emittedSets[5] == emitted && declaredSets[5] == declared && + std::equal(declaredSets.begin() + 6, declaredSets.end(), emittedSets.begin() + 6)); + check(InspectAnnexB(declaredSets, baseline).sps); + } check(NormalizeConstrainedBaseline(plainBaseline, mainVideo).empty()); check(NormalizeConstrainedBaseline(ParameterSets(mainVideo), mainVideo).empty()); rejects([&] { InspectAnnexB(plainBaseline, mainVideo); }); diff --git a/apps/client/native/screen-share/src/capture/host.cpp b/apps/client/native/screen-share/src/capture/host.cpp index 650d28c9..4944f6a0 100644 --- a/apps/client/native/screen-share/src/capture/host.cpp +++ b/apps/client/native/screen-share/src/capture/host.cpp @@ -526,7 +526,9 @@ class Host { description.AdapterLuid.LowPart; return; } - throw ContractError(IsSoftware(arguments_.encoder) ? "ERR_SCREEN_CAPTURE_DEVICE" : "ERR_SCREEN_CAPTURE_ENCODER_UNSUPPORTED", + // A software encoder accepts any real adapter 0, so reaching here means there is none: only the + // software renderer of a virtual machine or a machine without a graphics driver. + throw ContractError(IsSoftware(arguments_.encoder) ? "ERR_SCREEN_CAPTURE_NO_GPU" : "ERR_SCREEN_CAPTURE_ENCODER_UNSUPPORTED", "The selected encoder is not supported on capture adapter 0; cross-adapter or unrequested software fallback is forbidden"); } void CheckDevice() { diff --git a/apps/client/native/screen-share/src/rtc/inputs/abi/adapter_policy_probe.cc b/apps/client/native/screen-share/src/rtc/inputs/abi/adapter_policy_probe.cc index 38a06003..f187c392 100644 --- a/apps/client/native/screen-share/src/rtc/inputs/abi/adapter_policy_probe.cc +++ b/apps/client/native/screen-share/src/rtc/inputs/abi/adapter_policy_probe.cc @@ -247,6 +247,20 @@ void VerifyAdaptiveDecoderSessions() { Check(amf_plan && amf_plan->profileLevelId == "4d0433" && amf_info.sps->compatibility == 0x04, "AMF constrained Main SPS must be admitted without rewriting its original bytes"); Reject([&] { plan(std::nullopt, amf_info, amf_packet); }, "ERR_RTC_NEGOTIATED_SPS"); + // AMF baseline (compatible format) also sets constraint_set5; it must still decode as Constrained Baseline. + for (const auto flags : std::initializer_list{0x44, 0x48, 0x4c, 0xc4}) { + auto amf_baseline = small_sps; + amf_baseline[2] = flags; + const auto amf_baseline_packet = ParserPacket(amf_baseline); + const auto amf_baseline_info = policy::InspectAccessUnit(amf_baseline_packet); + const auto amf_baseline_plan = plan(std::nullopt, amf_baseline_info, amf_baseline_packet); + Check(amf_baseline_plan && amf_baseline_info.sps->compatibility == flags && + amf_baseline_plan->profileLevelId == amf_baseline_info.sps->ProfileLevelId(), + "AMF Constrained Baseline with constraint_set4/5 must be admitted without rewriting its original bytes"); + Check(policy::PlanDecoderSession(std::nullopt, amf_baseline_info, amf_baseline_packet, + {screen_video::H264Profile::Main, 51}, 1920, 1080, 8, options).has_value(), + "A Main receiver must admit AMF Constrained Baseline with constraint_set4/5"); + } // A browser's software encoder answers Main SDP with Main-compatible Constrained Baseline (42c0). const auto browser_plan = policy::PlanDecoderSession(std::nullopt, small_info, smaller, {screen_video::H264Profile::Main, 51}, 1920, 1080, 8, options); diff --git a/apps/client/native/screen-share/src/rtc/inputs/adapters/mf_rtc_policy.cc b/apps/client/native/screen-share/src/rtc/inputs/adapters/mf_rtc_policy.cc index a0452595..80398f1a 100644 --- a/apps/client/native/screen-share/src/rtc/inputs/adapters/mf_rtc_policy.cc +++ b/apps/client/native/screen-share/src/rtc/inputs/adapters/mf_rtc_policy.cc @@ -144,7 +144,11 @@ void ValidateSps(const sv::H264Sps& sps, NegotiatedH264 negotiated, throw AdapterError("ERR_RTC_NEGOTIATED_SPS", "Actual SPS violates negotiated profile/level, geometry, or BT.709-limited input"); } - const auto actual = webrtc::ParseH264ProfileLevelId(sps.ProfileLevelId().c_str()); + // constraint_set4/5 carry no meaning for Baseline (H.264 7.4.2.1.1), yet AMF sets constraint_set5 and WebRTC's + // table then refuses its Constrained Baseline. Senders that predate clearing it are still admitted. + auto declared = sps; + if (sps.profileIdc == 66) declared.compatibility = static_cast(sps.compatibility & ~0x0cu); + const auto actual = webrtc::ParseH264ProfileLevelId(declared.ProfileLevelId().c_str()); const auto expected = negotiated.profile == sv::H264Profile::Main && !main_compatible_baseline ? webrtc::H264Profile::kProfileMain : webrtc::H264Profile::kProfileConstrainedBaseline; // Pinned AMF emits this Main SPS byte pattern, outside WebRTC's SDP table. diff --git a/apps/client/package.json b/apps/client/package.json index e38a82ce..1a88262d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -13,7 +13,7 @@ "build:preload": "tsc -p tsconfig.preload.json && node scripts/buildPreloads.mjs", "start": "electron .", "screenshots:onboarding": "node scripts/capture-onboarding-screenshots.cjs", - "test": "tsc -p tsconfig.test.json && node dist-test/test/utils.test.js && node --test dist-test/test/eventBus.test.js dist-test/test/featureAvailability.test.js dist-test/test/botInteractions.test.js dist-test/test/botScreens.test.js dist-test/test/commandUi.test.js dist-test/test/commandAutocomplete.test.js dist-test/test/soundDownloads.test.js dist-test/test/soundDownloadConfirmation.test.js dist-test/test/presence.test.js dist-test/test/gameShare.test.js dist-test/test/rolePermissions.test.js dist-test/test/networkClient.test.js dist-test/test/serverShutdownNotice.test.js dist-test/test/voiceConnection.test.js dist-test/test/voiceModeReconnect.test.js dist-test/test/webRtcManager.test.js dist-test/test/screenSharing.test.js dist-test/test/screenShareControls.test.js dist-test/test/screenShareFailures.test.js dist-test/test/screenSubscriptions.test.js dist-test/test/webScreenPublisher.test.js dist-test/test/videoDiagnostics.test.js dist-test/test/videoTelemetry.test.js dist-test/test/performanceDiagnostics.test.js dist-test/test/responsivenessMonitor.test.js dist-test/test/shortcuts.test.js dist-test/test/releaseNotes.test.js dist-test/test/audioPreferences.test.js dist-test/test/serverSettings.test.js dist-test/test/voiceRooms.test.js dist-test/test/favorites.test.js dist-test/test/cameraEffects.test.js dist-test/test/screenColorPicker.test.js dist-test/test/serverSettingsStore.test.js dist-test/test/hostedServerLifecycle.test.js dist-test/test/messageEditing.test.js dist-test/test/messageSelection.test.js dist-test/test/markdownFormatting.test.js dist-test/test/serverEventCalendar.test.js dist-test/test/autoEntry.test.js dist-test/test/hostedServers.test.js dist-test/test/serverAddress.test.js dist-test/test/idleCallback.test.js dist-test/test/backgroundRouting.test.js dist-test/test/nicknames.test.js dist-test/test/livePulse.test.js dist-test/test/botLocalePreferencesStore.test.js dist-test/test/serverMonitor.test.js dist-test/test/serverAudit.test.js dist-test/test/sessionBinding.test.js dist-test/test/localPermissions.test.js dist-test/test/botDeviceStorage.test.js dist-test/test/localExecutionService.test.js dist-test/test/localTools.test.js dist-test/test/localExecutionRenderer.test.js dist-test/test/localExecutionTasks.test.js dist-test/test/localAudioSender.test.js dist-test/test/worker.test.js dist-test/test/workerClient.test.js dist-test/test/workerProtocol.test.js dist-test/test/mediaFolders.test.js dist-test/test/attachmentDownload.test.js dist-test/test/localPreparationView.test.js test/serverManagerLifecycle.test.cjs test/globalInputHookProcess.test.cjs test/developmentProfile.test.cjs test/updaterCompatibility.test.cjs test/crashRecovery.test.cjs test/serverEventCalendarIpc.test.cjs test/hostingSkills.test.cjs test/fileBatchDownloadIpc.test.cjs test/identityExport.test.cjs test/identityService.test.cjs test/localExecutionIpc.test.cjs test/localToolsTab.test.cjs test/steamPresence.test.cjs test/taskbarCallControls.test.cjs test/screenSharingUi.test.cjs test/soundboardLimiter.test.cjs test/trayManager.test.cjs test/onboardingShots.test.cjs test/ipcSenderPolicy.test.cjs test/windowSandbox.test.cjs test/recentSoundSaveIpc.test.cjs test/webDeployment.test.cjs test/webMiniapps.test.cjs test/zipFormat.test.cjs dist-test/test/settingsPersistence.test.js test/startupLoading.test.cjs dist-test/test/pointerReorder.test.js dist-test/test/rovingFocus.test.js && node --test dist-test/test/authHardening.test.js dist-test/test/hardeningAndOverlay.test.js dist-test/test/dmService.test.js dist-test/test/dmTransport.test.js dist-test/test/dmRelayPacer.test.js dist-test/test/dmIncomingBurst.test.js dist-test/test/callScope.test.js dist-test/test/cameraPublication.test.js dist-test/test/remoteMediaSink.test.js dist-test/test/voiceActivity.test.js dist-test/test/dmWire.test.js dist-test/test/dmFriends.test.js test/ipcContract.test.cjs test/ipcRegistry.test.cjs test/appIpc.test.cjs test/dmIpc.test.cjs dist-test/test/rewatchPolicy.test.js dist-test/test/stageTiles.test.js test/motionTokens.test.cjs dist-test/test/nativeFormAnswers.test.js dist-test/test/shellEvents.test.js dist-test/test/initial.test.js", + "test": "tsc -p tsconfig.test.json && node dist-test/test/utils.test.js && node --test dist-test/test/eventBus.test.js dist-test/test/featureAvailability.test.js dist-test/test/botInteractions.test.js dist-test/test/botScreens.test.js dist-test/test/commandUi.test.js dist-test/test/commandAutocomplete.test.js dist-test/test/soundDownloads.test.js dist-test/test/soundDownloadConfirmation.test.js dist-test/test/presence.test.js dist-test/test/gameShare.test.js dist-test/test/rolePermissions.test.js dist-test/test/networkClient.test.js dist-test/test/serverShutdownNotice.test.js dist-test/test/voiceConnection.test.js dist-test/test/voiceModeReconnect.test.js dist-test/test/webRtcManager.test.js dist-test/test/screenSharing.test.js dist-test/test/screenShareControls.test.js dist-test/test/screenShareFailures.test.js dist-test/test/screenSubscriptions.test.js dist-test/test/webScreenPublisher.test.js dist-test/test/videoDiagnostics.test.js dist-test/test/videoTelemetry.test.js dist-test/test/performanceDiagnostics.test.js dist-test/test/responsivenessMonitor.test.js dist-test/test/shortcuts.test.js dist-test/test/releaseNotes.test.js dist-test/test/audioPreferences.test.js dist-test/test/serverSettings.test.js dist-test/test/voiceRooms.test.js dist-test/test/favorites.test.js dist-test/test/cameraEffects.test.js dist-test/test/screenColorPicker.test.js dist-test/test/serverSettingsStore.test.js dist-test/test/hostedServerLifecycle.test.js dist-test/test/messageEditing.test.js dist-test/test/messageSelection.test.js dist-test/test/markdownFormatting.test.js dist-test/test/serverEventCalendar.test.js dist-test/test/autoEntry.test.js dist-test/test/hostedServers.test.js dist-test/test/hostedServerReach.test.js dist-test/test/serverAddress.test.js dist-test/test/idleCallback.test.js dist-test/test/backgroundRouting.test.js dist-test/test/nicknames.test.js dist-test/test/livePulse.test.js dist-test/test/botLocalePreferencesStore.test.js dist-test/test/serverMonitor.test.js dist-test/test/serverAudit.test.js dist-test/test/sessionBinding.test.js dist-test/test/localPermissions.test.js dist-test/test/botDeviceStorage.test.js dist-test/test/webBotStorage.test.js dist-test/test/localExecutionService.test.js dist-test/test/localTools.test.js dist-test/test/localExecutionRenderer.test.js dist-test/test/localExecutionTasks.test.js dist-test/test/localAudioSender.test.js dist-test/test/worker.test.js dist-test/test/workerClient.test.js dist-test/test/workerProtocol.test.js dist-test/test/mediaFolders.test.js dist-test/test/attachmentDownload.test.js dist-test/test/localPreparationView.test.js test/serverManagerLifecycle.test.cjs test/globalInputHookProcess.test.cjs test/developmentProfile.test.cjs test/updaterCompatibility.test.cjs test/crashRecovery.test.cjs test/serverEventCalendarIpc.test.cjs test/hostingSkills.test.cjs test/fileBatchDownloadIpc.test.cjs test/identityExport.test.cjs test/identityService.test.cjs test/localExecutionIpc.test.cjs test/localToolsTab.test.cjs test/steamPresence.test.cjs test/taskbarCallControls.test.cjs test/screenSharingUi.test.cjs test/soundboardLimiter.test.cjs test/trayManager.test.cjs test/onboardingShots.test.cjs test/ipcSenderPolicy.test.cjs test/windowSandbox.test.cjs test/recentSoundSaveIpc.test.cjs test/webDeployment.test.cjs test/webMiniapps.test.cjs test/zipFormat.test.cjs dist-test/test/settingsPersistence.test.js test/startupLoading.test.cjs dist-test/test/pointerReorder.test.js dist-test/test/rovingFocus.test.js && node --test dist-test/test/authHardening.test.js dist-test/test/hardeningAndOverlay.test.js dist-test/test/dmService.test.js dist-test/test/dmTransport.test.js dist-test/test/dmRelayPacer.test.js dist-test/test/dmIncomingBurst.test.js dist-test/test/callScope.test.js dist-test/test/cameraPublication.test.js dist-test/test/remoteMediaSink.test.js dist-test/test/voiceActivity.test.js dist-test/test/dmWire.test.js dist-test/test/dmFriends.test.js test/ipcContract.test.cjs test/ipcRegistry.test.cjs test/appIpc.test.cjs test/dmIpc.test.cjs dist-test/test/rewatchPolicy.test.js dist-test/test/stageTiles.test.js test/motionTokens.test.cjs dist-test/test/nativeFormAnswers.test.js dist-test/test/shellEvents.test.js dist-test/test/initial.test.js", "test:development": "node test/developmentProfileSmoke.cjs", "test:categories": "node --test test/channelCategoriesDom.test.cjs", "test:calendar": "tsc -p tsconfig.test.json && node --test dist-test/test/serverEventCalendar.test.js dist-test/test/calendarDate.test.js test/serverEventCalendarIpc.test.cjs test/recentSoundSaveIpc.test.cjs", @@ -40,11 +40,11 @@ "test:overlay": "tsc -p tsconfig.electron.json && npm run build:preload && node --test test/overlayPosition.test.cjs && node test/settingsNavigationSmoke.cjs --overlay-window && node test/overlayNativeResizeSmoke.cjs", "test:loading": "node --test test/loadingSkeletonSmoke.cjs", "test:invites": "tsc -p tsconfig.test.json && node --test dist-test/test/serverInvites.test.js test/navigationFavoritesSmoke.cjs test/serverInviteWebsiteSmoke.cjs test/serverInviteCardDom.cjs", - "test:bots": "tsc -p tsconfig.test.json && node --test dist-test/test/botInteractions.test.js dist-test/test/botScreens.test.js dist-test/test/commandUi.test.js dist-test/test/commandAutocomplete.test.js dist-test/test/soundDownloads.test.js dist-test/test/soundDownloadConfirmation.test.js dist-test/test/localPermissions.test.js dist-test/test/botDeviceStorage.test.js dist-test/test/localExecutionService.test.js dist-test/test/localTools.test.js dist-test/test/localExecutionRenderer.test.js dist-test/test/localExecutionTasks.test.js dist-test/test/localAudioSender.test.js dist-test/test/worker.test.js dist-test/test/workerClient.test.js dist-test/test/workerProtocol.test.js test/localExecutionIpc.test.cjs test/localToolsTab.test.cjs", + "test:bots": "tsc -p tsconfig.test.json && node --test dist-test/test/botInteractions.test.js dist-test/test/botScreens.test.js dist-test/test/commandUi.test.js dist-test/test/commandAutocomplete.test.js dist-test/test/soundDownloads.test.js dist-test/test/soundDownloadConfirmation.test.js dist-test/test/localPermissions.test.js dist-test/test/botDeviceStorage.test.js dist-test/test/webBotStorage.test.js dist-test/test/localExecutionService.test.js dist-test/test/localTools.test.js dist-test/test/localExecutionRenderer.test.js dist-test/test/localExecutionTasks.test.js dist-test/test/localAudioSender.test.js dist-test/test/worker.test.js dist-test/test/workerClient.test.js dist-test/test/workerProtocol.test.js test/localExecutionIpc.test.cjs test/localToolsTab.test.cjs", "test:screens": "tsc -p tsconfig.test.json && tsc -p tsconfig.electron.json && npm run build:preload && node --test dist-test/test/botScreens.test.js test/botScreenDom.test.cjs test/botScreenStorageDom.test.cjs", "test:bots:ui": "node test/commandDomSmoke.cjs && node --test test/localExecutionCommandDom.test.cjs", "test:bots:voice": "tsc -p tsconfig.electron.json && node --test test/botVoiceRejoinSmoke.cjs", - "test:local-execution": "tsc -p tsconfig.test.json && tsc -p tsconfig.electron.json && npm run build:preload && node --test dist-test/test/localPermissions.test.js dist-test/test/botDeviceStorage.test.js dist-test/test/localExecutionService.test.js dist-test/test/localTools.test.js dist-test/test/localExecutionRenderer.test.js dist-test/test/localExecutionTasks.test.js dist-test/test/localAudioSender.test.js dist-test/test/worker.test.js dist-test/test/workerClient.test.js dist-test/test/workerProtocol.test.js dist-test/test/localPreparationView.test.js test/localExecutionIpc.test.cjs test/localToolsTab.test.cjs test/localToolsDom.test.cjs test/localExecutionCommandDom.test.cjs test/localPreparationDialog.test.cjs", + "test:local-execution": "tsc -p tsconfig.test.json && tsc -p tsconfig.electron.json && npm run build:preload && node --test dist-test/test/localPermissions.test.js dist-test/test/botDeviceStorage.test.js dist-test/test/webBotStorage.test.js dist-test/test/localExecutionService.test.js dist-test/test/localTools.test.js dist-test/test/localExecutionRenderer.test.js dist-test/test/localExecutionTasks.test.js dist-test/test/localAudioSender.test.js dist-test/test/worker.test.js dist-test/test/workerClient.test.js dist-test/test/workerProtocol.test.js dist-test/test/localPreparationView.test.js test/localExecutionIpc.test.cjs test/localToolsTab.test.cjs test/localToolsDom.test.cjs test/localExecutionCommandDom.test.cjs test/localPreparationDialog.test.cjs", "test:local-preparation": "tsc -p tsconfig.test.json && tsc -p tsconfig.electron.json && npm run build:preload && node --test dist-test/test/localPreparationView.test.js test/localPreparationDialog.test.cjs", "test:local-execution:e2e": "node --test test/localExecutionE2eSmoke.cjs", "test:shortcuts": "tsc -p tsconfig.test.json && tsc -p tsconfig.electron.json && npm run build:preload && node --test dist-test/test/shortcuts.test.js test/globalInputHookProcess.test.cjs && node test/shortcutWorkerSmoke.cjs", diff --git a/apps/client/src/main/botScreenDocuments.ts b/apps/client/src/main/botScreenDocuments.ts index c78bb8b6..0acdfca0 100644 --- a/apps/client/src/main/botScreenDocuments.ts +++ b/apps/client/src/main/botScreenDocuments.ts @@ -1,15 +1,10 @@ import { randomUUID } from 'node:crypto'; import { ipcMain, protocol, type Session, type WebContents } from 'electron'; -import { BOT_SCREEN_DOCUMENT_IPC, BOT_SCREEN_DOCUMENT_MAX_BYTES, botScreenDocumentConsentSchema, type BotDeviceStorageScope } from '@monky/shared'; -import { botDeviceStorageScopeId, type BotDeviceStorage } from './botStorage/BotDeviceStorage'; - -/** Storage on this computer that one opened miniapp document may reach. */ -export interface BotScreenStorageBinding { - scopeId: string; - scope: BotDeviceStorageScope; - /** "Only this session": lasts while this document exists; reopening asks again. */ - session: boolean; -} +import { + BOT_SCREEN_DOCUMENT_IPC, BOT_SCREEN_DOCUMENT_MAX_BYTES, bindBotScreenStorage, botScreenDocumentConsentSchema, + type BotScreenStorageBinding, +} from '@monky/shared'; +import type { BotDeviceStorage } from './botStorage/BotDeviceStorage'; const scheme = 'monky-miniapp'; const documents = new Map(); @@ -64,23 +59,7 @@ export function bindBotScreenDocuments(isOwner: (contents: WebContents) => boole } const owner = event.sender; const html = args[0]; - let binding: BotScreenStorageBinding | null = null; - if (consent.data.storage && storage) { - const { scope, grant } = consent.data.storage; - const scopeId = botDeviceStorageScopeId(scope); - let session = grant === 'session'; - if (grant === 'always') { - try { - // "Always" is remembered, but never overrides a "Never" chosen in the bot settings. - if (await storage.decision(scopeId) === 'ask') await storage.setDecision(scope, 'always'); - } catch (error) { - // The person allowed it: that holds for this view, and each storage operation reports its own failure. - console.warn('[Bot storage] Could not remember the storage decision; it applies to this view only.', error); - session = true; - } - } - binding = { scopeId, scope, session }; - } + const binding = consent.data.storage && storage ? await bindBotScreenStorage(storage, consent.data.storage) : null; if (owner.isDestroyed() || !isOwner(owner)) throw new Error('The miniapp window closed.'); if ([...documents.values()].filter(entry => entry.owner === owner).length >= 2) { throw new Error('Close another miniapp before opening this one.'); diff --git a/apps/client/src/main/botStorage/BotDeviceStorage.ts b/apps/client/src/main/botStorage/BotDeviceStorage.ts index 9189fd08..743c7400 100644 --- a/apps/client/src/main/botStorage/BotDeviceStorage.ts +++ b/apps/client/src/main/botStorage/BotDeviceStorage.ts @@ -2,260 +2,45 @@ import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { - BOT_STORAGE_LIMITS, botDeviceStorageFileSchema, botDeviceStorageScopeSchema, botStorageKeySchema, botStorageLevel, - botStoragePrefixSchema, botStorageQuotaSchema, - type BotDeviceStorageDecision, type BotDeviceStorageEntry, type BotDeviceStorageFailure, type BotDeviceStorageFile, - type BotDeviceStorageKeyInfo, type BotDeviceStorageScope, type BotDeviceStorageValue, type BotStorageUsage, + BOT_STORAGE_LIMITS, BotDeviceStorageEngine, BotDeviceStorageError, botDeviceStorageScopeIdentity, + type BotDeviceStorageBackend, type BotDeviceStorageFile, type BotDeviceStorageScope, } from '@monky/shared'; import { localToolDirectory, localToolStat, missingLocalToolFile, readLocalToolFile, removeLocalToolTree } from '../localExecution/localToolsStorage'; +export { BotDeviceStorageError, isBotDeviceStorageScopeId } from '@monky/shared'; + const INDEX = 'scope.json'; const OBJECTS = 'objects'; const INDEX_BYTES = 2 * 1024 * 1024; -const SCOPE_ID = /^[a-f0-9]{64}$/; - -export class BotDeviceStorageError extends Error { - constructor(readonly reason: BotDeviceStorageFailure, options?: { cause?: unknown }) { - super(`Bot storage on this computer failed: ${reason}`, options); - } -} - -type IndexEntry = BotDeviceStorageFile['objects'][number]; -interface ScopeState { - id: string; - scope: BotDeviceStorageScope; - decision: BotDeviceStorageDecision; - maxBytes: number; - objects: Map; - /** False until something was saved: reading or opening a miniapp never creates folders. */ - persisted: boolean; - /** The last write did not fit; the bot shows as full until space is freed or added. */ - refused: boolean; - updatedAt: number; +function sha256Hex(text: string): string { + return createHash('sha256').update(text).digest('hex'); } /** Same tuple as the bot preferences of a person: server address, server, account and bot. */ export function botDeviceStorageScopeId(scope: BotDeviceStorageScope): string { - const url = new URL(scope.serverUrl); - return createHash('sha256') - .update(JSON.stringify([url.origin, url.pathname, scope.serverId, scope.userId, scope.botId])) - .digest('hex'); -} - -export function isBotDeviceStorageScopeId(value: unknown): value is string { - return typeof value === 'string' && SCOPE_ID.test(value); -} - -function failure(error: unknown): BotDeviceStorageError { - return error instanceof BotDeviceStorageError ? error : new BotDeviceStorageError('failed', { cause: error }); + return sha256Hex(botDeviceStorageScopeIdentity(scope)); } /** - * What bots keep on this computer through their miniapps, one folder per server, - * account and bot. Keys are only names inside an index: files get random names, so - * no key can reach a path. Every scope is serialized, and the index is replaced - * atomically after the new object reached the disk. + * The desktop keeps each scope as a folder in its profile: `scope.json` and an + * `objects` folder of files with random names. Files are written to a staging + * copy, synced and renamed, so a reader sees the old file or the new one. */ -export class BotDeviceStorage { - private root: string; +class BotDeviceStorageFiles implements BotDeviceStorageBackend { private ready: Promise | null = null; - private readonly states = new Map(); - private readonly queues = new Map>(); - private closed = false; - - constructor(root: string, private readonly changed: (scopeId: string) => void = () => undefined) { - if (!path.isAbsolute(root)) throw new BotDeviceStorageError('invalid'); - this.root = root; - } - - async entry(input: BotDeviceStorageScope): Promise { - const scope = this.scope(input); - return this.serial(scope.id, async () => this.toEntry(await this.state(scope.id, scope.value))); - } - - async list(): Promise { - await this.initialize(); - let names: string[]; - try { - names = await fs.readdir(this.root); - } catch (error) { - throw failure(error); - } - const entries: BotDeviceStorageEntry[] = []; - for (const id of names.filter(isBotDeviceStorageScopeId).slice(0, BOT_STORAGE_LIMITS.deviceScopes)) { - try { - const state = await this.serial(id, () => this.state(id)); - if (state) entries.push(this.toEntry(state)); - } catch (error) { - console.warn('[Bot storage] Ignoring an unreadable storage folder.', error); - } - } - return entries.sort((first, second) => second.updatedAt - first.updatedAt); - } - - /** Access is decided from the stored preference on every operation. */ - async decision(scopeId: string): Promise { - if (!isBotDeviceStorageScopeId(scopeId)) return 'never'; - return this.serial(scopeId, async () => (await this.state(scopeId))?.decision ?? 'ask'); - } - - async setDecision(input: BotDeviceStorageScope, decision: BotDeviceStorageDecision): Promise { - const scope = this.scope(input); - return this.mutate(scope.id, async () => { - const state = await this.state(scope.id, scope.value); - return this.toEntry(await this.save({ ...state, scope: scope.value, decision, updatedAt: Date.now() })); - }); - } - - async setQuota(input: BotDeviceStorageScope, maxBytes: number): Promise { - const scope = this.scope(input); - const quota = botStorageQuotaSchema.safeParse(maxBytes); - if (!quota.success) throw new BotDeviceStorageError('invalid'); - return this.mutate(scope.id, async () => { - const state = await this.state(scope.id, scope.value); - return this.toEntry(await this.save({ ...state, scope: scope.value, maxBytes: quota.data, refused: false, updatedAt: Date.now() })); - }); - } - - /** Deletes the objects and keeps the person's preference for this bot. */ - async clear(scopeId: string): Promise { - if (!isBotDeviceStorageScopeId(scopeId)) throw new BotDeviceStorageError('invalid'); - return this.mutate(scopeId, async () => { - const state = await this.state(scopeId); - if (!state) return; - const files = [...state.objects.values()].map((entry) => entry.file); - await this.save({ ...state, objects: new Map(), refused: false, updatedAt: Date.now() }); - for (const file of files) await this.removeObject(scopeId, file); - }); - } - - /** Deletes the objects and the preferences, as if the bot had never stored anything here. */ - async forget(scopeId: string): Promise { - if (!isBotDeviceStorageScopeId(scopeId)) throw new BotDeviceStorageError('invalid'); - return this.mutate(scopeId, async () => { - await this.initialize(); - this.states.delete(scopeId); - try { - await removeLocalToolTree(this.root, path.join(this.root, scopeId)); - } catch (error) { - throw failure(error); - } - }); - } - - async get(input: BotDeviceStorageScope, keyInput: string): Promise<{ value: BotDeviceStorageValue | null; usage: BotStorageUsage }> { - const scope = this.scope(input); - const key = this.key(keyInput); - return this.serial(scope.id, async () => { - const state = await this.state(scope.id, scope.value); - const entry = state.objects.get(key); - if (!entry) return { value: null, usage: this.usageOf(state) }; - const bytes = entry.sizeBytes === 0 ? Buffer.alloc(0) : await this.readObject(scope.id, entry.file, entry.sizeBytes); - const value: BotDeviceStorageValue = entry.kind === 'text' - ? { kind: 'text', text: bytes.toString('utf8') } : { kind: 'bytes', bytes: new Uint8Array(bytes) }; - return { value, usage: this.usageOf(state) }; - }); - } - - async set(input: BotDeviceStorageScope, keyInput: string, value: BotDeviceStorageValue): Promise { - const scope = this.scope(input); - const key = this.key(keyInput); - const bytes = value.kind === 'text' - ? Buffer.from(value.text, 'utf8') : Buffer.from(value.bytes.buffer, value.bytes.byteOffset, value.bytes.byteLength); - if (bytes.byteLength > BOT_STORAGE_LIMITS.objectBytes) throw new BotDeviceStorageError('invalid'); - return this.mutate(scope.id, async () => { - const state = await this.state(scope.id, scope.value); - const previous = state.objects.get(key); - const usage = this.usageOf(state); - if (usage.usedBytes - (previous?.sizeBytes ?? 0) + bytes.byteLength > state.maxBytes || - (!previous && state.objects.size >= BOT_STORAGE_LIMITS.deviceObjects)) { - if (!state.refused) { - state.refused = true; - this.changed(scope.id); - } - throw new BotDeviceStorageError('quota'); - } - await this.requireRoom(state); - const file = await this.writeObject(scope.id, bytes); - const objects = new Map(state.objects); - objects.set(key, { key, file, sizeBytes: bytes.byteLength, kind: value.kind, updatedAt: Date.now() }); - let saved: ScopeState; - try { - saved = await this.save({ ...state, scope: scope.value, objects, refused: false, updatedAt: Date.now() }); - } catch (error) { - await this.removeObject(scope.id, file); - throw error; - } - if (previous) await this.removeObject(scope.id, previous.file); - return this.usageOf(saved); - }); - } - - async remove(input: BotDeviceStorageScope, keyInput: string): Promise<{ removed: boolean; usage: BotStorageUsage }> { - const scope = this.scope(input); - const key = this.key(keyInput); - return this.mutate(scope.id, async () => { - const state = await this.state(scope.id, scope.value); - const previous = state.objects.get(key); - if (!previous) return { removed: false, usage: this.usageOf(state) }; - const objects = new Map(state.objects); - objects.delete(key); - const saved = await this.save({ ...state, objects, refused: false, updatedAt: Date.now() }); - await this.removeObject(scope.id, previous.file); - return { removed: true, usage: this.usageOf(saved) }; - }); - } - - async keys(input: BotDeviceStorageScope, prefixInput = ''): Promise<{ keys: BotDeviceStorageKeyInfo[]; usage: BotStorageUsage }> { - const scope = this.scope(input); - const prefix = botStoragePrefixSchema.safeParse(prefixInput); - if (!prefix.success) throw new BotDeviceStorageError('invalid'); - return this.serial(scope.id, async () => { - const state = await this.state(scope.id, scope.value); - const keys = [...state.objects.values()].filter((entry) => entry.key.startsWith(prefix.data)) - .sort((first, second) => first.key < second.key ? -1 : first.key > second.key ? 1 : 0) - .map(({ key, sizeBytes, updatedAt }) => ({ key, sizeBytes, updatedAt })); - return { keys, usage: this.usageOf(state) }; - }); - } - - async usage(input: BotDeviceStorageScope): Promise { - const scope = this.scope(input); - return this.serial(scope.id, async () => this.usageOf(await this.state(scope.id, scope.value))); - } - - async dispose(): Promise { - this.closed = true; - await Promise.allSettled([...this.queues.values()]); - } - - private scope(input: BotDeviceStorageScope): { id: string; value: BotDeviceStorageScope } { - const parsed = botDeviceStorageScopeSchema.safeParse(input); - if (!parsed.success) throw new BotDeviceStorageError('invalid'); - return { id: botDeviceStorageScopeId(parsed.data), value: parsed.data }; - } - private key(input: string): string { - const parsed = botStorageKeySchema.safeParse(input); - if (!parsed.success) throw new BotDeviceStorageError('invalid'); - return parsed.data; - } + constructor(private root: string) {} - private usageOf(state: ScopeState): BotStorageUsage { - let usedBytes = 0; - for (const entry of state.objects.values()) usedBytes += entry.sizeBytes; - return { - usedBytes, maxBytes: state.maxBytes, objectCount: state.objects.size, maxObjects: BOT_STORAGE_LIMITS.deviceObjects, - level: botStorageLevel(usedBytes, state.maxBytes, state.refused), - }; + scopeId(identity: string): string { + return sha256Hex(identity); } - private toEntry(state: ScopeState): BotDeviceStorageEntry { - return { id: state.id, scope: { ...state.scope }, decision: state.decision, usage: this.usageOf(state), updatedAt: state.updatedAt }; + randomId(): string { + return randomUUID(); } - private initialize(): Promise { + initialize(): Promise { if (!this.ready) { const loading = (async () => { await fs.mkdir(this.root, { recursive: true, mode: 0o700 }); @@ -265,90 +50,68 @@ export class BotDeviceStorage { this.ready = loading; loading.catch(() => { if (this.ready === loading) this.ready = null; }); } - return this.ready.catch((error: unknown) => { throw failure(error); }); + return this.ready; } - /** Without `fallback`, an unknown scope is null; with it, a default that is saved only on the first change. */ - private state(id: string): Promise; - private state(id: string, fallback: BotDeviceStorageScope): Promise; - private async state(id: string, fallback?: BotDeviceStorageScope): Promise { - await this.initialize(); - const cached = this.states.get(id); - if (cached) return cached; - const directory = path.join(this.root, id); - let loaded: ScopeState | null = null; - try { - if (await localToolStat(directory)) { - await localToolDirectory(directory); - if (await localToolStat(path.join(directory, INDEX))) { - const file = await readLocalToolFile(this.root, path.join(directory, INDEX), INDEX_BYTES, undefined, true); - const data = botDeviceStorageFileSchema.parse(JSON.parse(file.contents.toString('utf8'))); - if (botDeviceStorageScopeId(data.scope) !== id) throw new BotDeviceStorageError('failed'); - loaded = { - id, scope: data.scope, decision: data.decision, maxBytes: data.maxBytes, - objects: new Map(data.objects.map((entry) => [entry.key, entry])), persisted: true, refused: false, updatedAt: data.updatedAt, - }; - await this.removeUnreferenced(loaded); - } else { - // The index is written last: without one, the first save stopped midway and nothing here was ever stored. - // An unreadable index is different (it may come from a newer Monky) and fails closed instead. - await removeLocalToolTree(this.root, directory); - } - } - } catch (error) { - throw failure(error); - } - if (loaded) { - this.states.set(id, loaded); - return loaded; + scopes(): Promise { + return fs.readdir(this.root); + } + + async hasScope(scopeId: string): Promise { + return await localToolStat(path.join(this.root, scopeId)) !== null; + } + + async readIndex(scopeId: string): Promise { + const directory = path.join(this.root, scopeId); + if (!await localToolStat(directory)) return null; + await localToolDirectory(directory); + if (!await localToolStat(path.join(directory, INDEX))) { + // The index is written last: without one, the first save stopped midway and nothing here was ever stored. + await removeLocalToolTree(this.root, directory); + return null; } - if (!fallback) return null; - return { - id, scope: fallback, decision: 'ask', maxBytes: BOT_STORAGE_LIMITS.deviceDefaultBytes, objects: new Map(), - persisted: false, refused: false, updatedAt: Date.now(), - }; + const file = await readLocalToolFile(this.root, path.join(directory, INDEX), INDEX_BYTES, undefined, true); + return JSON.parse(file.contents.toString('utf8')); } - /** Bounds how many server, account and bot folders one computer keeps. */ - private async requireRoom(state: ScopeState): Promise { - if (state.persisted || await localToolStat(path.join(this.root, state.id))) return; - const names = await fs.readdir(this.root); - if (names.filter(isBotDeviceStorageScopeId).length >= BOT_STORAGE_LIMITS.deviceScopes) throw new BotDeviceStorageError('quota'); + async writeIndex(scopeId: string, index: BotDeviceStorageFile): Promise { + const directory = path.join(this.root, scopeId); + await localToolDirectory(path.join(directory, OBJECTS), true); + await this.replaceFile(directory, `.${INDEX}-${randomUUID()}.tmp`, INDEX, JSON.stringify(index)); } - private async save(state: ScopeState): Promise { - await this.requireRoom(state); - const directory = path.join(this.root, state.id); - try { - await localToolDirectory(path.join(directory, OBJECTS), true); - const data: BotDeviceStorageFile = { - version: 1, scope: state.scope, decision: state.decision, maxBytes: state.maxBytes, - objects: [...state.objects.values()], updatedAt: state.updatedAt, - }; - const content = JSON.stringify(botDeviceStorageFileSchema.parse(data)); - await this.replaceFile(directory, `.${INDEX}-${randomUUID()}.tmp`, INDEX, content); - } catch (error) { - throw failure(error); + async removeUnreferenced(scopeId: string, referenced: ReadonlySet): Promise { + const directory = path.join(this.root, scopeId); + for (const name of await this.names(directory)) { + if (name.startsWith(`.${INDEX}-`) && name.endsWith('.tmp')) await this.removeFile(path.join(directory, name)); + } + for (const name of await this.names(path.join(directory, OBJECTS))) { + if (!referenced.has(name)) await this.removeObject(scopeId, name); } - const saved = { ...state, persisted: true }; - this.states.set(state.id, saved); - return saved; } - private async writeObject(scopeId: string, bytes: Buffer): Promise { + async writeObject(scopeId: string, file: string, bytes: Uint8Array): Promise { const directory = path.join(this.root, scopeId, OBJECTS); - const file = `${randomUUID()}.bin`; - try { - await localToolDirectory(directory, true); - await this.replaceFile(directory, `${file}.tmp`, file, bytes); - } catch (error) { - throw failure(error); - } - return file; + await localToolDirectory(directory, true); + await this.replaceFile(directory, `${file}.tmp`, file, bytes); + } + + async readObject(scopeId: string, file: string): Promise { + const read = await readLocalToolFile(this.root, path.join(this.root, scopeId, OBJECTS, file), + BOT_STORAGE_LIMITS.objectBytes, undefined, true); + return read.contents; + } + + removeObject(scopeId: string, file: string): Promise { + return this.removeFile(path.join(this.root, scopeId, OBJECTS, file)); + } + + removeScope(scopeId: string): Promise { + return removeLocalToolTree(this.root, path.join(this.root, scopeId)); } /** Readers see the old file or the new one, never a partial write; a failed attempt leaves nothing behind. */ - private async replaceFile(directory: string, staging: string, target: string, contents: string | Buffer): Promise { + private async replaceFile(directory: string, staging: string, target: string, contents: string | Uint8Array): Promise { const staged = path.join(directory, staging); const handle = await fs.open(staged, 'wx', 0o600); try { @@ -365,21 +128,6 @@ export class BotDeviceStorage { } } - private async readObject(scopeId: string, file: string, sizeBytes: number): Promise { - try { - const read = await readLocalToolFile(this.root, path.join(this.root, scopeId, OBJECTS, file), - BOT_STORAGE_LIMITS.objectBytes, undefined, true); - if (read.size !== sizeBytes) throw new BotDeviceStorageError('failed'); - return read.contents; - } catch (error) { - throw failure(error); - } - } - - private removeObject(scopeId: string, file: string): Promise { - return this.removeFile(path.join(this.root, scopeId, OBJECTS, file)); - } - private async removeFile(filename: string): Promise { try { await fs.unlink(filename); @@ -388,18 +136,6 @@ export class BotDeviceStorage { } } - /** Files left by an interrupted write are not in the index and never count as usage. */ - private async removeUnreferenced(state: ScopeState): Promise { - const directory = path.join(this.root, state.id); - for (const name of await this.names(directory)) { - if (name.startsWith(`.${INDEX}-`) && name.endsWith('.tmp')) await this.removeFile(path.join(directory, name)); - } - const referenced = new Set([...state.objects.values()].map((entry) => entry.file)); - for (const name of await this.names(path.join(directory, OBJECTS))) { - if (!referenced.has(name)) await this.removeObject(state.id, name); - } - } - private async names(directory: string): Promise { try { return await fs.readdir(directory); @@ -408,21 +144,12 @@ export class BotDeviceStorage { throw error; } } +} - private mutate(scopeId: string, operation: () => Promise): Promise { - return this.serial(scopeId, operation).then((result) => { - this.changed(scopeId); - return result; - }); - } - - private serial(scopeId: string, operation: () => Promise): Promise { - if (this.closed) return Promise.reject(new BotDeviceStorageError('unavailable')); - const previous = this.queues.get(scopeId) ?? Promise.resolve(); - const result = previous.then(operation, operation).catch((error: unknown) => { throw failure(error); }); - const tail = result.then(() => undefined, () => undefined); - this.queues.set(scopeId, tail); - void tail.then(() => { if (this.queues.get(scopeId) === tail) this.queues.delete(scopeId); }); - return result; +/** What bots keep on this computer through their miniapps, one folder per server, account and bot. */ +export class BotDeviceStorage extends BotDeviceStorageEngine { + constructor(root: string, changed: (scopeId: string) => void = () => undefined) { + if (!path.isAbsolute(root)) throw new BotDeviceStorageError('invalid'); + super(new BotDeviceStorageFiles(root), changed); } -} +} \ No newline at end of file diff --git a/apps/client/src/main/botStorage/ipc.ts b/apps/client/src/main/botStorage/ipc.ts index aa11fec2..82c3e0e2 100644 --- a/apps/client/src/main/botStorage/ipc.ts +++ b/apps/client/src/main/botStorage/ipc.ts @@ -1,81 +1,30 @@ import { ipcMain, type IpcMainInvokeEvent, type WebContents } from 'electron'; import { - BOT_STORAGE_IPC, BOT_STORAGE_LIMITS, botDeviceStorageDecisionUpdateSchema, botDeviceStorageQuotaUpdateSchema, - botDeviceStorageRequestSchema, botDeviceStorageScopeSchema, - type BotDeviceStorageDocumentState, type BotDeviceStorageEntry, type BotDeviceStorageResponse, type BotDeviceStorageStatus, + BOT_STORAGE_IPC, BotScreenStorageRateLimit, answerBotScreenStorageRequest, botDeviceStorageDecisionUpdateSchema, + botDeviceStorageQuotaUpdateSchema, botDeviceStorageScopeSchema, botScreenStorageState, + type BotDeviceStorageDocumentState, type BotDeviceStorageEntry, type BotDeviceStorageResponse, } from '@monky/shared'; -import { botScreenDocumentStorage, type BotScreenStorageBinding } from '../botScreenDocuments'; -import { BotDeviceStorageError, isBotDeviceStorageScopeId, type BotDeviceStorage } from './BotDeviceStorage'; - -interface RateWindow { start: number; count: number } +import { botScreenDocumentStorage } from '../botScreenDocuments'; +import { isBotDeviceStorageScopeId, type BotDeviceStorage } from './BotDeviceStorage'; /** * Miniapps reach their storage only through the document Monky created for them, * and only while the person allows it; the decision is read again on every call. */ export function bindBotDeviceStorage(isOwner: (contents: WebContents) => boolean, storage: BotDeviceStorage): () => void { - const rates = new Map(); + const rates = new BotScreenStorageRateLimit(); const owns = (event: IpcMainInvokeEvent): boolean => isOwner(event.sender) && event.senderFrame === event.sender.mainFrame; const reject = (): never => { throw new Error('Invalid bot storage request.'); }; - const allow = (url: string): boolean => { - const now = Date.now(); - const window = rates.get(url); - if (!window || now - window.start >= 1000) { - // Stale windows of closed documents never outlive a second of inactivity. - for (const [key, value] of rates) if (now - value.start >= 1000) rates.delete(key); - rates.set(url, { start: now, count: 1 }); - return true; - } - return ++window.count <= BOT_STORAGE_LIMITS.operationsPerSecond; - }; - const access = async (url: string, owner: WebContents): Promise<{ status: BotDeviceStorageStatus; binding: BotScreenStorageBinding | null }> => { - const binding = botScreenDocumentStorage(url, owner); - if (!binding) return { status: 'unavailable', binding: null }; - const decision = await storage.decision(binding.scopeId); - return { status: decision === 'always' || (decision === 'ask' && binding.session) ? 'granted' : 'denied', binding }; - }; ipcMain.handle(BOT_STORAGE_IPC.documentState, async (event, ...args: unknown[]): Promise => { if (!owns(event) || args.length !== 1 || typeof args[0] !== 'string') return reject(); - const { status, binding } = await access(args[0], event.sender); - return { - status, scopeId: binding?.scopeId ?? null, - usage: binding && status === 'granted' ? await storage.usage(binding.scope) : null, - }; + return botScreenStorageState(storage, botScreenDocumentStorage(args[0], event.sender)); }); ipcMain.handle(BOT_STORAGE_IPC.documentRequest, async (event, ...args: unknown[]): Promise => { - if (!owns(event) || args.length !== 2 || typeof args[0] !== 'string') return reject(); - const request = botDeviceStorageRequestSchema.safeParse(args[1]); - if (!request.success) return { ok: false, reason: 'invalid', usage: null }; - if (!allow(args[0])) return { ok: false, reason: 'failed', usage: null }; - const { status, binding } = await access(args[0], event.sender); - if (!binding) return { ok: false, reason: 'unavailable', usage: null }; - if (status !== 'granted') return { ok: false, reason: 'denied', usage: null }; - try { - switch (request.data.op) { - case 'get': { - const { value, usage } = await storage.get(binding.scope, request.data.key); - return { ok: true, op: 'get', value, usage }; - } - case 'set': - return { ok: true, op: 'set', usage: await storage.set(binding.scope, request.data.key, request.data.value) }; - case 'remove': { - const { removed, usage } = await storage.remove(binding.scope, request.data.key); - return { ok: true, op: 'remove', removed, usage }; - } - case 'list': { - const { keys, usage } = await storage.keys(binding.scope, request.data.prefix); - return { ok: true, op: 'list', keys, usage }; - } - case 'usage': - return { ok: true, op: 'usage', usage: await storage.usage(binding.scope) }; - } - } catch (error) { - const reason = error instanceof BotDeviceStorageError ? error.reason : 'failed'; - if (reason === 'failed') console.warn('[Bot storage] A miniapp storage operation failed.', error); - return { ok: false, reason, usage: reason === 'quota' ? await storage.usage(binding.scope).catch(() => null) : null }; - } + const url = args[0]; + if (!owns(event) || args.length !== 2 || typeof url !== 'string') return reject(); + return answerBotScreenStorageRequest(storage, botScreenDocumentStorage(url, event.sender), args[1], () => rates.allow(url)); }); ipcMain.handle(BOT_STORAGE_IPC.list, async (event, ...args: unknown[]): Promise => { diff --git a/apps/client/src/main/nativeScreen/encoderDiscovery.ts b/apps/client/src/main/nativeScreen/encoderDiscovery.ts index 89b23042..a17eec56 100644 --- a/apps/client/src/main/nativeScreen/encoderDiscovery.ts +++ b/apps/client/src/main/nativeScreen/encoderDiscovery.ts @@ -175,7 +175,7 @@ export class EncoderDiscovery { */ encoding(request: ScreenEncodingRequest, signal?: AbortSignal, refresh = false): Promise { if (this.retirementFailure) return Promise.reject(this.retirementFailure); - const { video, mode, codec, strategy, inspectHardware, h264Only = false } = request; + const { video, mode, codec, strategy, inspectHardware } = request; const abort = signal ? AbortSignal.any([this.abortController.signal, signal]) : this.abortController.signal; const remembered = refresh ? null : this.cache.get(request, this.host.displayGeneration()); if (remembered) return Promise.resolve().then(() => { @@ -244,7 +244,7 @@ export class EncoderDiscovery { encoder: selection.encoder, codec: selection.codec, encodingMode: selection.mode, refusal: outcome, retirementConfirmed: true, continuingHardwareDiscovery: outcome === 'next-candidate', }, outcome === 'failed' ? 'WARN' : 'INFO'); - }, h264Only); + }); this.cache.set(request, displayGeneration, availability, skipped); return availability; }); diff --git a/apps/client/src/main/nativeScreenSharing.ts b/apps/client/src/main/nativeScreenSharing.ts index cd1dc0c5..665a618e 100644 --- a/apps/client/src/main/nativeScreenSharing.ts +++ b/apps/client/src/main/nativeScreenSharing.ts @@ -11,7 +11,7 @@ import { } from '@monky/shared'; import type { NativeScreenPublisher, NativeScreenSubscription, NativePcmCaptureHub, NativeScreenPreviewBridge, - NativeScreenRuntime, NativeScreenAudioOptions, NativeScreenCaptureTarget, NativeScreenCaptureCapability, + NativeScreenRuntime, NativeScreenAudioOptions, NativeScreenCaptureTarget, NativeScreenCaptureCapability, NativeScreenH264Profile, } from '@monky/screen-share'; import { validateCaptureTarget } from '@monky/screen-share/target'; import * as screenAudio from '@monky/screen-audio'; @@ -27,6 +27,7 @@ import { diagnosticId, diagnosticProfile, errorDetails, NativeScreenNotice, rend import { documentIdentity, RendererBridge } from './nativeScreen/rendererBridge'; import { screenShareModule } from './nativeScreen/screenShareModule'; import { isScreenEncoderFailure } from './screenEncodingCache'; +import { screenH264Profile } from './screenEncodingPolicy'; type SourceRecord = { desktopSourceId: string; @@ -162,8 +163,7 @@ class NativeScreenSharingService { if (command.action === 'probe-encoding') { if (this.shutdownRequested || this.disposal) throw cancelled(); return { kind: 'encoding', availability: await this.discovery.verify(command.probeId, { video: command.video, - mode: command.encodingMode, codec: command.codec, strategy: command.encodingStrategy ?? 'automatic', inspectHardware: true, - h264Only: command.screenFormat === 'compatible' }) }; + mode: command.encodingMode, codec: command.codec, strategy: command.encodingStrategy ?? 'automatic', inspectHardware: true }) }; } if (command.action === 'join') { if (this.admissionsFrozen || this.shutdownRequested || this.disposal) throw cancelled(); @@ -428,26 +428,25 @@ class NativeScreenSharingService { assertCurrent(); monitor.assertCurrent(); stage = 'preflight'; - // The compatible format is H.264 Constrained Baseline, which Firefox and Safari decode too (#858). - const compatible = command.screenFormat === 'compatible'; - const h264Profile = compatible ? 'constrained-baseline' as const : 'main' as const; - this.diagnostics.log('preflight-start', { ...diagnostic, screenFormat: compatible ? 'compatible' : 'main' }); + this.diagnostics.log('preflight-start', { ...diagnostic, screenFormat: command.screenFormat ?? 'main' }); const discover = (): Promise => this.discovery.encoding({ video: command.video, mode: command.encodingMode ?? 'hardware', codec: command.codec ?? 'auto', - strategy: command.encodingStrategy ?? 'automatic', inspectHardware: false, h264Only: compatible }, selection.abort.signal); - type Preflight = { video: NativeScreenVideoProfile; proof: NativeScreenCaptureCapability }; + strategy: command.encodingStrategy ?? 'automatic', inspectHardware: false }, selection.abort.signal); + type Preflight = { video: NativeScreenVideoProfile; proof: NativeScreenCaptureCapability; h264Profile?: NativeScreenH264Profile }; // The selected source proves the chosen encoder with the real target, even when discovery was remembered. const prepare = async (encoding: ScreenEncodingAvailability): Promise => { assertCurrent(); if (!encoding.selection) throw new Error(encoding.reason ?? 'No compatible screen encoder is available for this codec and profile.'); const sized = getScreenShareProfile(command.video, 'source', encoding.selection.codec); - const video = compatible ? compatibleScreenProfile(sized) : sized; + // The format is an H.264 profile (#858, #890): AV1 has none, so it never narrows the codec choice. + const h264Profile = screenH264Profile(encoding.selection.codec, command.screenFormat); + const video = h264Profile === 'constrained-baseline' ? compatibleScreenProfile(sized) : sized; diagnostic.video = diagnosticProfile(video); selection.probe = new SelectedCaptureProbe(this.nativeRuntime(), captureDirectory, error => this.diagnostics.logFailure('preflight', error, undefined, { ...diagnostic, stage: 'preflight' })); try { - return { video, proof: await selection.probe.prepare(target, video, selection.abort.signal, encoding.selection, - command.preserveAspectRatio, h264Profile) }; + return { video, h264Profile, proof: await selection.probe.prepare(target, video, selection.abort.signal, + encoding.selection, command.preserveAspectRatio, h264Profile) }; } catch (error) { // An encoder that fails with the real source makes every remembered discovery suspect. if (isScreenEncoderFailure(error)) this.discovery.cache.clear(); @@ -465,7 +464,7 @@ class NativeScreenSharingService { encoding = await discover(); preflight = await prepare(encoding); } - const { video, proof } = preflight; + const { video, proof, h264Profile } = preflight; assertCurrent(); monitor.assertCurrent(); const backend = target.platform === 'darwin' @@ -548,7 +547,7 @@ class NativeScreenSharingService { source: { ...options.source, codec: source.codec ?? 'h264' }, onState: state => { observe(state); options.onState(state); }, publisherSessionId: call.config.sessionId, target: captureTarget, captureDirectory, captureEncoder: proof.encoderId, - h264Profile, + ...(h264Profile ? { h264Profile } : {}), preserveAspectRatio: command.preserveAspectRatio ?? true, isSourcePaused: () => monitor.paused, assertSourceCurrent: () => { diff --git a/apps/client/src/main/screenEncodingCache.ts b/apps/client/src/main/screenEncodingCache.ts index 77379f96..ad335a94 100644 --- a/apps/client/src/main/screenEncodingCache.ts +++ b/apps/client/src/main/screenEncodingCache.ts @@ -9,8 +9,6 @@ export interface ScreenEncodingRequest { readonly strategy: ScreenEncodingStrategy; /** Whether a software request also reports hardware availability, as Settings asks. */ readonly inspectHardware: boolean; - /** The compatible format (#858): only H.264 encoders, whatever the codec preference. */ - readonly h264Only?: boolean; } interface Entry { @@ -109,9 +107,9 @@ export class ScreenEncodingCache { } } -function cacheKey({ video, mode, codec, strategy, inspectHardware, h264Only = false }: ScreenEncodingRequest): string { +function cacheKey({ video, mode, codec, strategy, inspectHardware }: ScreenEncodingRequest): string { // Automatic discovery ignores dormant manual fields, and hardware discovery always inspects hardware. const automatic = strategy === 'automatic'; - return JSON.stringify([strategy, automatic ? 'hardware' : mode, h264Only ? 'h264' : automatic ? 'auto' : codec, + return JSON.stringify([strategy, automatic ? 'hardware' : mode, automatic ? 'auto' : codec, automatic || mode === 'hardware' || inspectHardware, video.width, video.height, video.fps, video.maxBitrateKbps]); } diff --git a/apps/client/src/main/screenEncodingPolicy.ts b/apps/client/src/main/screenEncodingPolicy.ts index e698487c..f25e4f63 100644 --- a/apps/client/src/main/screenEncodingPolicy.ts +++ b/apps/client/src/main/screenEncodingPolicy.ts @@ -1,13 +1,23 @@ import { nativeErrorCodes, type ScreenCodec, type ScreenCodecPreference, type ScreenEncoder, type ScreenEncodingAvailability, - type ScreenEncodingMode, type ScreenEncodingSelection, type ScreenEncodingStrategy, + type ScreenEncodingMode, type ScreenEncodingSelection, type ScreenEncodingStrategy, type ScreenFormat, } from '@monky/shared'; +import type { NativeScreenH264Profile } from '@monky/screen-share'; const hardwareEncoders: Readonly> = { av1: ['av1_texture_amf', 'obs_nvenc_av1_tex'], h264: ['h264_texture_amf', 'obs_nvenc_h264_tex', 'apple_vt_h264'], }; +/** + * The H.264 profile a screen format asks for (#858). The format never chooses the codec (#890): an AV1 screen + * has no H.264 profile, so the pipeline receives none. + */ +export function screenH264Profile(codec: ScreenCodec, format: ScreenFormat | undefined): NativeScreenH264Profile | undefined { + if (codec !== 'h264') return undefined; + return format === 'compatible' ? 'constrained-baseline' : 'main'; +} + /** Only a positive unsupported diagnosis permits falling back to software. */ export function isUnsupportedScreenEncoder(error: unknown): boolean { if (error instanceof AggregateError) return false; // Retirement/driver errors must remain actionable. @@ -33,12 +43,9 @@ export async function selectScreenEncoding( (selection, _error, outcome) => { console.warn(`[ScreenShare] Hardware encoder probe failed (${outcome}):`, selection.encoder); }, - h264Only = false, ): Promise { - if (strategy === 'manual' && preference === 'auto' && !h264Only) throw new Error('Manual encoding requires an exact codec.'); + if (strategy === 'manual' && preference === 'auto') throw new Error('Manual encoding requires an exact codec.'); if (strategy === 'automatic') { mode = 'hardware'; preference = 'auto'; } - // The compatible format is H.264 Constrained Baseline (#858): AV1 is never a candidate. - if (h264Only) preference = 'h264'; let hardware: ScreenEncodingSelection | null = null; let hardwareError = false; let hardwareFailure: Error | null = null; diff --git a/apps/client/src/renderer/core/AudioDeviceService.ts b/apps/client/src/renderer/core/AudioDeviceService.ts index 7a9e6a8d..3660b8a8 100644 --- a/apps/client/src/renderer/core/AudioDeviceService.ts +++ b/apps/client/src/renderer/core/AudioDeviceService.ts @@ -61,6 +61,15 @@ export function audioDeviceError(error: unknown): string { return t('audioDevices.selectionFailed'); } +/** Why the call started without a microphone, in the user's language rather than Chromium's. */ +export function microphoneFailureReason(error: unknown): string { + if (error instanceof DOMException && error.name === 'NotFoundError' && !settingsStore.selectedMicrophoneId) { + return t('voiceJoin.noMicrophoneFound'); + } + if (error instanceof DOMException && error.name === 'NotReadableError') return t('voiceJoin.microphoneBusy'); + return audioDeviceError(error); +} + export async function selectAudioDevice(kind: AudioDeviceKind, deviceId: string, signal?: AbortSignal): Promise { if (kind === 'output') { const next = copyAudioOutputPreferences(settingsStore); diff --git a/apps/client/src/renderer/core/NetworkClient.ts b/apps/client/src/renderer/core/NetworkClient.ts index 561398ac..12158758 100644 --- a/apps/client/src/renderer/core/NetworkClient.ts +++ b/apps/client/src/renderer/core/NetworkClient.ts @@ -29,8 +29,8 @@ import { t } from '../i18n'; import { translateProtocolError } from '../i18n/protocolErrors'; import { settingsStore } from '../stores/settingsStore'; import { connectionStore } from '../stores/connectionStore'; -import { isIpLiteralHost, isLoopbackHost, isSecureServer, normalizeHost, toWsUrl } from '../utils/serverAddress'; -import { platformSupports } from '../platform/capabilities'; +import { isIpLiteralHost, isLoopbackHost, normalizeHost, toWsUrl } from '../utils/serverAddress'; +import { insecureServerError, needsSecureConnection, SecureServerRequiredError } from '../utils/secureServer'; /** * WebKit 26 (Safari and every iOS browser) leaves a WebSocket to an IP address @@ -239,8 +239,8 @@ export class NetworkClient { private async diagnoseConnectionFailure(host: string, port: number): Promise { clientLog.warn('NETWORK', `Diagnosing connection failure to ${host}:${port}`); // On the web every other server is reached over TLS, and the page cannot probe it (#858). - if (!platformSupports('plainRemoteServers') && !isLoopbackHost(host)) { - return new Error(t('network.webSecureFailed', { host, port })); + if (needsSecureConnection(host)) { + return new SecureServerRequiredError(t('network.webSecureFailed', { host, port })); } // Outside Electron (tests, a page opened without the preload) there is no probe. const probe = window.api?.probeServer; @@ -417,8 +417,9 @@ export class NetworkClient { try { // A page served over HTTPS may only open `wss://` or this machine (#858). - if (!platformSupports('plainRemoteServers') && !isSecureServer({ host, port }) && !isLoopbackHost(host)) { - fail(new Error(t('network.webUnsupportedServer', { host: probeHost, port }))); + const insecure = insecureServerError(probeHost, port); + if (insecure) { + fail(insecure); return; } this.ws = new WebSocket(this.currentServerUrl); diff --git a/apps/client/src/renderer/core/cameraEffects/RvmPersonMatting.ts b/apps/client/src/renderer/core/cameraEffects/RvmPersonMatting.ts index 09653f06..9ccf6b39 100644 --- a/apps/client/src/renderer/core/cameraEffects/RvmPersonMatting.ts +++ b/apps/client/src/renderer/core/cameraEffects/RvmPersonMatting.ts @@ -7,6 +7,30 @@ import weightsUrl from '../../assets/camera-effects/rvm-mobilenetv3.bin?url'; import { CameraEffectError, needsPersonSegmentation, type CameraEffectSettings } from '../../utils/cameraEffects'; import { CameraGpuCompositor } from './CameraGpuCompositor'; +const MODEL_OUTPUTS = ['fgr', 'pha', 'r1o', 'r2o', 'r3o', 'r4o']; +let modForwarded = false; + +// WebGL has no CPU path for FloorMod, so RVM's tiny padding math ran on the GPU and +// was read back synchronously. Computing it on the CPU avoids those stalls and keeps +// the padding valid while compiling only (where GPU outputs are never written). +function forwardShapeModToCpu(): void { + const original = tf.getKernel(tf.Mod, 'webgl'); + if (modForwarded || !original) return; + modForwarded = true; + tf.unregisterKernel(tf.Mod, 'webgl'); + tf.registerKernel({ + kernelName: tf.Mod, backendName: 'webgl', kernelFunc: args => { + const { backend, inputs: { a, b } } = args; + if (!(backend instanceof MathBackendWebGL) || !a || !b || a.dtype !== 'int32' || b.dtype !== 'int32' + || a.shape.join() !== b.shape.join() || !backend.shouldExecuteOnCPU([a, b])) return original.kernelFunc(args); + const x = backend.readSync(a.dataId); + const y = backend.readSync(b.dataId); + if (!(x instanceof Int32Array) || !(y instanceof Int32Array) || y.includes(0)) return original.kernelFunc(args); + return backend.makeTensorInfo(a.shape, 'int32', x.map((value, i) => ((value % y[i]) + y[i]) % y[i])); + }, + }); +} + export class RvmPersonMatting { private recurrent: tf.Tensor[] = []; private ratio: tf.Scalar | null = null; @@ -21,6 +45,7 @@ export class RvmPersonMatting { if (!tf.findBackendFactory('webgl')) tf.registerBackend('webgl', () => new MathBackendWebGL(), 2); if (!await tf.setBackend('webgl')) throw new CameraEffectError('unsupported'); await tf.ready(); + forwardShapeModToCpu(); const backend = tf.backend(); if (!(backend instanceof MathBackendWebGL)) throw new CameraEffectError('unsupported'); model = await loadGraphModel(new URL(modelUrl, self.location.href).href, { @@ -66,6 +91,27 @@ export class RvmPersonMatting { } finally { clearTimeout(timer); } } + // The first inference would compile ~370 WebGL programs one at a time, which + // dominated the first blur (#878). Compile-only mode queues them all so the + // driver can link them in parallel (KHR_parallel_shader_compile), off the frame path. + private async warmUp(width: number, height: number, ratio: tf.Scalar): Promise { + const owned: tf.Tensor[] = [tf.zeros([1, height, width, 3])]; + try { + tf.env().set('ENGINE_COMPILE_ONLY', true); + try { + const result = await this.model.executeAsync({ + src: owned[0], downsample_ratio: ratio, + r1i: this.recurrent[0], r2i: this.recurrent[1], r3i: this.recurrent[2], r4i: this.recurrent[3], + }, MODEL_OUTPUTS); + owned.push(...(Array.isArray(result) ? result : [result])); + } finally { tf.env().set('ENGINE_COMPILE_ONLY', false); } + await this.backend.checkCompileCompletionAsync(); + this.backend.getUniformLocations(); + } catch { + // The real inference compiles whatever is still missing. + } finally { tf.dispose(owned); } + } + public async render(bitmap: ImageBitmap, settings: CameraEffectSettings, background: ImageBitmap | null): Promise { if (this.disposed || !needsPersonSegmentation(settings.mode)) throw new CameraEffectError('processing'); const size = `${bitmap.width}x${bitmap.height}`; @@ -73,6 +119,7 @@ export class RvmPersonMatting { this.reset(); this.ratio = tf.scalar(Math.min(1, 480 / Math.max(bitmap.width, bitmap.height))); this.size = size; + await this.warmUp(bitmap.width, bitmap.height, this.ratio); } if (!this.ratio) throw new CameraEffectError('processing'); let source: tf.Tensor | undefined; @@ -84,7 +131,7 @@ export class RvmPersonMatting { const result = await this.model.executeAsync({ src: source, downsample_ratio: this.ratio, r1i: this.recurrent[0], r2i: this.recurrent[1], r3i: this.recurrent[2], r4i: this.recurrent[3], - }, ['fgr', 'pha', 'r1o', 'r2o', 'r3o', 'r4o']); + }, MODEL_OUTPUTS); results = Array.isArray(result) ? result : [result]; if (results.length !== 6) throw new CameraEffectError('processing'); const [foreground, alpha, ...next] = results; diff --git a/apps/client/src/renderer/core/hostedServerStart.ts b/apps/client/src/renderer/core/hostedServerStart.ts index 1d299054..11cefca6 100644 --- a/apps/client/src/renderer/core/hostedServerStart.ts +++ b/apps/client/src/renderer/core/hostedServerStart.ts @@ -12,6 +12,14 @@ export function findOwnedServer(host: string, port: number): CreatedServer | und return connectionStore.createdServers.find(server => server.port === port); } +/** + * The server never sends its password back. This device only knows the one it + * connected with, or the one its own hosted server was created with. + */ +export function knownServerPassword(session: { host: string; port: number; password?: string }): string | undefined { + return session.password || findOwnedServer(session.host, session.port)?.password || undefined; +} + /** * Several of the user's servers may run side by side, each on its own port. * Starting never replaces another hosted instance: a port held by a different diff --git a/apps/client/src/renderer/core/screenEncoding.ts b/apps/client/src/renderer/core/screenEncoding.ts index c89c83da..d56b7432 100644 --- a/apps/client/src/renderer/core/screenEncoding.ts +++ b/apps/client/src/renderer/core/screenEncoding.ts @@ -31,7 +31,7 @@ export async function probeScreenEncoding( try { const result = await window.api.nativeScreenCommand({ action: 'probe-encoding', probeId, video, - ...preferences, screenFormat: settingsStore.screenFormat, + ...preferences, }); signal.throwIfAborted(); if (result.kind !== 'encoding') throw new Error('Invalid screen encoder discovery response.'); diff --git a/apps/client/src/renderer/core/serverConnection.ts b/apps/client/src/renderer/core/serverConnection.ts index 7ae5e1d1..b6ce9bc6 100644 --- a/apps/client/src/renderer/core/serverConnection.ts +++ b/apps/client/src/renderer/core/serverConnection.ts @@ -15,6 +15,7 @@ import { stopLocalScreenShares } from './screenShareControls'; import { afkChannelIdOf } from '../utils/voiceRooms'; import { showInfoToast } from '../views/CopyToast'; import { announceCallJoined, announceCallLeft } from './callCues'; +import { microphoneFailureReason } from './AudioDeviceService'; interface ClientIdentity { publicKey: string; @@ -378,7 +379,7 @@ export async function joinCallOnSession( webRtcManager.setCurrentSessionId(mySessionId); let audioTrack: MediaStreamTrack | null = null; - let microphoneError: Error | null = null; + let microphoneError: string | null = null; try { const stream = await audioProcessor.startMicrophone(); if (!isCurrent()) { @@ -386,7 +387,12 @@ export async function joinCallOnSession( assertCurrent(); } audioTrack = stream.getAudioTracks()[0] ?? null; - if (!audioTrack || audioTrack.readyState !== 'live') throw new Error(t('voiceJoin.noAudioTrack')); + if (!audioTrack || audioTrack.readyState !== 'live') { + audioTrack = null; + audioProcessor.stopMicrophone(); + microphoneError = t('voiceJoin.noAudioTrack'); + clientLog.warn('AUDIO', 'Admitted to voice without a microphone', { error: 'no live audio track' }); + } } catch (error) { assertCurrent(); if (error instanceof Error && error.name === 'AbortError') { @@ -395,8 +401,8 @@ export async function joinCallOnSession( } else { audioTrack = null; audioProcessor.stopMicrophone(); - microphoneError = error instanceof Error ? error : new Error(String(error)); - clientLog.warn('AUDIO', 'Admitted to voice without a microphone', { error: microphoneError.message }); + microphoneError = microphoneFailureReason(error); + clientLog.warn('AUDIO', 'Admitted to voice without a microphone', { error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) }); } } webRtcManager.resumeAfterVoiceReconnect(); @@ -414,7 +420,7 @@ export async function joinCallOnSession( } assertCurrent(); announceCallJoined(sessionKey, channelId); - if (microphoneError) appEvents.emit('voice.microphone_failed', { error: microphoneError.message }); + if (microphoneError) appEvents.emit('voice.microphone_failed', { error: microphoneError }); if (reconnect) appEvents.emit('voice.mode_switched', { mode: 'p2p' }); } catch (error) { if (!isCurrent()) throw cancelled(); diff --git a/apps/client/src/renderer/i18n/locales/en.ts b/apps/client/src/renderer/i18n/locales/en.ts index 6b920a43..af1da6f3 100644 --- a/apps/client/src/renderer/i18n/locales/en.ts +++ b/apps/client/src/renderer/i18n/locales/en.ts @@ -644,16 +644,26 @@ export const en: TranslationMap = { 'serverSettings.serverVersionUnavailable': 'Not provided by the server', 'serverSettings.nameLabel': 'Server name', 'serverSettings.securitySection': 'Security and access password', - 'serverSettings.statusProtected': 'Server protected with a password', - 'serverSettings.statusOpen': 'Open server (no password)', + 'serverSettings.statusProtected': 'Password active', + 'serverSettings.statusOpen': 'No password', 'serverSettings.statusProtectedDesc': 'New users must type the password to join.', 'serverSettings.statusOpenDesc': 'Any friend with the IP can join directly.', 'serverSettings.removePassword': 'Remove password', - 'serverSettings.changePasswordLabel': 'Change access password', - 'serverSettings.setPasswordLabel': 'Set a new password', - 'serverSettings.changePasswordPlaceholder': 'Type to change the password...', - 'serverSettings.setPasswordPlaceholder': 'Leave empty to keep the server open', - 'serverSettings.passwordHelp': 'Leave it blank if you do not want to change the current password.', + 'serverSettings.removePasswordTitle': 'Remove the server password?', + 'serverSettings.removePasswordMessage': 'The server will be open: anyone with the address will be able to join without a password.', + 'serverSettings.passwordSection': 'Access password', + 'serverSettings.passwordShow': 'Show', + 'serverSettings.passwordHide': 'Hide', + 'serverSettings.passwordCopy': 'Copy', + 'serverSettings.passwordChange': 'Change', + 'serverSettings.passwordSet': 'Set password', + 'serverSettings.passwordNew': 'New password', + 'serverSettings.passwordConfirm': 'Confirm new password', + 'serverSettings.passwordSave': 'Save password', + 'serverSettings.passwordMismatch': 'The passwords do not match. Type the same password in both fields.', + 'serverSettings.passwordCopied': 'Password copied!', + 'serverSettings.passwordCopyFailed': 'Could not copy the password. Check the clipboard permission.', + 'serverSettings.passwordUnknown': 'This device does not know the current password, so it cannot show or copy it. Use Change to set a new one.', 'serverSettings.passwordWillBeRemoved': 'The password will be removed when you save!', 'serverSettings.markedForRemoval': 'Marked for password removal.', 'serverSettings.passwordRemovalWarning': 'The password will be removed and the server will become open when you save.', @@ -961,10 +971,10 @@ export const en: TranslationMap = { 'screenShare.browserCannotWatch': 'This browser cannot watch this share. Ask the publisher to use the Compatible format, or watch it in Chrome, Edge or the desktop app.', 'screenShare.format': 'Video format', 'screenShare.formatCompatible': 'Compatible', - 'screenShare.formatCompatibleDescription': 'Everyone can watch it: Chrome, Edge, Firefox, Safari and the desktop app. Recommended.', + 'screenShare.formatCompatibleDescription': 'Everyone can watch it: Chrome, Edge, Firefox, Safari and the desktop app.', 'screenShare.formatMain': 'Better quality', 'screenShare.formatMainDescription': 'A better picture with the same connection, but people on Firefox or Safari cannot watch it.', - 'settings.screenFormatApply': 'Applies from the next screen share. In the Compatible format, the screen is always sent in H.264.', + 'settings.screenFormatApply': 'Applies only to the H.264 codec, from the next screen share.', 'desktopOnly.badge': 'Desktop', 'desktopOnly.tooltip': 'Available in the desktop app', 'desktopOnly.reason.screenShare': 'This browser cannot share screens to Monky. Use Chrome, Edge or Firefox, or the desktop app.', @@ -1057,6 +1067,7 @@ export const en: TranslationMap = { 'screenShare.failure.permission': 'The system did not allow screen recording. Allow Monky under Privacy & Security in System Settings, then try again.', 'screenShare.failure.source-lost': 'The chosen window or display was closed, disconnected or changed. Check that it is still open and try again.', 'screenShare.failure.files': 'Monky\'s screen capture files are damaged or in a folder that cannot be used. Update or reinstall Monky.', + 'screenShare.failure.no-gpu': 'This computer has no graphics card screen sharing can use, which is common in virtual machines. Install the graphics card driver or, in a virtual machine, turn on 3D acceleration.', 'screenShare.failure.encoder': 'The video encoder or graphics card is not available right now. Update the graphics driver or change the encoding in Settings → Quality & sharing → Screen encoding and codec.', 'screenShare.failure.quality': 'This computer does not accept the chosen quality or codec. Choose a lower quality or another codec in Settings → Quality & sharing.', 'screenShare.failure.interrupted': 'The capture stopped responding. Try again in a few seconds; if it keeps happening, restart Monky.', @@ -1073,7 +1084,9 @@ export const en: TranslationMap = { 'voiceJoin.failedTitle': 'Could not join the voice channel', 'voiceJoin.timeout': 'The server did not confirm voice admission in time. Check the connection and try again.', 'voiceJoin.microphoneTitle': 'Joined without a microphone', - 'voiceJoin.microphoneUnavailable': 'Your call remains connected, but your microphone could not start. Check its permissions or select another input device: {error}', + 'voiceJoin.microphoneUnavailable': 'Your call remains connected, but your microphone could not start. {error}', + 'voiceJoin.noMicrophoneFound': 'No microphone was found on this computer. Connect one and choose it in the audio settings.', + 'voiceJoin.microphoneBusy': 'The microphone is in use by another program or did not respond. Close the other program or choose another device.', 'voiceJoin.noAudioTrack': 'The selected microphone did not provide a live audio track.', 'screenShare.shareAppAudio': 'Share app audio', 'screenShare.ownWindowAudioUnavailable': 'Monky windows can only be shared without audio to prevent call feedback.', @@ -1709,6 +1722,11 @@ export const en: TranslationMap = { 'hostedServer.shutDown': 'Shut down server', 'hostedServer.keepRunning': 'Keep it running', 'hostedServer.dontAskAgain': "Don't ask again", + 'hostedServer.dontShowAgain': "Don't show again", + 'hostedServer.reachTitle': 'Before you create your server', + 'hostedServer.reachMessage': 'The server will run on this computer. Your friends can only join if they are on the same local network, on the same VPN (such as Radmin or ZeroTier), or if the ports are open on your router. Otherwise, only you can join.', + 'hostedServer.reachConfirm': 'Got it, create server', + 'hostedServer.reachDocs': 'See how to open access', 'hostedServer.stopWithUsersTitle': 'People are still connected', 'hostedServer.stopWithUsersMessage.one': '{count} person is connected to this server. Shutting it down will disconnect them.', 'hostedServer.stopWithUsersMessage.other': '{count} people are connected to this server. Shutting it down will disconnect all of them.', @@ -2001,6 +2019,8 @@ export const en: TranslationMap = { 'settings.minimizeToTrayDesc': 'When closing the window, keep Monky running in the background system tray.', 'settings.askShutdownOnLastLeave': 'Ask when I leave my empty server', 'settings.askShutdownOnLastLeaveDesc': 'When you are the last one to leave a server hosted on this machine, ask whether to shut it down.', + 'settings.warnHostedServerReach': 'Warn how friends reach my server', + 'settings.warnHostedServerReachDesc': 'Before creating a server, remind me that others need a VPN, the local network or open ports to join.', 'settings.communitySection': 'Community & Support', 'settings.licenseSection': 'License and source code', 'settings.licenseDescription': 'Free software under the GNU GPL version 3 or later, without warranty. You may modify and redistribute Monky under that license. Dependency notices and licenses remain applicable.', @@ -2152,8 +2172,10 @@ export const en: TranslationMap = { 'network.connectionTimeout': 'The server at {host}:{port} did not answer. It may be closed (or blocked by a firewall), or the computer may be offline. Check that the server is running and the port is open.', 'network.addressError': 'Could not connect to {url}: {error}', 'network.timeout': 'Connection timed out. Check the IP and the port.', - 'network.webUnsupportedServer': 'You cannot connect to {host}:{port} from the web version. Browsers only allow servers with a secure connection (wss://). Use the Monky desktop app, or ask the server owner to turn on the automatic secure connection (with the port open).', - 'network.webSecureFailed': 'Could not connect to {host}:{port} from the web version. If the server is online, it may not have the secure connection turned on. Use the Monky desktop app, or ask the server owner to turn on the automatic secure connection (with the port open).', + 'network.webUnsupportedServer': 'The server {host}:{port} has no secure connection (TLS), and the web version can only join servers with a secure connection. Ask the server owner to turn on the automatic secure connection, or use the Monky desktop app.', + 'network.webSecureFailed': 'Could not join {host}:{port} from the web version. If the server is online, it probably does not have the secure connection (TLS) turned on yet. Ask the server owner to turn it on, or use the Monky desktop app.', + 'network.secureRequiredTitle': 'This server needs a secure connection', + 'network.secureRequiredDocs': 'How to turn on the secure connection', 'webFatal.title': 'Monky could not open', 'webFatal.message': 'Something went wrong while starting Monky in this browser. Reload the page to try again.', 'webFatal.reload': 'Reload', diff --git a/apps/client/src/renderer/i18n/locales/pt-BR.ts b/apps/client/src/renderer/i18n/locales/pt-BR.ts index 23d848ef..d2b61237 100644 --- a/apps/client/src/renderer/i18n/locales/pt-BR.ts +++ b/apps/client/src/renderer/i18n/locales/pt-BR.ts @@ -606,16 +606,26 @@ export const ptBR = { 'serverSettings.serverVersionUnavailable': 'Não informada pelo servidor', 'serverSettings.nameLabel': 'Nome do Servidor', 'serverSettings.securitySection': 'Segurança e Senha de Acesso', - 'serverSettings.statusProtected': 'Servidor Protegido com Senha', - 'serverSettings.statusOpen': 'Servidor Aberto (Sem Senha)', + 'serverSettings.statusProtected': 'Senha ativa', + 'serverSettings.statusOpen': 'Sem senha', 'serverSettings.statusProtectedDesc': 'Novos usuários precisam digitar a senha para entrar.', 'serverSettings.statusOpenDesc': 'Qualquer amigo com o IP pode entrar diretamente.', - 'serverSettings.removePassword': 'Remover Senha', - 'serverSettings.changePasswordLabel': 'Alterar Senha de Acesso', - 'serverSettings.setPasswordLabel': 'Definir Nova Senha', - 'serverSettings.changePasswordPlaceholder': 'Digite para alterar a senha...', - 'serverSettings.setPasswordPlaceholder': 'Deixe vazio para manter sem senha', - 'serverSettings.passwordHelp': 'Deixe em branco caso não queira modificar a senha atual.', + 'serverSettings.removePassword': 'Remover senha', + 'serverSettings.removePasswordTitle': 'Remover a senha do servidor?', + 'serverSettings.removePasswordMessage': 'O servidor ficará aberto: qualquer pessoa com o endereço poderá entrar sem senha.', + 'serverSettings.passwordSection': 'Senha de acesso', + 'serverSettings.passwordShow': 'Mostrar', + 'serverSettings.passwordHide': 'Ocultar', + 'serverSettings.passwordCopy': 'Copiar', + 'serverSettings.passwordChange': 'Alterar', + 'serverSettings.passwordSet': 'Definir senha', + 'serverSettings.passwordNew': 'Nova senha', + 'serverSettings.passwordConfirm': 'Confirmar nova senha', + 'serverSettings.passwordSave': 'Salvar senha', + 'serverSettings.passwordMismatch': 'As senhas não coincidem. Digite a mesma senha nos dois campos.', + 'serverSettings.passwordCopied': 'Senha copiada!', + 'serverSettings.passwordCopyFailed': 'Não foi possível copiar a senha. Confira a permissão da área de transferência.', + 'serverSettings.passwordUnknown': 'Este aparelho não conhece a senha atual, então não pode mostrá-la nem copiá-la. Use Alterar para definir uma nova.', 'serverSettings.passwordWillBeRemoved': 'A senha será removida ao salvar!', 'serverSettings.markedForRemoval': 'Marcado para remoção de senha.', 'serverSettings.passwordRemovalWarning': 'A senha será removida e o servidor ficará público ao salvar.', @@ -923,10 +933,10 @@ export const ptBR = { 'screenShare.browserCannotWatch': 'Este navegador não consegue assistir a esta transmissão. Peça a quem está transmitindo para usar o formato Compatível, ou assista pelo Chrome, pelo Edge ou pelo app desktop.', 'screenShare.format': 'Formato do vídeo', 'screenShare.formatCompatible': 'Compatível', - 'screenShare.formatCompatibleDescription': 'Todo mundo consegue assistir: Chrome, Edge, Firefox, Safari e o app desktop. Recomendado.', + 'screenShare.formatCompatibleDescription': 'Todo mundo consegue assistir: Chrome, Edge, Firefox, Safari e o app desktop.', 'screenShare.formatMain': 'Melhor qualidade', 'screenShare.formatMainDescription': 'Imagem melhor com a mesma internet, mas quem usa o Firefox ou o Safari não consegue assistir.', - 'settings.screenFormatApply': 'Vale a partir do próximo compartilhamento de tela. No formato Compatível, a tela sai sempre em H.264.', + 'settings.screenFormatApply': 'Vale só para o codec H.264, a partir do próximo compartilhamento de tela.', 'desktopOnly.badge': 'Desktop', 'desktopOnly.tooltip': 'Disponível no app desktop', 'desktopOnly.reason.screenShare': 'Este navegador não consegue transmitir tela para o Monky. Use o Chrome, o Edge ou o Firefox, ou o app desktop.', @@ -1019,6 +1029,7 @@ export const ptBR = { 'screenShare.failure.permission': 'O sistema não deu permissão para gravar a tela. Libere o Monky em Privacidade e Segurança, nos Ajustes do Sistema, e tente de novo.', 'screenShare.failure.source-lost': 'A janela ou o monitor escolhido foi fechado, desconectado ou mudou. Confira se ele continua aberto e tente de novo.', 'screenShare.failure.files': 'Os arquivos de captura de tela do Monky estão danificados ou numa pasta que não pode ser usada. Atualize ou reinstale o Monky.', + 'screenShare.failure.no-gpu': 'Este computador não tem uma placa de vídeo que o compartilhamento de tela possa usar, algo comum em máquinas virtuais. Instale o driver da placa de vídeo ou, numa máquina virtual, ative a aceleração 3D.', 'screenShare.failure.encoder': 'O codificador de vídeo ou a placa de vídeo não está disponível agora. Atualize o driver da placa de vídeo ou mude a codificação em Configurações → Qualidade e compartilhamento → Codificação e codec de tela.', 'screenShare.failure.quality': 'Este computador não aceita a qualidade ou o codec escolhido. Escolha uma qualidade menor ou outro codec em Configurações → Qualidade e compartilhamento.', 'screenShare.failure.interrupted': 'A captura parou de responder. Tente de novo em alguns segundos; se continuar, reinicie o Monky.', @@ -1035,7 +1046,9 @@ export const ptBR = { 'voiceJoin.failedTitle': 'Não foi possível entrar no canal de voz', 'voiceJoin.timeout': 'O servidor não confirmou a entrada no canal de voz a tempo. Verifique a conexão e tente novamente.', 'voiceJoin.microphoneTitle': 'Você entrou sem microfone', - 'voiceJoin.microphoneUnavailable': 'A chamada continua conectada, mas não foi possível iniciar o microfone. Verifique as permissões ou selecione outro dispositivo de entrada: {error}', + 'voiceJoin.microphoneUnavailable': 'A chamada continua conectada, mas não foi possível iniciar o microfone. {error}', + 'voiceJoin.noMicrophoneFound': 'Nenhum microfone foi encontrado neste computador. Conecte um microfone e escolha-o nas configurações de áudio.', + 'voiceJoin.microphoneBusy': 'O microfone está em uso por outro programa ou não respondeu. Feche o outro programa ou escolha outro dispositivo.', 'voiceJoin.noAudioTrack': 'O microfone selecionado não forneceu uma trilha de áudio ativa.', 'screenShare.shareAppAudio': 'Compartilhar áudio do aplicativo', 'screenShare.ownWindowAudioUnavailable': 'A janela do Monky só pode ser compartilhada sem áudio, para evitar eco da chamada.', @@ -1671,6 +1684,11 @@ export const ptBR = { 'hostedServer.shutDown': 'Desligar servidor', 'hostedServer.keepRunning': 'Manter ligado', 'hostedServer.dontAskAgain': 'Não perguntar novamente', + 'hostedServer.dontShowAgain': 'Não mostrar novamente', + 'hostedServer.reachTitle': 'Antes de criar o seu servidor', + 'hostedServer.reachMessage': 'O servidor vai rodar neste computador. Seus amigos só vão conseguir entrar se estiverem na mesma rede local, usando a mesma VPN (como Radmin ou ZeroTier) ou se as portas estiverem abertas no seu roteador. Sem isso, só você consegue entrar.', + 'hostedServer.reachConfirm': 'Entendi, criar servidor', + 'hostedServer.reachDocs': 'Ver como liberar o acesso', 'hostedServer.stopWithUsersTitle': 'Ainda há gente conectada', 'hostedServer.stopWithUsersMessage.one': 'Há {count} pessoa conectada neste servidor. Se você desligar, ela será desconectada.', 'hostedServer.stopWithUsersMessage.other': 'Há {count} pessoas conectadas neste servidor. Se você desligar, todas serão desconectadas.', @@ -1963,6 +1981,8 @@ export const ptBR = { 'settings.minimizeToTrayDesc': 'Ao fechar a janela, manter o Monky rodando na bandeja do sistema em segundo plano.', 'settings.askShutdownOnLastLeave': 'Perguntar ao sair do meu servidor vazio', 'settings.askShutdownOnLastLeaveDesc': 'Quando você for o último a sair de um servidor hospedado nesta máquina, perguntar se deseja desligá-lo.', + 'settings.warnHostedServerReach': 'Avisar como amigos entram no meu servidor', + 'settings.warnHostedServerReachDesc': 'Antes de criar um servidor, lembrar que é preciso VPN, rede local ou portas abertas para outras pessoas entrarem.', 'settings.communitySection': 'Comunidade & Apoio', 'settings.licenseSection': 'Licença e código-fonte', 'settings.licenseDescription': 'Software livre sob a GNU GPL versão 3 ou posterior, sem garantia. Você pode modificar e redistribuir o Monky conforme essa licença. Os avisos e as licenças das dependências permanecem aplicáveis.', @@ -2114,8 +2134,10 @@ export const ptBR = { 'network.connectionTimeout': 'O servidor em {host}:{port} não respondeu. Ele pode estar fechado (ou bloqueado por um firewall) ou o computador pode estar offline. Verifique se o servidor está rodando e se a porta está liberada.', 'network.addressError': 'Não foi possível conectar ao endereço {url}: {error}', 'network.timeout': 'Tempo limite de conexão esgotado. Verifique o IP e a porta.', - 'network.webUnsupportedServer': 'Não é possível se conectar a {host}:{port} pela versão web. O navegador só permite servidores com conexão segura (wss://). Use o app desktop do Monky, ou peça ao dono do servidor para ativar a conexão segura automática (com a porta aberta).', - 'network.webSecureFailed': 'Não foi possível se conectar a {host}:{port} pela versão web. Se o servidor estiver online, ele pode não ter a conexão segura ativada. Use o app desktop do Monky, ou peça ao dono do servidor para ativar a conexão segura automática (com a porta aberta).', + 'network.webUnsupportedServer': 'O servidor {host}:{port} não tem conexão segura (TLS), e a versão web só consegue entrar em servidores com conexão segura. Fale com o dono do servidor para ele ativar a conexão segura automática, ou use o app desktop do Monky.', + 'network.webSecureFailed': 'Não foi possível entrar em {host}:{port} pela versão web. Se o servidor estiver online, provavelmente ele ainda não tem a conexão segura (TLS) ativada. Fale com o dono do servidor para ele ativá-la, ou use o app desktop do Monky.', + 'network.secureRequiredTitle': 'Este servidor precisa de conexão segura', + 'network.secureRequiredDocs': 'Como ativar a conexão segura', 'webFatal.title': 'O Monky não conseguiu abrir', 'webFatal.message': 'Algo deu errado ao iniciar o Monky neste navegador. Recarregue a página para tentar de novo.', 'webFatal.reload': 'Recarregar', diff --git a/apps/client/src/renderer/platform/web/webBotScreens.ts b/apps/client/src/renderer/platform/web/webBotScreens.ts index 45cda63f..21d50ab3 100644 --- a/apps/client/src/renderer/platform/web/webBotScreens.ts +++ b/apps/client/src/renderer/platform/web/webBotScreens.ts @@ -5,6 +5,7 @@ import { import type { BotScreenDocumentHost } from '../api'; import { t } from '../../i18n'; import { reportError } from '../../utils/feedback'; +import { bindWebBotScreenStorage, releaseWebBotScreenStorage } from './webBotStorage'; /** * Bot miniapps on the web (#858). The app's own origin and CSP must never run a @@ -80,26 +81,35 @@ const deliver = (event: MessageEvent): void => { entry.origin === 'null' ? '*' : entry.origin); }; -export function createWebBotScreenDocument(html: string, consent: BotScreenDocumentConsent): Promise { +export async function createWebBotScreenDocument(html: string, consent: BotScreenDocumentConsent): Promise { if (typeof html !== 'string' || !html || new TextEncoder().encode(html).byteLength > BOT_SCREEN_DOCUMENT_MAX_BYTES) { - return Promise.reject(new Error('Invalid miniapp document request.')); + throw new Error('Invalid miniapp document request.'); } - if (!botScreenDocumentConsentSchema.safeParse(consent).success) { - return Promise.reject(new Error('Current, instance-bound miniapp consent is required.')); - } - if (documents.size >= MAX_DOCUMENTS) return Promise.reject(new Error('Close another miniapp before opening this one.')); + const parsed = botScreenDocumentConsentSchema.safeParse(consent); + if (!parsed.success) throw new Error('Current, instance-bound miniapp consent is required.'); + if (documents.size >= MAX_DOCUMENTS) throw new Error('Close another miniapp before opening this one.'); const token = crypto.randomUUID(); const { url, origin } = documentUrl(token); + // Reserved before the storage choice is applied, so two openings at once cannot pass the limit. documents.set(url, { token, origin, html, refused: false }); if (!listening) { listening = true; window.addEventListener('message', deliver); } - return Promise.resolve(url); + if (parsed.data.storage) { + try { + await bindWebBotScreenStorage(url, parsed.data.storage); + } catch (error) { + await removeWebBotScreenDocument(url); + throw error; + } + } + return url; } export function removeWebBotScreenDocument(url: string): Promise { documents.delete(url); + releaseWebBotScreenStorage(url); if (documents.size === 0 && listening) { listening = false; window.removeEventListener('message', deliver); diff --git a/apps/client/src/renderer/platform/web/webBotStorage.ts b/apps/client/src/renderer/platform/web/webBotStorage.ts new file mode 100644 index 00000000..9c010e85 --- /dev/null +++ b/apps/client/src/renderer/platform/web/webBotStorage.ts @@ -0,0 +1,130 @@ +import { sha256 } from '@noble/hashes/sha2.js'; +import { + BOT_STORAGE_LIMITS, BotDeviceStorageEngine, BotDeviceStorageError, BotScreenStorageRateLimit, answerBotScreenStorageRequest, + bindBotScreenStorage, botScreenStorageState, + type BotDeviceStorageBackend, type BotDeviceStorageFile, type BotDeviceStorageGrant, type BotDeviceStorageScope, + type BotScreenStorageBinding, +} from '@monky/shared'; +import type { BotStorageApi } from '../api'; +import { + deleteRecord, deleteRecordsWithPrefix, listRecordKeysWithPrefix, readRecord, writeRecord, +} from './webStorage'; + +/** + * Bot storage "on this computer" for the web version (#861): the same rules as the + * desktop, over IndexedDB of the app's origin. Miniapps run on another origin and + * only reach it through `documentState`/`documentRequest`, like on the desktop. + * + * Records: `bot-storage//index` and `bot-storage//objects/`. + * Each record is written by one IndexedDB request, so a reader sees the old value or the new one. + */ +const PREFIX = 'bot-storage/'; + +const scopePrefix = (scopeId: string): string => `${PREFIX}${scopeId}/`; +const indexKey = (scopeId: string): string => `${scopePrefix(scopeId)}index`; +const objectsPrefix = (scopeId: string): string => `${scopePrefix(scopeId)}objects/`; + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +const indexedDbBackend: BotDeviceStorageBackend = { + scopeId: (identity) => hex(sha256(new TextEncoder().encode(identity))), + randomId: () => crypto.randomUUID(), + initialize: () => Promise.resolve(), + async scopes() { + const names = new Set(); + for (const key of await listRecordKeysWithPrefix(PREFIX)) names.add(key.slice(PREFIX.length).split('/')[0]); + return [...names]; + }, + async hasScope(scopeId) { + return (await listRecordKeysWithPrefix(scopePrefix(scopeId))).length > 0; + }, + async readIndex(scopeId) { + const index: unknown = await readRecord(indexKey(scopeId)); + if (index !== undefined) return index; + // The index is written last: without one, the first save stopped midway and nothing here was ever stored. + await deleteRecordsWithPrefix(scopePrefix(scopeId)); + return null; + }, + async writeIndex(scopeId: string, index: BotDeviceStorageFile) { + await writeRecord(indexKey(scopeId), index); + }, + async removeUnreferenced(scopeId, referenced) { + const prefix = objectsPrefix(scopeId); + for (const key of await listRecordKeysWithPrefix(prefix)) { + if (!referenced.has(key.slice(prefix.length))) await indexedDbBackend.removeObject(scopeId, key.slice(prefix.length)); + } + }, + async writeObject(scopeId, file, bytes) { + // A copy of exactly these bytes: a view would store its whole underlying buffer. + await writeRecord(`${objectsPrefix(scopeId)}${file}`, bytes.slice()); + }, + async readObject(scopeId, file) { + const bytes: unknown = await readRecord(`${objectsPrefix(scopeId)}${file}`); + if (!(bytes instanceof Uint8Array) || bytes.byteLength > BOT_STORAGE_LIMITS.objectBytes) { + throw new BotDeviceStorageError('failed'); + } + return bytes; + }, + async removeObject(scopeId, file) { + try { + await deleteRecord(`${objectsPrefix(scopeId)}${file}`); + } catch (error) { + console.warn('[Bot storage] Could not remove a record that is no longer used.', error); + } + }, + async removeScope(scopeId) { + await deleteRecordsWithPrefix(scopePrefix(scopeId)); + }, +}; + +const listeners = new Set<(scopeId: string) => void>(); +const bindings = new Map(); +const rates = new BotScreenStorageRateLimit(); +let engine: BotDeviceStorageEngine | null = null; + +function storage(): BotDeviceStorageEngine { + engine ??= new BotDeviceStorageEngine(indexedDbBackend, (scopeId) => { + for (const listener of [...listeners]) listener(scopeId); + }); + return engine; +} + +/** Applies the choice made when opening a miniapp document; it lasts until the document is removed. */ +export async function bindWebBotScreenStorage( + url: string, choice: { scope: BotDeviceStorageScope; grant: BotDeviceStorageGrant }, +): Promise { + bindings.set(url, await bindBotScreenStorage(storage(), choice)); +} + +/** Removing the document (leaving the miniapp) ends whatever it was allowed to store. */ +export function releaseWebBotScreenStorage(url: string): void { + bindings.delete(url); +} + +export const webBotStorage: BotStorageApi = { + documentState: (url) => botScreenStorageState(storage(), bindings.get(url) ?? null), + documentRequest: (url, request) => + answerBotScreenStorageRequest(storage(), bindings.get(url) ?? null, request, () => rates.allow(url)), + list: () => storage().list(), + get: (scope) => storage().entry(scope), + setDecision: (scope, decision) => storage().setDecision(scope, decision), + setQuota: (scope, maxBytes) => storage().setQuota(scope, maxBytes), + clear: (scopeId) => storage().clear(scopeId), + forget: (scopeId) => storage().forget(scopeId), + onChanged: (callback) => { + listeners.add(callback); + return () => { listeners.delete(callback); }; + }, +}; + +/** Leaving the account removes what every bot kept in this browser, after the operations in progress end. */ +export async function wipeWebBotStorage(): Promise { + const current = engine; + engine = null; + bindings.clear(); + rates.clear(); + await current?.dispose(); + await deleteRecordsWithPrefix(PREFIX); +} diff --git a/apps/client/src/renderer/platform/web/webPlatform.ts b/apps/client/src/renderer/platform/web/webPlatform.ts index 83080127..4f31320f 100644 --- a/apps/client/src/renderer/platform/web/webPlatform.ts +++ b/apps/client/src/renderer/platform/web/webPlatform.ts @@ -5,7 +5,7 @@ import { type EditorCommand, type FileBatchDownloadFile, type FileBatchDownloadRequest, type FileBatchDownloadResult, type HostServerStatus, type LinkPreviewData, type ServerInviteResult, } from '@monky/shared'; -import type { BotStorageApi, PlatformApi, PlatformCapabilities } from '../api'; +import type { PlatformApi, PlatformCapabilities } from '../api'; import { t } from '../../i18n'; import { IMAGE_ACCEPT, base64ToBytes, bytesToBase64, pickFiles, readImageSelection, saveBlob, saveText } from './webFiles'; import { @@ -19,6 +19,7 @@ import { import { embeddedReleaseRequest } from './webReleases'; import { createWebMediaLibrary, supportsDirectoryPicker } from './webMediaLibrary'; import { createWebBotScreenDocument, removeWebBotScreenDocument, webBotScreenDocumentHost } from './webBotScreens'; +import { webBotStorage, wipeWebBotStorage } from './webBotStorage'; import { createEventCalendar } from '../../utils/eventCalendar'; import { clearWebLogs, exportWebLogs, getWebLogConfig, getWebLogSize, setWebLogConfig, writeWebLog } from './webClientLog'; import { WebScreenRuntime, webScreenCapabilities } from './webScreen/webScreenRuntime'; @@ -79,18 +80,6 @@ const never = (): (() => void) => () => {}; const NO_HOSTED_SERVERS: HostServerStatus = { servers: [] }; -const webBotStorage: BotStorageApi = { - documentState: unsupported('Bot storage'), - documentRequest: unsupported('Bot storage'), - list: () => Promise.resolve([]), - get: unsupported('Bot storage'), - setDecision: unsupported('Bot storage'), - setQuota: unsupported('Bot storage'), - clear: unsupported('Bot storage'), - forget: unsupported('Bot storage'), - onChanged: never, -}; - /** Only web pages leave the app, in a new tab that cannot reach back into it. */ function openExternal(url: string): Promise<{ success: boolean }> { let parsed: URL; @@ -217,6 +206,7 @@ async function logOut(): Promise<{ success: boolean; error?: string }> { try { await deleteWebIdentity(); await wipeWebDirectMessages(); + await wipeWebBotStorage(); // The desktop relaunches; a tab starts over by loading the page again. setTimeout(() => window.location.reload(), 0); return { success: true }; diff --git a/apps/client/src/renderer/platform/web/webScreen/webScreenRuntime.ts b/apps/client/src/renderer/platform/web/webScreen/webScreenRuntime.ts index e666c036..87b6b38a 100644 --- a/apps/client/src/renderer/platform/web/webScreen/webScreenRuntime.ts +++ b/apps/client/src/renderer/platform/web/webScreen/webScreenRuntime.ts @@ -241,8 +241,8 @@ export class WebScreenRuntime { throw new Error('The capture kind does not match what the browser captured.'); if (command.audio && (!capture.audioTrack || !capabilities.captureAudio)) throw new Error('The browser did not share sound with this capture.'); - // The compatible format unless the publisher chose a better one this browser encodes; both are H.264. - const format = command.screenFormat ?? 'compatible'; + // The better-quality format unless the publisher chose the compatible one (#890); both are H.264. + const format = command.screenFormat ?? 'main'; if (!capabilities.browserFormats?.includes(format)) throw new Error(`This browser cannot share in the ${format} format.`); call.admitting.add(command.shareId); try { diff --git a/apps/client/src/renderer/platform/web/webStorage.ts b/apps/client/src/renderer/platform/web/webStorage.ts index 5158f296..692351e7 100644 --- a/apps/client/src/renderer/platform/web/webStorage.ts +++ b/apps/client/src/renderer/platform/web/webStorage.ts @@ -58,6 +58,12 @@ function prefixRange(prefix: string): IDBKeyRange { return IDBKeyRange.bound(prefix, `${prefix}\uffff`); } +/** The keys that start with `prefix`, without reading their values. */ +export async function listRecordKeysWithPrefix(prefix: string): Promise { + const keys = await requestResult((await store('readonly')).getAllKeys(prefixRange(prefix))); + return keys.filter((key): key is string => typeof key === 'string'); +} + /** Every record whose key starts with `prefix`. */ export async function readRecordsWithPrefix(prefix: string): Promise> { const records = await store('readonly'); diff --git a/apps/client/src/renderer/stores/settingsStore.ts b/apps/client/src/renderer/stores/settingsStore.ts index 38db3b67..2ceda419 100644 --- a/apps/client/src/renderer/stores/settingsStore.ts +++ b/apps/client/src/renderer/stores/settingsStore.ts @@ -27,7 +27,7 @@ import { autoEntryServerKey, restoreAutoEntryServerKeys, type AutoEntryServerAdd import { normalizeCustomQualityProfile } from '../utils/qualityProfileLimits'; import { platformSupports } from '../platform/capabilities'; -const defaultScreenFormat = (): ScreenFormat => platformSupports('browserScreenPicker') ? 'compatible' : 'main'; +const DEFAULT_SCREEN_FORMAT: ScreenFormat = 'main'; /** * Chat-notification-sound mode for the 3-level configuration (#153). @@ -105,6 +105,7 @@ function savedSettings(store: SettingsStore) { updateBetaChannel: store.updateBetaChannel, minimizeToTrayOnClose: store.minimizeToTrayOnClose, askShutdownOnLastLeave: store.askShutdownOnLastLeave, + warnHostedServerReach: store.warnHostedServerReach, appearOffline: store.appearOffline, brb: store.brb, customStatus: store.customStatus, @@ -207,8 +208,8 @@ export class SettingsStore { public screenEncodingMode: ScreenEncodingMode = 'hardware'; public screenEncodingStrategy: ScreenEncodingStrategy = 'automatic'; public preferredScreenCodec: ScreenCodec = 'h264'; - /** The H.264 screens are shared in (#858): every viewer watches the web's by default; the desktop keeps Main. */ - public screenFormat: ScreenFormat = defaultScreenFormat(); + /** The H.264 profile screens are shared in (#858): better quality by default, everywhere; AV1 ignores it (#890). */ + public screenFormat: ScreenFormat = DEFAULT_SCREEN_FORMAT; public customProfile: QualityProfile = { ...DEFAULT_CUSTOM_PROFILE }; public inputMode: 'voice_activity' | 'push_to_talk' = 'voice_activity'; // #186 public pttKey: PttKeyBinding = { code: 'KeyV', display: 'V', keyType: 'keyboard', keyCode: 47 }; @@ -281,6 +282,9 @@ export class SettingsStore { // Ask whether to shut the local server down when its owner is the last one // leaving (#334). Turned off from the prompt itself or in Settings. public askShutdownOnLastLeave: boolean = true; + // Before creating a server, warn that friends only reach it over a VPN, the + // local network or forwarded ports (#877). Turned off from the warning or in Settings. + public warnHostedServerReach: boolean = true; // Appear offline to other users on all servers (#561). public appearOffline: boolean = false; // "Volto já" status (user-brb). Never combined with appearOffline. @@ -332,7 +336,7 @@ export class SettingsStore { this.screenEncodingMode = 'hardware'; this.screenEncodingStrategy = 'automatic'; this.preferredScreenCodec = 'h264'; - this.screenFormat = defaultScreenFormat(); + this.screenFormat = DEFAULT_SCREEN_FORMAT; this.screenShareReceiver = 'native'; this.soundboardLimiterEnabled = false; this.soundboardLoudnessLimit = 6; @@ -359,7 +363,7 @@ export class SettingsStore { this.preferredScreenCodec = parsed.preferredScreenCodec === 'av1' ? 'av1' : 'h264'; // v41.5.4 saved the web's choice as browserScreenFormat. const screenFormat = screenFormatSchema.safeParse(parsed.screenFormat ?? parsed.browserScreenFormat); - this.screenFormat = screenFormat.success ? screenFormat.data : defaultScreenFormat(); + this.screenFormat = screenFormat.success ? screenFormat.data : DEFAULT_SCREEN_FORMAT; this.screenEncodingStrategy = parsed.screenEncodingStrategy === 'manual' ? 'manual' : parsed.screenEncodingStrategy === undefined && (parsed.screenEncodingMode === 'software' || parsed.preferredScreenCodec === 'h264' @@ -495,6 +499,9 @@ export class SettingsStore { if (typeof this.askShutdownOnLastLeave !== 'boolean') { this.askShutdownOnLastLeave = true; } + if (typeof this.warnHostedServerReach !== 'boolean') { + this.warnHostedServerReach = true; + } if (typeof this.appearOffline !== 'boolean') { this.appearOffline = false; } diff --git a/apps/client/src/renderer/styles/theme.css b/apps/client/src/renderer/styles/theme.css index 2d64352c..f024a627 100644 --- a/apps/client/src/renderer/styles/theme.css +++ b/apps/client/src/renderer/styles/theme.css @@ -11829,6 +11829,18 @@ button.member-live-badge:focus-visible { overflow-wrap: anywhere; } +/* A docs page that explains the dialog further; it opens outside and the dialog stays. */ +.dialog-link-row { + margin: 12px 0 0; +} + +.dialog-link { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; +} + /* Mutually exclusive choice inside a confirmation, as selectable cards. */ .dialog-choices { margin-top: 16px; diff --git a/apps/client/src/renderer/utils/docsLinks.ts b/apps/client/src/renderer/utils/docsLinks.ts new file mode 100644 index 00000000..5e14ebf9 --- /dev/null +++ b/apps/client/src/renderer/utils/docsLinks.ts @@ -0,0 +1,23 @@ +import { getLanguage, type SupportedLanguage } from '../i18n'; + +const DOCS_BASE: Readonly> = { + 'pt-BR': 'https://monkyorg.github.io/Monky/', + en: 'https://monkyorg.github.io/Monky/en/', +}; + +/** Pages of the documentation the app points to. Anchors differ per language: headings are translated. */ +const DOCS_PAGES = { + /** Friends reaching a server hosted here: VPN, ports and the automatic secure connection (#874, #877). */ + reachServer: { + 'pt-BR': 'criar-seu-servidor#liberar-acesso-pela-internet', + en: 'criar-seu-servidor#open-access-over-the-internet', + }, +} as const satisfies Readonly>>>; + +export type DocsPage = keyof typeof DOCS_PAGES; + +/** The documentation page in the app's language. */ +export function docsUrl(page: DocsPage): string { + const language = getLanguage(); + return DOCS_BASE[language] + DOCS_PAGES[page][language]; +} diff --git a/apps/client/src/renderer/utils/hostedServer.ts b/apps/client/src/renderer/utils/hostedServer.ts index d32c9597..e89dd729 100644 --- a/apps/client/src/renderer/utils/hostedServer.ts +++ b/apps/client/src/renderer/utils/hostedServer.ts @@ -3,6 +3,7 @@ import { settingsStore } from '../stores/settingsStore'; import { showConfirm, showConfirmWithOption } from '../views/Dialog'; import { t, tCount } from '../i18n'; import { isLoopbackHost, parseServerUrl } from './serverAddress'; +import { docsUrl } from './docsLinks'; /** * Number of people connected to the server this app hosts on `port`, or null @@ -35,6 +36,31 @@ export async function confirmStopHostedServer(port: number): Promise { }); } +/** + * Before the first server is created, explains that friends only reach it over + * a VPN, the same local network or forwarded ports (#877). Returns whether to + * go on; "don't show again" turns the warning off, Settings turns it back on. + */ +export async function confirmHostedServerReach(): Promise { + if (!settingsStore.warnHostedServerReach) return true; + + const { confirmed, checked } = await showConfirmWithOption({ + title: t('hostedServer.reachTitle'), + message: t('hostedServer.reachMessage'), + confirmLabel: t('hostedServer.reachConfirm'), + variant: 'info', + link: { label: t('hostedServer.reachDocs'), url: docsUrl('reachServer') }, + checkboxLabel: t('hostedServer.dontShowAgain'), + }); + + // Only a confirmed warning counts as read: cancelling keeps it for next time. + if (confirmed && checked) { + settingsStore.warnHostedServerReach = false; + settingsStore.save(); + } + return confirmed; +} + /** * Snapshot taken *before* leaving a server: once the socket is gone there is no * way to tell whether the user was hosting it or whether anyone else was still diff --git a/apps/client/src/renderer/utils/screenShareFailure.ts b/apps/client/src/renderer/utils/screenShareFailure.ts index 6fdc6c5c..a11922a1 100644 --- a/apps/client/src/renderer/utils/screenShareFailure.ts +++ b/apps/client/src/renderer/utils/screenShareFailure.ts @@ -25,6 +25,7 @@ const CATEGORIES: Readonly> = permission: 'screenShare.failure.permission', 'source-lost': 'screenShare.failure.source-lost', files: 'screenShare.failure.files', + 'no-gpu': 'screenShare.failure.no-gpu', encoder: 'screenShare.failure.encoder', quality: 'screenShare.failure.quality', capacity: 'screenShare.nativeFailure.capacity-exceeded', @@ -34,13 +35,8 @@ const CATEGORIES: Readonly> = const IPC_REJECTION = /^Error invoking remote method '/; -/** - * The message a screen-sharing failure shows (#490): what happened and what the person can do, never the - * technical text. A message the app already wrote for the person is kept; native codes Main sent along an - * IPC rejection pick a category; anything else reads as unexpected. The technical detail Main logged stays - * in its log, and what only the renderer knows goes to the client log here. - */ -export function screenShareFailureMessage(error: unknown, action: ScreenShareFailureAction): string { +/** The native codes of a failure, including those Main wrote along an IPC rejection, and any message already explained. */ +function readFailure(error: unknown): { chain: unknown[]; codes: string[]; reason: string | null } { const chain = errorChain(error); const codes = new Set(nativeErrorCodes(error)); let reason: string | null = null; @@ -51,9 +47,25 @@ export function screenShareFailureMessage(error: unknown, action: ScreenShareFai if (detail.notice) { reason = detail.notice; break; } for (const code of detail.codes) codes.add(code); } - const category = screenShareFailureCategory([...codes]); + return { chain, codes: [...codes], reason }; +} + +/** True when the failure means this computer has no graphics card native screen sharing can use. */ +export function isNoGpuScreenFailure(error: unknown): boolean { + return screenShareFailureCategory(readFailure(error).codes) === 'no-gpu'; +} + +/** + * The message a screen-sharing failure shows (#490): what happened and what the person can do, never the + * technical text. A message the app already wrote for the person is kept; native codes Main sent along an + * IPC rejection pick a category; anything else reads as unexpected. The technical detail Main logged stays + * in its log, and what only the renderer knows goes to the client log here. + */ +export function screenShareFailureMessage(error: unknown, action: ScreenShareFailureAction): string { + const { chain, codes, reason } = readFailure(error); + const category = screenShareFailureCategory(codes); clientLog.warn('SCREEN_SHARE', `Screen share ${action} failed`, { - category: reason === null ? category : 'explained', codes: [...codes], + category: reason === null ? category : 'explained', codes, // Main already logged what crossed the IPC, keeping only allowlisted fields of its messages. errors: chain.slice(0, 4).map(current => current instanceof Error ? { name: current.name, ...(IPC_REJECTION.test(current.message) ? {} : { message: current.message.slice(0, 300) }) } diff --git a/apps/client/src/renderer/utils/secureServer.ts b/apps/client/src/renderer/utils/secureServer.ts new file mode 100644 index 00000000..6fe2fa01 --- /dev/null +++ b/apps/client/src/renderer/utils/secureServer.ts @@ -0,0 +1,26 @@ +import { t } from '../i18n'; +import { platformSupports } from '../platform/capabilities'; +import { isLoopbackHost, isSecureServer } from './serverAddress'; + +/** + * The web version only reaches servers with a secure connection (wss://), and + * this one has none or did not answer over it (#858). Its owner has to turn the + * secure connection on, so the app explains that and links the docs (#874). + */ +export class SecureServerRequiredError extends Error { + constructor(message: string) { + super(message); + this.name = 'SecureServerRequiredError'; + } +} + +/** Whether this page can only reach the server over a secure connection: on the web, anything but this machine. */ +export function needsSecureConnection(host: string): boolean { + return !platformSupports('plainRemoteServers') && !isLoopbackHost(host); +} + +/** The error for a server this page cannot even dial, or null when it can try. */ +export function insecureServerError(host: string, port: number): SecureServerRequiredError | null { + if (!needsSecureConnection(host) || isSecureServer({ host, port })) return null; + return new SecureServerRequiredError(t('network.webUnsupportedServer', { host, port })); +} diff --git a/apps/client/src/renderer/utils/secureServerHelp.ts b/apps/client/src/renderer/utils/secureServerHelp.ts new file mode 100644 index 00000000..daa3f2c3 --- /dev/null +++ b/apps/client/src/renderer/utils/secureServerHelp.ts @@ -0,0 +1,20 @@ +import { t } from '../i18n'; +import { showAlert } from '../views/Dialog'; +import { docsUrl } from './docsLinks'; +import { SecureServerRequiredError } from './secureServer'; + +/** + * Shows the friendly explanation, with the docs link, when the failure was a + * missing secure connection (#874). Returns false for any other error, which + * the caller reports as before. + */ +export async function showSecureServerHelp(error: unknown): Promise { + if (!(error instanceof SecureServerRequiredError)) return false; + await showAlert({ + title: t('network.secureRequiredTitle'), + message: error.message, + variant: 'warning', + link: { label: t('network.secureRequiredDocs'), url: docsUrl('reachServer') }, + }); + return true; +} diff --git a/apps/client/src/renderer/views/Dialog.ts b/apps/client/src/renderer/views/Dialog.ts index 9fbbd4f8..0c75a0f5 100644 --- a/apps/client/src/renderer/views/Dialog.ts +++ b/apps/client/src/renderer/views/Dialog.ts @@ -1,15 +1,22 @@ import { escapeHtml } from '../utils/html'; import { t } from '../i18n'; import { openModal } from '../utils/modalShell'; -import { reportError } from '../utils/feedback'; +import { openExternalUrl, reportError } from '../utils/feedback'; type DialogVariant = 'info' | 'warning' | 'danger' | 'success'; +/** A page that explains the dialog further, opened in the browser while the dialog stays. */ +interface DialogLink { + label: string; + url: string; +} + interface AlertOptions { title?: string; message: string; okLabel?: string; variant?: DialogVariant; + link?: DialogLink; } interface ConfirmOptions { @@ -17,6 +24,7 @@ interface ConfirmOptions { message: string; /** A short remark below the message. */ note?: string; + link?: DialogLink; confirmLabel?: string; cancelLabel?: string; variant?: DialogVariant; @@ -77,6 +85,7 @@ function buildDialog(params: { choices?: DialogChoices; /** A short remark below the message, e.g. what was already decided elsewhere. */ note?: string; + link?: DialogLink; signal?: AbortSignal; requireUserGesture?: boolean; focusCancel?: boolean; @@ -103,6 +112,10 @@ function buildDialog(params: { ${params.message ? `
${escapeHtml(params.message)}
` : ''} ${params.note ? `

${escapeHtml(params.note)}

` : ''} + ${params.link ? `` : ''} ${params.choices ? `

${escapeHtml(params.choices.legend)}

${params.choices.description ? `

${escapeHtml(params.choices.description)}

` : ''} @@ -210,12 +223,22 @@ function buildDialog(params: { settle(false); return; } + if (e.target instanceof HTMLElement && e.target.dataset.action === 'open-link') { + e.target.click(); + return; + } settle(true); } confirmButton?.addEventListener('click', (event) => { if (!params.requireUserGesture || event.isTrusted) settle(true); }, { signal: shell.signal }); + const link = params.link; + if (link) { + card.querySelector('[data-action="open-link"]')?.addEventListener('click', () => { + void openExternalUrl(link.url, t('settings.linkOpenFailed', { url: link.url })); + }, { signal: shell.signal }); + } inputs.forEach(field => field.addEventListener('input', () => validateInput('changes'), { signal: shell.signal })); if (shell.closed) return; @@ -236,6 +259,7 @@ export function showAlert(options: AlertOptions): Promise { confirmLabel: options.okLabel ?? t('common.ok'), cancelLabel: '', confirmClass: options.variant === 'danger' ? 'btn-danger' : 'btn-primary', + link: options.link, onResolve: () => resolve(), }); }); @@ -249,6 +273,7 @@ export function showConfirm(options: ConfirmOptions): Promise { focusCancel: options.focusCancel, message: options.message, note: options.note, + link: options.link, variant: options.variant ?? 'warning', showCancel: true, confirmLabel: options.confirmLabel ?? t('common.confirm'), @@ -298,6 +323,8 @@ export function showConfirmWithOption( buildDialog({ title: options.title ?? t('dialog.confirmTitle'), message: options.message, + note: options.note, + link: options.link, variant: options.variant ?? 'warning', showCancel: true, confirmLabel: options.confirmLabel ?? t('common.confirm'), diff --git a/apps/client/src/renderer/views/InviteModal.ts b/apps/client/src/renderer/views/InviteModal.ts index 26767c60..a33e1517 100644 --- a/apps/client/src/renderer/views/InviteModal.ts +++ b/apps/client/src/renderer/views/InviteModal.ts @@ -4,7 +4,7 @@ import { getActiveNetworkClient, networkClient, type NetworkClient } from '../co import { serverStore } from '../stores/serverStore'; import { connectionStore } from '../stores/connectionStore'; import { sessionManager } from '../core/SessionManager'; -import { findOwnedServer } from '../core/hostedServerStart'; +import { knownServerPassword } from '../core/hostedServerStart'; import { escapeHtml } from '../utils/html'; import { getLanguage, t } from '../i18n'; import { openModal, type ModalShell } from '../utils/modalShell'; @@ -98,8 +98,7 @@ export class InviteModal { this.networkInterfaces = []; const active = sessionManager.getActive(); - this.knownPassword = active?.password - || (active ? findOwnedServer(active.host, active.port)?.password : undefined); + this.knownPassword = active ? knownServerPassword(active) : undefined; this.serverKey = active ? connectionStore.serverKeyExpectation(active).pinned : undefined; const shell = openModal({ diff --git a/apps/client/src/renderer/views/JoinInviteModal.ts b/apps/client/src/renderer/views/JoinInviteModal.ts index 713d6a7f..f9111afc 100644 --- a/apps/client/src/renderer/views/JoinInviteModal.ts +++ b/apps/client/src/renderer/views/JoinInviteModal.ts @@ -7,6 +7,7 @@ import { t } from '../i18n'; import { openModal, type ModalCloseReason } from '../utils/modalShell'; import { escapeHtml } from '../utils/html'; import { errorMessage, reportError, validateForm } from '../utils/feedback'; +import { showSecureServerHelp } from '../utils/secureServerHelp'; import { nicknameError } from '../utils/nickname'; import { setServerTransport } from '../utils/serverAddress'; @@ -152,7 +153,8 @@ export class JoinInviteModal { await window.api.maximize?.().catch(() => clientLog.warn('APP', 'Could not maximize after joining the invited server')); } catch (error: unknown) { clientLog.warn('CONNECTION', 'Could not join the invited server'); - if (!shell.closed) reportError(errorMessage(error, t('connection.connectError'))); + if (shell.closed) return; + if (!(await showSecureServerHelp(error))) reportError(errorMessage(error, t('connection.connectError'))); } finally { connecting = false; if (!shell.closed) { diff --git a/apps/client/src/renderer/views/ScreenEncodingControls.ts b/apps/client/src/renderer/views/ScreenEncodingControls.ts index ccb29bf8..0c774bea 100644 --- a/apps/client/src/renderer/views/ScreenEncodingControls.ts +++ b/apps/client/src/renderer/views/ScreenEncodingControls.ts @@ -11,6 +11,7 @@ import { FPS_OPTIONS } from './settings/qualityOptions'; import { showInfoToast } from './CopyToast'; import { platformSupports } from '../platform/capabilities'; import { isDesktopOnly, lockedBlock } from '../utils/desktopOnly'; +import { isNoGpuScreenFailure } from '../utils/screenShareFailure'; type CheckedProfile = { profile: NativeScreenVideoProfile; @@ -45,6 +46,8 @@ export class ScreenEncodingControls { private captureKnown: boolean | null = null; // Without native capture there is no encoder to check; the controls say so instead. private captureUnavailable = false; + // Why capture is unavailable, when a check found out (no usable graphics card); empty for the generic reason. + private captureUnavailableReason = ''; constructor(private readonly profile: () => NativeScreenVideoProfile | null, private readonly changed: () => void = () => {}, @@ -183,19 +186,26 @@ export class ScreenEncodingControls { }, { signal: this.events?.signal }); } - // The compatible format is always H.264 (#858): the codec choice waits for the better-quality format. private savedPreferences(): ScreenEncodingPreferences { - return { encodingStrategy: settingsStore.screenEncodingStrategy, encodingMode: settingsStore.screenEncodingMode, - codec: settingsStore.screenFormat === 'compatible' ? 'h264' : settingsStore.preferredScreenCodec }; + return { encodingStrategy: settingsStore.screenEncodingStrategy, + encodingMode: settingsStore.screenEncodingMode, codec: settingsStore.preferredScreenCodec }; } - private preferences(): ScreenEncodingPreferences { - const preferences = this.requested ?? this.savedPreferences(); - return settingsStore.screenFormat === 'compatible' ? { ...preferences, codec: 'h264' } : preferences; + private preferences(): ScreenEncodingPreferences { return this.requested ?? this.savedPreferences(); } + + /** + * The codec screens are sent in, as these controls show it: the manual choice, what Automatic resolved, or + * H.264 where the browser encodes. Null while Automatic is still resolving or nothing can be encoded (#890). + */ + get transmittedCodec(): ScreenCodec | null { + if (isDesktopOnly('screenEncoderChoice')) return 'h264'; + const preferences = this.preferences(); + if (preferences.encodingStrategy === 'manual') return preferences.codec; + return this.pending ? null : this.availability?.selection?.codec ?? null; } private key(profile = this.profile(), preferences = this.preferences()): string { - return JSON.stringify([profile, settingsStore.screenFormat, preferences.encodingStrategy, + return JSON.stringify([profile, preferences.encodingStrategy, ...(preferences.encodingStrategy === 'automatic' ? [] : [preferences.encodingMode, preferences.codec])]); } @@ -370,6 +380,15 @@ export class ScreenEncodingControls { checkedCurrent = true; console.warn('[ScreenEncoding] Encoder verification failed:', error); this.availability = null; + if (profile && isNoGpuScreenFailure(error)) { + // No encoder can run here, so retrying or checking alternatives would only fail again. + this.captureKnown = false; + this.captureUnavailable = true; + this.captureUnavailableReason = t('screenShare.failure.no-gpu'); + this.settleWithoutCapture(profile, applyRequested); + this.clearToast = showInfoToast(this.captureUnavailableReason); + return; + } this.error = t('settings.screenEncodingProbeFailed'); if (!automatic) this.choices.set(`${mode}:${codec}`, { error: this.error }); this.requested = !automatic && this.savedPreferences().encodingStrategy === 'automatic' ? preferences : null; @@ -425,7 +444,7 @@ export class ScreenEncodingControls { private async discoverChoices(probe: AbortController): Promise { const profile = this.profile(); - if (!profile) return; + if (!profile || this.captureUnavailable) return; const profileKey = JSON.stringify(profile), key = this.key(profile), saved = JSON.stringify(this.savedPreferences()); if (this.choicesProfile !== profileKey) { this.choices.clear(); @@ -444,8 +463,7 @@ export class ScreenEncodingControls { this.render(); try { for (const mode of ['hardware', 'software'] as const) { - // The compatible format has no AV1 to check (#858). - for (const codec of settingsStore.screenFormat === 'compatible' ? ['h264'] as const : ['h264', 'av1'] as const) { + for (const codec of ['h264', 'av1'] as const) { if (!current() || defer()) return; if (this.choice(mode, codec)) continue; let result: CheckedEncoding; @@ -504,15 +522,13 @@ export class ScreenEncodingControls { } const codec = this.container?.querySelector('#select-video-codec'); if (codec) { - const compatible = settingsStore.screenFormat === 'compatible'; - codec.disabled = unavailable || automatic || compatible; - codec.setAttribute('aria-readonly', String(unavailable || automatic || compatible)); + codec.disabled = unavailable || automatic; + codec.setAttribute('aria-readonly', String(unavailable || automatic)); for (const value of ['h264', 'av1'] as const) { const option = codec.querySelector(`option[value="${value}"]`); if (!option) continue; const choice = this.choice(preferences.encodingMode, value); - option.disabled = (compatible && value !== 'h264') - || (!automatic && !!choice && !('error' in choice) && !choice.availability.selection); + option.disabled = !automatic && !!choice && !('error' in choice) && !choice.availability.selection; const label = t(value === 'av1' ? 'settings.codecAv1' : 'settings.codecH264'); const explanation = automatic ? '' : this.choiceText(preferences.encodingMode, value, choice); option.textContent = !automatic && (!choice || 'error' in choice || !choice.availability.selection) @@ -521,7 +537,7 @@ export class ScreenEncodingControls { ? t('settings.screenEncodingRetry') : t('screenShare.unavailable')}` : label; option.title = explanation; } - codec.value = compatible ? 'h264' : automatic ? selection?.codec ?? '' : preferences.codec; + codec.value = automatic ? selection?.codec ?? '' : preferences.codec; if (!automatic && codec.querySelector(`option[value="${preferences.codec}"]`)?.disabled) codec.value = ''; } for (const mode of ['hardware', 'software'] as const) { @@ -543,7 +559,8 @@ export class ScreenEncodingControls { const status = this.container?.querySelector('#screen-encoding-status'); if (status) { status.setAttribute('aria-busy', String(this.pending)); - status.textContent = this.pending ? t('common.loading') : unavailable ? t('settings.screenEncodingCaptureUnavailable') : this.error + status.textContent = this.pending ? t('common.loading') : unavailable + ? this.captureUnavailableReason || t('settings.screenEncodingCaptureUnavailable') : this.error ? this.error : !selection ? t('settings.screenEncodingProfileUnavailable', { codec: settingsStore.preferredScreenCodec.toUpperCase(), diff --git a/apps/client/src/renderer/views/ServerRailView.ts b/apps/client/src/renderer/views/ServerRailView.ts index 4c94128b..e7876c90 100644 --- a/apps/client/src/renderer/views/ServerRailView.ts +++ b/apps/client/src/renderer/views/ServerRailView.ts @@ -17,6 +17,8 @@ import { } from '../stores/connectionStore'; import { showConfirm, showAlert, showTextForm } from './Dialog'; import { reportError } from '../utils/feedback'; +import { insecureServerError } from '../utils/secureServer'; +import { showSecureServerHelp } from '../utils/secureServerHelp'; import { checkServerOnline, fetchServerPreview } from '../utils/serverStatus'; import { confirmStopHostedServer, @@ -1188,6 +1190,7 @@ export class ServerRailView { try { await this.runConnectToSavedServer(server, isCurrent); } catch (error: unknown) { + if (await showSecureServerHelp(error)) return; await showAlert({ title: t('main.serverOfflineTitle'), message: error instanceof Error && error.message ? error.message : t('connection.connectError'), @@ -1200,6 +1203,9 @@ export class ServerRailView { private async runConnectToSavedServer(server: SavedServer, isCurrent: () => boolean): Promise { assertServerBrowseAvailable(server.host, server.port); + // The web cannot even ask a server without TLS whether it is online (#874). + const insecure = insecureServerError(server.host, server.port); + if (insecure) throw insecure; // Probe without changing the visible session or the ongoing call. const online = await checkServerOnline(server.host, server.port); if (!isCurrent()) return; @@ -1253,6 +1259,7 @@ export class ServerRailView { lastConnected: Date.now(), }); } catch (err: unknown) { + if (await showSecureServerHelp(err)) return; const message = err instanceof Error && err.message ? err.message : t('main.serverOfflineMessage', { name: server.name || server.host }); diff --git a/apps/client/src/renderer/views/ServerSettingsModal.ts b/apps/client/src/renderer/views/ServerSettingsModal.ts index 925a50ff..8b6295bf 100644 --- a/apps/client/src/renderer/views/ServerSettingsModal.ts +++ b/apps/client/src/renderer/views/ServerSettingsModal.ts @@ -58,6 +58,7 @@ export class ServerSettingsModal { private domEvents = new AbortController(); private bindings: FieldBinding[] = []; private generalTab = new ServerGeneralTab(); + private securityTab = new ServerSecurityTab(); private storageTab = new ServerStorageTab(); private rolesTab = new ServerRolesTab(); private botsTab = new ServerBotsTab(); @@ -102,7 +103,7 @@ export class ServerSettingsModal { const tabs: Array = [ { id: 'general', icon: 'tune', label: 'serverSettings.tabGeneral', permission: undefined, html: this.generalTab.renderHtml(this.communitySettings) }, - { id: 'security', icon: 'lock', label: 'serverSettings.tabSecurity', permission: Permission.MANAGE_SERVER, html: new ServerSecurityTab().renderHtml() }, + { id: 'security', icon: 'lock', label: 'serverSettings.tabSecurity', permission: Permission.MANAGE_SERVER, html: this.securityTab.renderHtml() }, { id: 'voice_video', icon: 'music_note', label: 'serverSettings.tabVoiceVideo', permission: Permission.MANAGE_SERVER, html: new ServerVoiceVideoTab().renderHtml() }, { id: 'storage', icon: 'cloud', label: 'serverSettings.tabStorage', permission: Permission.MANAGE_SERVER, html: this.storageTab.renderHtml() }, { id: 'notifications', icon: 'notifications', label: 'serverSettings.tabNotifications', permission: undefined, html: new ServerNotificationsTab().renderHtml() }, @@ -208,8 +209,8 @@ export class ServerSettingsModal { () => String(details()?.maxMessageLength || LIMITS.MAX_MESSAGE_LENGTH), messageLimitPatch); this.bindSetting('#input-server-name', 'name', 'serverSettings.nameLabel', () => details()?.name ?? '', (value) => ({ name: value.trim() })); - this.bindSetting('#input-server-pass', 'password', 'invite.passwordLabel', () => '', - (value) => value ? { password: value } : null); + this.securityTab.attach(root, context, + (password) => this.applyPatch('password', t('serverSettings.passwordSection'), { password }), this.domEvents.signal); const memberPatch = (): ServerUpdateSettingsPayload => { const limited = root.querySelector('#checkbox-limit-members')?.checked; const value = Number(root.querySelector('#input-max-users')?.value); @@ -277,9 +278,6 @@ export class ServerSettingsModal { const inputName = root.querySelector('#input-server-name'); const emojiButton = root.querySelector('#btn-emoji-server-name'); if (inputName && emojiButton) this.unbind.push(attachInputEmojiPicker(inputName, emojiButton)); - root.querySelector('#btn-remove-pass')?.addEventListener('click', () => { - this.applyPatch('password', t('invite.passwordLabel'), { password: null }); - }, options); root.querySelector('#server-icon-wrapper')?.addEventListener('click', () => { if (context.operations.isPending('icon')) return; void context.operations.run('icon', t('serverSettings.iconAlt'), Permission.MANAGE_SERVER, async () => { @@ -362,10 +360,11 @@ export class ServerSettingsModal { }, options); } - private applyPatch(key: string, label: string, patch: ServerUpdateSettingsPayload): void { + /** Resolves whether the edit went through: declined, refused and failed edits resolve false. */ + private applyPatch(key: string, label: string, patch: ServerUpdateSettingsPayload): Promise { const context = this.context; - if (!context) return; - void context.operations.run(key, label, Permission.MANAGE_SERVER, async () => { + if (!context) return Promise.resolve(false); + return context.operations.run(key, label, Permission.MANAGE_SERVER, async () => { const persisted = context.store.serverDetails; if (!persisted) throw new Error(t('serverSettings.sessionChanged')); const error = serverSettingsValidationError(patch, persisted, context.store.knownMembers.size); @@ -377,7 +376,7 @@ export class ServerSettingsModal { confirmLabel: t('serverSettings.voiceModeDisconnectConfirm'), cancelLabel: t('common.cancel'), variant: 'warning', }); - if (!confirmed) return; + if (!confirmed) return false; } // Turning the AFK channel off deletes the room and drops whoever is in it. if (patch.afkChannelEnabled === false && afkChannelIdOf(persisted)) { @@ -387,7 +386,17 @@ export class ServerSettingsModal { confirmLabel: t('serverSettings.afkDisableConfirm'), cancelLabel: t('common.cancel'), variant: 'danger', }); - if (!confirmed) return; + if (!confirmed) return false; + } + // Removing the password lets anyone with the address in. + if (patch.password === null && persisted.hasPassword) { + const confirmed = await showConfirm({ + title: t('serverSettings.removePasswordTitle'), + message: t('serverSettings.removePasswordMessage'), + confirmLabel: t('serverSettings.removePassword'), + cancelLabel: t('common.cancel'), variant: 'danger', + }); + if (!confirmed) return false; } const stopProgress = patch.turnEnabled && !persisted.turnAvailability?.supported ? this.trackInstallProgress(context) : null; @@ -400,7 +409,8 @@ export class ServerSettingsModal { } finally { stopProgress?.(); } - }); + return true; + }).then((result) => result.ok && result.value); } private refreshState(): void { @@ -472,21 +482,7 @@ export class ServerSettingsModal { const icon = root.querySelector('#server-icon-preview'); if (icon) icon.src = s.iconUrl ? getAvatarUrl(s.iconUrl) : logoUrl; root.querySelector('#server-icon-wrapper')?.setAttribute('aria-busy', String(operations.isPending('icon'))); - const removePassword = root.querySelector('#btn-remove-pass'); - if (removePassword) removePassword.hidden = !s.hasPassword; - const passwordTitle = root.querySelector('#password-status-title'); - if (passwordTitle) passwordTitle.textContent = t(s.hasPassword ? 'serverSettings.statusProtected' : 'serverSettings.statusOpen'); - const passwordDescription = root.querySelector('#password-status-desc'); - if (passwordDescription) passwordDescription.textContent = t(s.hasPassword ? 'serverSettings.statusProtectedDesc' : 'serverSettings.statusOpenDesc'); - const passwordLabel = root.querySelector('#label-password-field'); - if (passwordLabel) passwordLabel.textContent = t(s.hasPassword ? 'serverSettings.changePasswordLabel' : 'serverSettings.setPasswordLabel'); - const password = root.querySelector('#input-server-pass'); - if (password) password.placeholder = t(s.hasPassword ? 'serverSettings.changePasswordPlaceholder' : 'serverSettings.setPasswordPlaceholder'); - const passwordIcon = root.querySelector('#password-status-icon'); - if (passwordIcon) { - passwordIcon.textContent = s.hasPassword ? 'lock' : 'lock_open'; - passwordIcon.style.color = s.hasPassword ? '#f0b232' : '#23a55a'; - } + this.securityTab.refreshState(root, context); const memberCount = root.querySelector('#server-settings-member-count'); if (memberCount) memberCount.textContent = t('serverSettings.membersCount', { count: store.knownMembers.size }); const memberLimitHint = root.querySelector('#server-settings-member-limit-hint'); diff --git a/apps/client/src/renderer/views/SettingsModal.ts b/apps/client/src/renderer/views/SettingsModal.ts index 5c1bd0aa..b650d864 100644 --- a/apps/client/src/renderer/views/SettingsModal.ts +++ b/apps/client/src/renderer/views/SettingsModal.ts @@ -38,6 +38,8 @@ interface SettingsTab extends SettingsTabEntry { hide?(): void; /** What it needs of the platform (#858); without it, its panel is shown read-only, naming `feature`. */ requires?: { capability: keyof PlatformCapabilities; feature: DesktopFeature }; + /** What still works where `requires` is missing: shown and bound below the read-only panel. */ + available?: { render(): string; attach(root: HTMLElement): void; cleanup(): void }; } export class SettingsModal { @@ -130,6 +132,11 @@ export class SettingsModal { render: () => this.localToolsTab.renderHtml(), attach: (root) => this.localToolsTab.attachEvents(root), cleanup: () => this.localToolsTab.cleanup(), + available: { + render: () => LocalToolsTab.botStorageHtml(), + attach: (root) => this.localToolsTab.attachBotStorage(root), + cleanup: () => this.localToolsTab.cleanup(), + }, }, { id: 'logs', icon: 'description', label: 'settings.tabLogs', @@ -153,12 +160,17 @@ export class SettingsModal { .map(entry => entry.requires && isDesktopOnly(entry.requires.capability) ? this.locked(entry, entry.requires) : entry); } - /** The tab as a read-only panel: its own content under the notice, never bound, so nothing it reads is asked of the platform. */ + /** + * The tab as a read-only panel: its own content under the notice, never bound, so nothing it reads is asked of + * the platform. Only what it marks as `available` here is shown below and bound. + */ private locked(entry: SettingsTab, requires: NonNullable): SettingsTab { + const available = entry.available; return { id: entry.id, icon: entry.icon, label: entry.label, separated: entry.separated, desktopOnly: true, - render: () => lockedPanel(requires.capability, requires.feature, entry.render()), - attach: () => {}, + render: () => lockedPanel(requires.capability, requires.feature, entry.render()) + (available?.render() ?? ''), + attach: (root) => available?.attach(root), + cleanup: available ? () => available.cleanup() : undefined, }; } diff --git a/apps/client/src/renderer/views/addServer/AddServerModal.ts b/apps/client/src/renderer/views/addServer/AddServerModal.ts index 3f59f76a..e51ec437 100644 --- a/apps/client/src/renderer/views/addServer/AddServerModal.ts +++ b/apps/client/src/renderer/views/addServer/AddServerModal.ts @@ -14,6 +14,8 @@ import { clientLog } from '../../core/ClientLogService'; import { assertServerBrowseAvailable, captureServerBrowseIntent, getServerSessionForAddress, openServerSession, updateSessionAvatar } from '../../core/serverConnection'; import { ensureHostedServerStarted, findOwnedServer } from '../../core/hostedServerStart'; import { checkServerOnline } from '../../utils/serverStatus'; +import { confirmHostedServerReach } from '../../utils/hostedServer'; +import { showSecureServerHelp } from '../../utils/secureServerHelp'; import { parseServerHostInput, setServerTransport } from '../../utils/serverAddress'; import { showConfirm } from '../Dialog'; import { joinInviteModal } from '../JoinInviteModal'; @@ -488,6 +490,7 @@ class AddServerModal { }; this.setConnectionPending(true); try { + if (!(await confirmHostedServerReach()) || !this.modalEl) return; await ensureHostedServerStarted(createdServer); await this.openAndSaveServer('127.0.0.1', port, password); } catch (error: unknown) { @@ -549,6 +552,7 @@ class AddServerModal { clientLog.warn('CONNECTION', 'Could not join the server from the add-server modal', { error: error instanceof Error ? error.message : String(error), }); + if (!this.modalEl || await showSecureServerHelp(error)) return; this.showError(errorMessage(error, t('connection.connectError'))); } finally { this.setConnectionPending(false); diff --git a/apps/client/src/renderer/views/serverSettings/serverSettingsImmediate.css b/apps/client/src/renderer/views/serverSettings/serverSettingsImmediate.css index 806d2778..85bf97f1 100644 --- a/apps/client/src/renderer/views/serverSettings/serverSettingsImmediate.css +++ b/apps/client/src/renderer/views/serverSettings/serverSettingsImmediate.css @@ -62,6 +62,92 @@ outline-offset: 2px; } +/* Security tab (#881): the password is a status card with its actions, never an open field. */ +.server-password-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.server-password-head { + display: flex; + align-items: center; + gap: 10px; +} + +.server-password-icon { + color: var(--success); +} + +.server-password-card[data-state="on"] .server-password-icon { + color: var(--warning); +} + +.server-password-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.server-password-desc, +.server-password-hint { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +.server-password-value { + display: block; + padding: 8px 10px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 13px; + user-select: text; + overflow-wrap: anywhere; +} + +.server-password-actions, +.server-password-form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.server-password-form-actions { + justify-content: flex-end; +} + +.server-password-action { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + padding: 6px 12px; +} + +.server-password-action[data-danger] { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 40%, transparent); +} + +.server-password-form { + margin-top: 12px; + padding: 14px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.server-password-form .form-group { + margin-bottom: 12px; +} + .role-dialog-card { width: min(720px, calc(100vw - 32px)); max-width: 720px; diff --git a/apps/client/src/renderer/views/serverSettings/tabs/ServerSecurityTab.ts b/apps/client/src/renderer/views/serverSettings/tabs/ServerSecurityTab.ts index 56773166..296682d4 100644 --- a/apps/client/src/renderer/views/serverSettings/tabs/ServerSecurityTab.ts +++ b/apps/client/src/renderer/views/serverSettings/tabs/ServerSecurityTab.ts @@ -1,37 +1,226 @@ import { serverStore } from '../../../stores/serverStore'; +import { sessionManager, type ServerSession } from '../../../core/SessionManager'; +import { findOwnedServer, knownServerPassword } from '../../../core/hostedServerStart'; +import { connectionStore } from '../../../stores/connectionStore'; import { t } from '../../../i18n'; import { escapeHtml } from '../../../utils/html'; +import { copyText, reportError, validationMessage } from '../../../utils/feedback'; +import { setSurfaceVisible } from '../../../utils/surfaceVisibility'; +import type { ServerSettingsContext } from '../ServerSettingsContext'; +import { serverSettingsValidationError } from '../serverSettingsValidation'; +/** Fixed length, so the hidden password does not reveal how long it is. */ +const MASK = '••••••••'; + +/** Applies a password change through the modal's queue; resolves whether the server acknowledged it. */ +export type ApplyPassword = (password: string | null) => Promise; + +/** + * The server only says whether it has a password (`hasPassword`), never which + * one. "Show" and "Copy" use the password this device already knows: the one + * it connected with, or the one its own hosted server was created with. + */ export class ServerSecurityTab { - public renderHtml(): string { - const s = serverStore.serverDetails; - if (!s) return ''; - const hasPass = s.hasPassword; + private revealed = false; + public renderHtml(): string { + if (!serverStore.serverDetails) return ''; + this.revealed = false; + const button = (id: string, icon: string, label: string, extra = '') => ` + `; return ` -
-
-
- ${hasPass ? 'lock' : 'lock_open'} - ${hasPass ? t('serverSettings.statusProtected') : t('serverSettings.statusOpen')} +
+
+
+ +
+
+
+
+
+ -
- ${hasPass ? t('serverSettings.statusProtectedDesc') : t('serverSettings.statusOpenDesc')} + +
+ ${button('btn-password-reveal', 'visibility', t('serverSettings.passwordShow'), 'aria-pressed="false" aria-controls="server-password-value"')} + ${button('btn-password-copy', 'content_copy', t('serverSettings.passwordCopy'))} + ${button('btn-password-change', 'edit', t('serverSettings.passwordChange'), 'aria-expanded="false" aria-controls="server-password-form"')} + ${button('btn-password-remove', 'delete', t('common.remove'), 'data-danger')} + +
+
+ - - -
- -
- - -
-
- ${t('serverSettings.passwordHelp')}
`; } + + public attach(root: HTMLElement, context: ServerSettingsContext, apply: ApplyPassword, signal: AbortSignal): void { + const form = root.querySelector('#server-password-form'); + const next = root.querySelector('#input-server-pass'); + const confirm = root.querySelector('#input-server-pass-confirm'); + if (!form || !next || !confirm) return; + const options = { signal }; + const triggers = () => [...root.querySelectorAll('#btn-password-change, #btn-password-set')]; + const setFormOpen = (open: boolean) => { + if (!open) { + next.value = ''; + confirm.value = ''; + next.removeAttribute('aria-invalid'); + confirm.removeAttribute('aria-invalid'); + } + for (const trigger of triggers()) trigger.setAttribute('aria-expanded', String(open)); + setSurfaceVisible(form, open); + if (open) next.focus({ preventScroll: true }); + }; + const save = async () => { + if (context.operations.isPending('password')) return; + const details = context.store.serverDetails; + if (!details) return; + if (!next.value) { + reportError(validationMessage(next), { field: next, focus: true }); + return; + } + const invalid = serverSettingsValidationError({ password: next.value }, details); + if (invalid) { + reportError(t(invalid), { field: next, focus: true }); + return; + } + if (confirm.value !== next.value) { + reportError(t('serverSettings.passwordMismatch'), { field: confirm, focus: true }); + return; + } + const password = next.value; + const session = this.requestSession(context); + if (!await apply(password)) return; + this.remember(context, session, password); + if (signal.aborted) return; + setFormOpen(false); + root.querySelector('#btn-password-change')?.focus({ preventScroll: true }); + this.refreshState(root, context); + }; + + root.querySelector('#btn-password-reveal')?.addEventListener('click', () => { + this.revealed = !this.revealed; + this.refreshState(root, context); + }, options); + root.querySelector('#btn-password-copy')?.addEventListener('click', () => { + const password = this.knownPassword(context); + if (password) void copyText(password, { copied: t('serverSettings.passwordCopied'), failed: t('serverSettings.passwordCopyFailed') }); + }, options); + for (const trigger of triggers()) { + trigger.addEventListener('click', () => setFormOpen(form.hidden || form.hasAttribute('data-ui-closing')), options); + } + root.querySelector('#btn-password-cancel')?.addEventListener('click', () => { + setFormOpen(false); + triggers().find((trigger) => !trigger.hidden)?.focus({ preventScroll: true }); + }, options); + root.querySelector('#btn-password-save')?.addEventListener('click', () => { void save(); }, options); + for (const input of [next, confirm]) { + // Enter saves this form instead of submitting the settings form around it. + input.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') return; + event.preventDefault(); + void save(); + }, options); + } + root.querySelector('#btn-password-remove')?.addEventListener('click', () => { + const session = this.requestSession(context); + void apply(null).then((removed) => { + if (!removed || context.store.serverDetails?.hasPassword) return; + this.remember(context, session, undefined); + if (signal.aborted) return; + setFormOpen(false); + this.refreshState(root, context); + }); + }, options); + } + + public refreshState(root: HTMLElement, context: ServerSettingsContext): void { + const hasPassword = Boolean(context.store.serverDetails?.hasPassword); + if (!hasPassword) this.revealed = false; + const known = this.knownPassword(context); + const pending = context.operations.isPending('password'); + const card = root.querySelector('#server-password-card'); + card?.setAttribute('data-state', hasPassword ? 'on' : 'off'); + card?.setAttribute('aria-busy', String(pending)); + const icon = root.querySelector('#password-status-icon'); + if (icon) icon.textContent = hasPassword ? 'lock' : 'lock_open'; + const title = root.querySelector('#password-status-title'); + if (title) title.textContent = t(hasPassword ? 'serverSettings.statusProtected' : 'serverSettings.statusOpen'); + const description = root.querySelector('#password-status-desc'); + if (description) description.textContent = t(hasPassword ? 'serverSettings.statusProtectedDesc' : 'serverSettings.statusOpenDesc'); + const valueRow = root.querySelector('#server-password-value-row'); + if (valueRow) valueRow.hidden = !hasPassword; + const value = root.querySelector('#server-password-value'); + if (value) value.textContent = this.revealed && known ? known : MASK; + const unknown = root.querySelector('#server-password-unknown'); + if (unknown) unknown.hidden = !hasPassword || Boolean(known); + const reveal = root.querySelector('#btn-password-reveal'); + if (reveal) { + reveal.setAttribute('aria-pressed', String(this.revealed && Boolean(known))); + const revealIcon = reveal.querySelector('.material-symbols-outlined'); + if (revealIcon) revealIcon.textContent = this.revealed ? 'visibility_off' : 'visibility'; + const label = reveal.querySelector('[data-label]'); + if (label) label.textContent = t(this.revealed ? 'serverSettings.passwordHide' : 'serverSettings.passwordShow'); + } + for (const [id, visible, disabled] of [ + ['#btn-password-reveal', hasPassword, !known], + ['#btn-password-copy', hasPassword, !known], + ['#btn-password-change', hasPassword, pending], + ['#btn-password-remove', hasPassword, pending], + ['#btn-password-set', !hasPassword, pending], + ['#btn-password-save', true, pending], + ] as const) { + const button = root.querySelector(id); + if (!button) continue; + button.hidden = !visible; + button.disabled = disabled; + } + } + + private knownPassword(context: ServerSettingsContext): string | undefined { + if (!context.store.serverDetails?.hasPassword) return undefined; + const session = sessionManager.getActive(); + return session?.client === context.client ? knownServerPassword(session) : undefined; + } + + /** The session this request was made through, captured before it is sent. */ + private requestSession(context: ServerSettingsContext): ServerSession | undefined { + const session = sessionManager.getActive(); + return session?.client === context.client ? session : undefined; + } + + /** + * The server checks the password on every sign-in, and reconnects after a + * restart use the saved entries. Once acknowledged, the session, the saved + * server and this device's hosted server all keep the new password (or none), + * or the next start would sign in with the old one and be refused (#881). + * A session that no longer talks to this connection is left alone. + */ + private remember(context: ServerSettingsContext, session: ServerSession | undefined, password: string | undefined): void { + if (!session || session.client !== context.client) return; + session.password = password; + const owned = findOwnedServer(session.host, session.port); + if (owned && owned.password !== password) connectionStore.saveCreatedServer({ ...owned, password }); + const saved = connectionStore.findSavedServer(session); + if (saved && saved.password !== password) connectionStore.addSavedServer({ ...saved, password }); + } } diff --git a/apps/client/src/renderer/views/settings/tabs/AboutTab.ts b/apps/client/src/renderer/views/settings/tabs/AboutTab.ts index 8119438c..892c85df 100644 --- a/apps/client/src/renderer/views/settings/tabs/AboutTab.ts +++ b/apps/client/src/renderer/views/settings/tabs/AboutTab.ts @@ -100,6 +100,18 @@ export class AboutTab {
${renderSwitch({ id: 'checkbox-ask-shutdown', label: t('settings.askShutdownOnLastLeave'), checked: settingsStore.askShutdownOnLastLeave })}
+
+
+ +
+ ${t('settings.warnHostedServerReachDesc')} +
+
+ ${renderSwitch({ id: 'checkbox-warn-hosted-reach', label: t('settings.warnHostedServerReach'), checked: settingsStore.warnHostedServerReach })} +
@@ -175,6 +187,7 @@ export class AboutTab { const checkboxAutoStart = container.querySelector('#checkbox-auto-start'); const checkboxMinimizeToTray = container.querySelector('#checkbox-minimize-to-tray'); const checkboxAskShutdown = container.querySelector('#checkbox-ask-shutdown'); + const checkboxWarnHostedReach = container.querySelector('#checkbox-warn-hosted-reach'); const btnSupport = container.querySelector('#btn-support-project'); const btnSuggest = container.querySelector('#btn-suggest-idea'); const btnVote = container.querySelector('#btn-vote-ideas'); @@ -276,6 +289,11 @@ export class AboutTab { settingsStore.save(); }); + checkboxWarnHostedReach?.addEventListener('change', () => { + settingsStore.warnHostedServerReach = checkboxWarnHostedReach.checked; + settingsStore.save(); + }); + const openLink = (url: string) => { void openExternalUrl(url, t('settings.linkOpenFailed', { url })); }; btnSupport?.addEventListener('click', () => openLink(DONATE_URL)); diff --git a/apps/client/src/renderer/views/settings/tabs/LocalToolsTab.ts b/apps/client/src/renderer/views/settings/tabs/LocalToolsTab.ts index d3c2392f..0ea16204 100644 --- a/apps/client/src/renderer/views/settings/tabs/LocalToolsTab.ts +++ b/apps/client/src/renderer/views/settings/tabs/LocalToolsTab.ts @@ -141,6 +141,16 @@ export class LocalToolsTab { private tasks = new Map(); private readonly storageList = new BotDeviceStorageList(); + /** What bots keep in this browser or on this computer: on the web, it stays usable beside the read-only tab (#861). */ + public static botStorageHtml(): string { + return sectionHtml('local-tools-bot-storage', 'botStorage.localTitle', BotDeviceStorageList.html()); + } + + public attachBotStorage(container: HTMLElement): void { + const storageRoot = container.querySelector('[data-bot-storage-list]'); + if (storageRoot) this.storageList.attach(storageRoot); + } + public renderHtml(): string { // Shown read-only where bots cannot run here (#858): nothing loads, so it shows the empty state. const locked = isDesktopOnly('localExecution'); @@ -174,7 +184,7 @@ export class LocalToolsTab {

${escapeHtml(t('localExecution.noPermissions'))}

`)} - ${sectionHtml('local-tools-bot-storage', 'botStorage.localTitle', BotDeviceStorageList.html())} + ${locked ? '' : LocalToolsTab.botStorageHtml()} ${sectionHtml('local-tools-tasks', 'localExecution.tasks', `

${escapeHtml(t('localExecution.noTasks'))}

@@ -222,8 +232,7 @@ export class LocalToolsTab { root.addEventListener('click', click); root.addEventListener('change', change); this.unbind.push(() => root.removeEventListener('click', click), () => root.removeEventListener('change', change)); - const storageRoot = root.querySelector('[data-bot-storage-list]'); - if (storageRoot) this.storageList.attach(storageRoot); + this.attachBotStorage(root); this.initialLoad = this.subscribe(root, generation) ? this.refreshState(root, generation) : Promise.resolve(); return this.initialLoad; } diff --git a/apps/client/src/renderer/views/settings/tabs/QualityTab.ts b/apps/client/src/renderer/views/settings/tabs/QualityTab.ts index ef1ad5c3..67e76127 100644 --- a/apps/client/src/renderer/views/settings/tabs/QualityTab.ts +++ b/apps/client/src/renderer/views/settings/tabs/QualityTab.ts @@ -52,9 +52,17 @@ export class QualityTab { private released = false; private appliedEncoding = ''; private readonly encoding = new ScreenEncodingControls(() => nativeScreenProfile(this.qualityProfile()), - undefined, profile => this.applyCompatibleProfile(profile), () => this.rejectQualityRequest(), + () => this.renderScreenFormat(), profile => this.applyCompatibleProfile(profile), () => this.rejectQualityRequest(), () => this.settleReleasedRequest()); + /** The format is an H.264 profile (#890): it shows only while screens go out in H.264, below the encoding. */ + private renderScreenFormat(): void { + const section = this.container?.querySelector('#screen-format-section'); + const codec = this.encoding.transmittedCodec; + // While Automatic resolves, the section keeps what it showed instead of flickering. + if (section && codec) setSurfaceVisible(section, codec === 'h264'); + } + private qualityProfile(): QualityProfile { return this.requestedQuality?.profile ?? (settingsStore.qualityPreset === 'CUSTOM' ? settingsStore.customProfile : QUALITY_PRESETS[settingsStore.qualityPreset]); @@ -162,13 +170,14 @@ export class QualityTab { // Reopening while a request is still being verified shows that request, not the old value. const preset = this.requestedQuality?.preset ?? settingsStore.qualityPreset; return ` -
+ ${ScreenEncodingControls.html()} +
${renderChoiceCards({ id: 'screen-format-choice', label: t('screenShare.format'), attribute: 'data-screen-format', selected: settingsStore.screenFormat, choices: screenFormatSchema.options.map(screenFormatChoice) })}

${t('settings.screenFormatApply')}

- ${ScreenEncodingControls.html()} ${lockedBlock('nativeScreenAudio', 'screenReceiver', `
diff --git a/apps/client/test/audioDeviceSmoke.cjs b/apps/client/test/audioDeviceSmoke.cjs index 91658848..fc78b1aa 100644 --- a/apps/client/test/audioDeviceSmoke.cjs +++ b/apps/client/test/audioDeviceSmoke.cjs @@ -198,6 +198,21 @@ async function runAudioDeviceSmoke() { let checks = 0; const check = (condition, message) => { if (!condition) throw new Error(message); checks++; }; const wait = () => new Promise((resolve) => setTimeout(resolve, 80)); + const savedMicrophone = settings.selectedMicrophoneId; + settings.selectedMicrophoneId = ''; + const reasons = [ + devices.microphoneFailureReason(new DOMException('Requested device not found', 'NotFoundError')), + devices.microphoneFailureReason(new DOMException('Could not start audio source', 'NotReadableError')), + devices.microphoneFailureReason(new DOMException('Permission denied', 'NotAllowedError')), + devices.microphoneFailureReason(new Error('Some Chromium detail')), + ]; + settings.selectedMicrophoneId = 'mic-gone'; + reasons.push(devices.microphoneFailureReason(new DOMException('Requested device not found', 'NotFoundError'))); + settings.selectedMicrophoneId = savedMicrophone; + check(reasons.every(reason => reason && !/Requested device|Could not start|Permission denied|Chromium/.test(reason)), + 'A failed microphone is explained in the app language, never with the browser message'); + check(new Set(reasons.slice(0, 3)).size === 3 && reasons[4] !== reasons[0], + 'No microphone, a busy one, a denied permission and a missing selected device each get their own explanation'); const original = { gum: navigator.mediaDevices.getUserMedia, enumerate: navigator.mediaDevices.enumerateDevices, AudioContext: window.AudioContext, raw: audio.getRawMicrophoneStream, diff --git a/apps/client/test/cameraEffectsSmoke.cjs b/apps/client/test/cameraEffectsSmoke.cjs index 9e4ade1d..6e27a272 100644 --- a/apps/client/test/cameraEffectsSmoke.cjs +++ b/apps/client/test/cameraEffectsSmoke.cjs @@ -153,6 +153,7 @@ if (!process.versions.electron) { const fpsOnly = process.argv.includes('--fps-only') || process.argv.includes('--quality-only'); const keyColorsOnly = process.argv.includes('--key-colors-only'); const gpuOnly = process.argv.includes('--gpu-only'); + const warmUpOnly = process.argv.includes('--warm-up-only'); const normalOnly = process.argv.includes('--normal-only'); const stallOnly = process.argv.includes('--stall-only'); const withoutTrackGenerator = process.argv.includes('--without-track-generator'); @@ -165,12 +166,12 @@ if (!process.versions.electron) { const selectedPhases = [ ...(ownershipOnly ? ['ownership'] : []), ...(keyColorsOnly ? ['key-colors'] : []), ...(fpsOnly ? ['quality'] : []), ...(transitionsOnly ? ['transitions'] : []), ...(chromaOnly ? ['chroma'] : []), - ...(gpuOnly ? ['gpu'] : []), + ...(gpuOnly ? ['gpu'] : []), ...(warmUpOnly ? ['warm-up'] : []), ...(normalOnly ? ['normal'] : []), ...(stallOnly ? ['stall'] : []), ]; const phases = selectedPhases.length ? selectedPhases : cpuCompositor ? ['chroma'] - : ['gpu', 'ownership', 'key-colors', 'quality', 'transitions', 'stall', 'missing-model', 'normal', 'recovery']; + : ['gpu', 'warm-up', 'ownership', 'key-colors', 'quality', 'transitions', 'stall', 'missing-model', 'normal', 'recovery']; let checks = 0; for (const phase of phases) { blockModel = phase === 'missing-model'; @@ -178,7 +179,7 @@ if (!process.versions.electron) { if (blockModel) await window.webContents.session.clearCache(); if (blockModel && packagedModel) fs.renameSync(packagedModel, `${packagedModel}.smoke-missing`); try { - const run = phase === 'gpu' ? runCameraGpuChecks : runCameraEffectsSmoke; + const run = phase === 'gpu' ? runCameraGpuChecks : phase === 'warm-up' ? runMattingWarmUpChecks : runCameraEffectsSmoke; checks += await window.webContents.executeJavaScript(`window.cameraEffectsTrace = ${process.argv.includes('--camera-trace')}; (${run.toString()})('${phase}')`, true); } finally { if (blockModel && packagedModel) fs.renameSync(`${packagedModel}.smoke-missing`, packagedModel); @@ -194,6 +195,45 @@ if (!process.versions.electron) { }).catch(async error => { console.error(error); await finish(1); }); } +async function runMattingWarmUpChecks() { + const { RvmPersonMatting, DEFAULT_CAMERA_EFFECT_SETTINGS: defaults } = window.cameraEffectsFixture; + let checks = 0; + const check = (value, message) => { if (!value) throw new Error(message); checks++; }; + const prototype = WebGL2RenderingContext.prototype; + const { linkProgram, drawElements } = prototype; + let draws = 0; + let linksAfterFirstDraw = 0; + prototype.linkProgram = function (program) { if (draws) linksAfterFirstDraw++; return linkProgram.call(this, program); }; + prototype.drawElements = function (...args) { draws++; return drawElements.apply(this, args); }; + let matting; + let bitmap; + try { + matting = await RvmPersonMatting.create(); + const source = new OffscreenCanvas(256, 144); + const context = source.getContext('2d'); + context.fillStyle = '#2a6f3a'; + context.fillRect(0, 0, source.width, source.height); + context.fillStyle = '#d9a37c'; + context.fillRect(96, 24, 64, 120); + bitmap = await createImageBitmap(source); + for (let frame = 0; frame < 2; frame++) { + const output = await matting.render(bitmap, { ...defaults, mode: 'blur' }, null); + check(output.width === source.width && output.height === source.height, 'Matting must keep the frame size'); + output.close(); + } + check(draws > 0, 'The matting model must run on WebGL'); + // Compiling ~330 programs serially inside the first inference made the first blur slow (#878). + // A handful of late programs (compositor, output upload, recurrent shapes) remain expected. + check(linksAfterFirstDraw <= 16, `RVM programs must be compiled before the first inference: ${linksAfterFirstDraw} linked mid-inference`); + } finally { + prototype.linkProgram = linkProgram; + prototype.drawElements = drawElements; + bitmap?.close(); + await matting?.dispose(); + } + return checks; +} + async function runCameraGpuChecks() { const { CameraGpuCompositor, CameraCpuBackgroundBlur, DEFAULT_CAMERA_EFFECT_SETTINGS: defaults, applyChromaKey } = window.cameraEffectsFixture; let checks = 0; diff --git a/apps/client/test/fixtures/cameraEffects.html b/apps/client/test/fixtures/cameraEffects.html index e20b230e..6e679d4c 100644 --- a/apps/client/test/fixtures/cameraEffects.html +++ b/apps/client/test/fixtures/cameraEffects.html @@ -12,7 +12,8 @@ import { CameraEffectError, DEFAULT_CAMERA_EFFECT_SETTINGS, applyChromaKey } from '../../src/renderer/utils/cameraEffects'; import { CameraGpuCompositor } from '../../src/renderer/core/cameraEffects/CameraGpuCompositor'; import { CameraCpuBackgroundBlur } from '../../src/renderer/core/cameraEffects/CameraCpuBackgroundBlur'; - window.cameraEffectsFixture = { videoService, cameraEffectsStore, CameraEffectsStore, CameraEffectsControl, settingsStore, appEvents, t, CameraEffectError, CameraGpuCompositor, CameraCpuBackgroundBlur, DEFAULT_CAMERA_EFFECT_SETTINGS, applyChromaKey }; + import { RvmPersonMatting } from '../../src/renderer/core/cameraEffects/RvmPersonMatting'; + window.cameraEffectsFixture = { videoService, cameraEffectsStore, CameraEffectsStore, CameraEffectsControl, settingsStore, appEvents, t, CameraEffectError, CameraGpuCompositor, CameraCpuBackgroundBlur, RvmPersonMatting, DEFAULT_CAMERA_EFFECT_SETTINGS, applyChromaKey }; diff --git a/apps/client/test/hostedServerReach.test.ts b/apps/client/test/hostedServerReach.test.ts new file mode 100644 index 00000000..26777f1f --- /dev/null +++ b/apps/client/test/hostedServerReach.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { test, type TestContext } from 'node:test'; +import { SettingsStore, settingsStore } from '../src/renderer/stores/settingsStore'; +import * as Dialog from '../src/renderer/views/Dialog'; +import { confirmHostedServerReach } from '../src/renderer/utils/hostedServer'; + +/** + * Creating a server on this computer first explains that others reach it only + * once the network lets them in, and links the docs (#877). "Don't show again" + * holds only for a confirmed warning, and survives a restart. + */ + +type Answer = { confirmed: boolean; checked: boolean }; + +function fixture(context: TestContext, answer: Answer) { + const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + const previousWarn = settingsStore.warnHostedServerReach; + const entries = new Map(); + const storage: Storage = { + get length() { return entries.size; }, + clear: () => entries.clear(), + getItem: (key) => entries.get(key) ?? null, + key: (index) => [...entries.keys()][index] ?? null, + removeItem: (key) => { entries.delete(key); }, + setItem: (key, value) => { entries.set(key, value); }, + }; + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage }); + settingsStore.warnHostedServerReach = true; + context.after(() => { + settingsStore.warnHostedServerReach = previousWarn; + if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage); + else Reflect.deleteProperty(globalThis, 'localStorage'); + }); + const prompt = context.mock.method(Dialog, 'showConfirmWithOption', async () => answer); + return { prompt }; +} + +test('creating a server warns with a docs link and an option to stop warning (#877)', async (context) => { + const { prompt } = fixture(context, { confirmed: true, checked: false }); + assert.equal(await confirmHostedServerReach(), true); + assert.equal(prompt.mock.callCount(), 1); + const [options] = prompt.mock.calls[0].arguments; + assert.ok(options); + assert.match(options.link?.url ?? '', /^https:\/\/monkyorg\.github\.io\/Monky\/.*criar-seu-servidor#/); + assert.ok(options.checkboxLabel); + assert.equal(settingsStore.warnHostedServerReach, true, 'Confirming without the option keeps warning'); + assert.equal(await confirmHostedServerReach(), true); + assert.equal(prompt.mock.callCount(), 2); +}); + +test('cancelling the warning stops the creation and keeps the warning, even with the option on (#877)', async (context) => { + const { prompt } = fixture(context, { confirmed: false, checked: true }); + assert.equal(await confirmHostedServerReach(), false); + assert.equal(settingsStore.warnHostedServerReach, true); + assert.equal(await confirmHostedServerReach(), false); + assert.equal(prompt.mock.callCount(), 2); +}); + +test('"don\'t show again" on a confirmed warning is saved and skips it from then on (#877)', async (context) => { + const { prompt } = fixture(context, { confirmed: true, checked: true }); + assert.equal(await confirmHostedServerReach(), true); + assert.equal(settingsStore.warnHostedServerReach, false); + assert.equal(new SettingsStore().warnHostedServerReach, false, 'The choice survives a restart'); + assert.equal(await confirmHostedServerReach(), true); + assert.equal(prompt.mock.callCount(), 1, 'No warning once dismissed'); +}); diff --git a/apps/client/test/modalShellDom.test.cjs b/apps/client/test/modalShellDom.test.cjs index 0f02d3fb..c2154e03 100644 --- a/apps/client/test/modalShellDom.test.cjs +++ b/apps/client/test/modalShellDom.test.cjs @@ -246,6 +246,27 @@ async function runRegression(language) { await contract('Alert', () => showAlert({ message: 'Notice' })); check(await contract('Confirmation', () => showConfirm({ message: 'Sure?' })) === false, 'Escape cancels a confirmation'); + // A docs link opens outside the app and keeps the dialog open; Enter on it opens the link instead of confirming (#874, #877). + const opened = []; + const previousApi = window.api; + window.api = { ...previousApi, openExternal: async url => { opened.push(url); return { success: true }; } }; + let linkedDone = false; + const linked = showConfirm({ message: 'Reach it', link: { label: 'How to', url: 'https://docs.example/reach' } }); + void linked.then(() => { linkedDone = true; }); + const docsLink = await until(() => document.querySelector('.modal-backdrop:not([data-ui-closing]) [data-action="open-link"]'), 'The docs link did not render'); + check(docsLink.textContent.includes('How to') && docsLink.tagName === 'BUTTON', 'The link is a labelled button'); + docsLink.click(); + await until(() => opened.length === 1, 'Clicking the link did not open it'); + docsLink.focus(); + key('Enter'); + await until(() => opened.length === 2, 'Enter on the link did not open it'); + check(!linkedDone && docsLink.isConnected && opened.every(url => url === 'https://docs.example/reach'), + 'Opening the docs keeps the dialog open, unanswered'); + docsLink.closest('.modal-card').querySelector('[data-action="confirm"]').click(); + check(await linked === true, 'The dialog still confirms after opening the docs'); + window.api = previousApi; + await settled(); + // Typing only marks a refused value; the reason goes to the shared alert toast. const naming = showConfirmWithText({ message: 'Name it', textInput: { label: 'Name', value: 'ok', validate: value => value.includes('/') ? 'No slashes' : undefined } }); let namingDone = false; diff --git a/apps/client/test/nativeScreenIpc.test.cjs b/apps/client/test/nativeScreenIpc.test.cjs index f76ded0f..23fcee78 100644 --- a/apps/client/test/nativeScreenIpc.test.cjs +++ b/apps/client/test/nativeScreenIpc.test.cjs @@ -75,23 +75,21 @@ test('encoding policy prefers initialized hardware AV1, then H264, then visible } }); -test('the compatible format tries only H.264 encoders, in either strategy and whatever codec is preferred (#858)', async () => { +test('the screen format is only an H.264 profile and never narrows the codec (#890)', async () => { + assert.equal(policy.screenH264Profile('av1', 'compatible'), undefined); + assert.equal(policy.screenH264Profile('av1', 'main'), undefined); + assert.equal(policy.screenH264Profile('av1', undefined), undefined); + assert.equal(policy.screenH264Profile('h264', 'compatible'), 'constrained-baseline'); + assert.equal(policy.screenH264Profile('h264', 'main'), 'main'); + assert.equal(policy.screenH264Profile('h264', undefined), 'main'); const compiled = ['av1_texture_amf', 'obs_nvenc_av1_tex', 'h264_texture_amf', 'obs_nvenc_h264_tex', 'obs_x264', 'monky_aom_av1']; - const unsupported = () => Object.assign(new Error('Unsupported'), { code: 'ERR_SCREEN_CAPTURE_ENCODER_UNSUPPORTED' }); - for (const [strategy, mode] of [['automatic', 'hardware'], ['manual', 'hardware'], ['manual', 'software']]) { - const tried = []; - const selected = await policy.selectScreenEncoding(strategy, mode, 'av1', compiled, async value => { - tried.push(value.encoder); - if (value.mode === 'hardware') throw unsupported(); - }, false, undefined, true); - assert.ok(tried.every(encoder => !encoder.includes('av1')), `${strategy}/${mode} tried AV1: ${tried}`); - assert.equal(selected.selection?.codec ?? 'h264', 'h264'); - if (!(strategy === 'manual' && mode === 'hardware')) assert.equal(selected.selection.encoder, 'obs_x264'); - } - // Without the compatible format, Automatic still prefers AV1. const tried = []; - await policy.selectScreenEncoding('automatic', 'hardware', 'h264', compiled, async value => { tried.push(value.encoder); }); + const selected = await policy.selectScreenEncoding('automatic', 'hardware', 'h264', compiled, + async value => { tried.push(value.encoder); }); assert.deepEqual(tried, ['av1_texture_amf']); + assert.equal(selected.selection.codec, 'av1'); + const manual = await policy.selectScreenEncoding('manual', 'hardware', 'av1', compiled, async () => {}); + assert.equal(manual.selection.codec, 'av1'); }); test('macOS keeps Automatic on hardware H264 and exposes AV1 only as explicit native software', async () => { @@ -1103,14 +1101,14 @@ for (const encoder of ['h264_texture_amf', 'obs_nvenc_h264_tex']) { } for (const screenFormat of ['compatible', 'main', undefined]) { - test(`the ${screenFormat ?? 'default'} format reaches discovery, the source probe and the publisher (#858)`, async t => { + test(`the ${screenFormat ?? 'default'} format reaches the H.264 source probe and publisher (#858)`, async t => { const f = fixture(t, { encoder: 'h264_texture_amf', compiledEncoders: ['av1_texture_amf', 'h264_texture_amf', 'obs_x264'] }); await f.join(); await f.participants(); const { source } = await f.addSource(undefined, { video: { width: 3840, height: 2160, fps: 120, maxBitrateKbps: 80000 }, ...(screenFormat ? { screenFormat } : {}) }); const compatible = screenFormat === 'compatible'; - assert.equal(f.encodingProbes.some(options => options.encoder === 'av1_texture_amf'), !compatible, - 'Automatic tries AV1 first, except in the compatible format'); + // The format is an H.264 profile, never a codec filter (#890): Automatic tries AV1 first in every format. + assert.equal(f.encodingProbes[0].encoder, 'av1_texture_amf'); assert.equal(source.codec, 'h264'); // Firefox decodes Constrained Baseline up to Level 5.2: 4K keeps its size and drops to 64 FPS. assert.equal(source.video.fps, compatible ? 64 : 120); @@ -1122,6 +1120,25 @@ for (const screenFormat of ['compatible', 'main', undefined]) { } }); assert.equal(f.endpoints[0].options.h264Profile, compatible ? 'constrained-baseline' : 'main'); }); + + test(`an AV1 screen ignores the ${screenFormat ?? 'default'} format and never carries an H.264 profile (#890)`, async t => { + const f = fixture(t, { encoder: 'av1_texture_amf', compiledEncoders: ['av1_texture_amf', 'h264_texture_amf', 'obs_x264'] }); + await f.join(); await f.participants(); + const { source } = await f.addSource(undefined, { video: { width: 3840, height: 2160, fps: 120, maxBitrateKbps: 80000 }, + ...(screenFormat ? { screenFormat } : {}) }); + assert.equal(source.codec, 'av1'); + assert.equal(source.video.fps, 120, 'the compatible H.264 level ceiling does not apply to AV1'); + assert.equal(f.probes[0].options.encoder, 'av1_texture_amf'); + assert.equal('h264Profile' in f.probes[0].options, false); + await f.command({ action: 'signal', signal: { + fromSessionId: 'viewer', targetSessionId: 'publisher', publisherSessionId: 'publisher', + channelId: f.config.channelId, shareId: source.shareId, sourceInstanceId: source.instanceId, + subscriptionId: randomUUID(), action: 'watch', quality: 'source', backend: 'native', + } }); + assert.equal(f.endpoints[0].options.captureEncoder, 'av1_texture_amf'); + // The native endpoint rejects an AV1 source with an H.264 profile (nativeEndpoint.cjs). + assert.equal('h264Profile' in f.endpoints[0].options, false); + }); } test('a junction above the profile gives probes, sources and cleanup one canonical capture folder', async t => { diff --git a/apps/client/test/networkClient.test.ts b/apps/client/test/networkClient.test.ts index 32d90307..08897478 100644 --- a/apps/client/test/networkClient.test.ts +++ b/apps/client/test/networkClient.test.ts @@ -12,6 +12,7 @@ import { translateProtocolError } from '../src/renderer/i18n/protocolErrors'; import { t } from '../src/renderer/i18n'; import { connectionStore } from '../src/renderer/stores/connectionStore'; import { setServerTransport } from '../src/renderer/utils/serverAddress'; +import { SecureServerRequiredError } from '../src/renderer/utils/secureServer'; const identity = { clientId: 'fixture-client', publicKey: 'fixture-public-key' }; const NONCE = 'ab'.repeat(32); @@ -713,7 +714,8 @@ test('the web version refuses a plain server on another machine without opening const attempt = f.client.connect('192.168.0.10', 3000, identity, 'Fixture'); const opened = f.sockets.length; f.sockets.at(-1)?.drop(); - await assert.rejects(attempt, { message: t('network.webUnsupportedServer', { host: '192.168.0.10', port: 3000 }) }); + await assert.rejects(attempt, error => error instanceof SecureServerRequiredError + && error.message === t('network.webUnsupportedServer', { host: '192.168.0.10', port: 3000 })); assert.equal(opened, 0); assert.equal(f.client.getStatus(), 'DISCONNECTED'); }); @@ -728,12 +730,14 @@ test('the web version still dials this machine and secure servers, and words a f const local = f.client.connect('localhost', 48731, identity, 'Fixture'); assert.equal(f.lastSocket().url, 'ws://localhost:48731'); f.lastSocket().drop(); - await assert.rejects(local, { message: t('network.genericConnectError') }); + await assert.rejects(local, error => !(error instanceof SecureServerRequiredError) + && error instanceof Error && error.message === t('network.genericConnectError')); const remote = f.client.connect(secure.host, secure.port, identity, 'Fixture'); assert.equal(f.lastSocket().url, 'wss://chat.example:443'); f.lastSocket().drop(); - await assert.rejects(remote, { message: t('network.webSecureFailed', { host: 'chat.example', port: 443 }) }); + await assert.rejects(remote, error => error instanceof SecureServerRequiredError + && error.message === t('network.webSecureFailed', { host: 'chat.example', port: 443 })); }); const SAFARI_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15'; diff --git a/apps/client/test/qualitySettingsSmoke.cjs b/apps/client/test/qualitySettingsSmoke.cjs index bf82f49e..d2c36a16 100644 --- a/apps/client/test/qualitySettingsSmoke.cjs +++ b/apps/client/test/qualitySettingsSmoke.cjs @@ -96,8 +96,8 @@ async function runQualitySettingsSmoke() { encodingRequests.push(command); const automatic = command.encodingStrategy === 'automatic'; const mode = automatic ? hardwareAvailable ? 'hardware' : 'software' : command.encodingMode; - // What Main does: the compatible format (#858) considers only H.264 encoders. - const codec = command.screenFormat === 'compatible' ? 'h264' : automatic ? mode === 'hardware' ? 'av1' : 'h264' : command.codec; + // What Main does: the format is an H.264 profile and never narrows the codec (#890). + const codec = automatic ? mode === 'hardware' ? 'av1' : 'h264' : command.codec; const hardwareSupported = hardwareAvailable && !(limitFourKH264 && codec === 'h264' && (command.video.width >= 3840 || command.video.height >= 2160) && command.video.fps > 60); const unavailable = !automatic && mode === 'hardware' && !hardwareSupported; @@ -239,24 +239,18 @@ async function runQualitySettingsSmoke() { 'Automatic fields must be read-only and show resolved Hardware/AV1 without overwriting manual choices.'); check(encodingStatus.textContent === language.t('settings.screenEncodingReady', { codec: 'AV1' }), 'Available encoding must show the concise localized status.'); + const formatSection = quality.querySelector('#screen-format-section'); const formatCards = quality.querySelector('#screen-format-choice'); const formatCard = value => formatCards.querySelector(`[data-screen-format="${value}"]`); + const formatShown = () => !formatSection.hidden && !formatSection.hasAttribute('data-ui-closing'); check(formatCard('main').getAttribute('aria-pressed') === 'true' && formatCard('compatible').getAttribute('aria-pressed') === 'false' && formatCard('compatible').textContent.includes(language.t('screenShare.formatCompatible')) && formatCard('main').textContent.includes(language.t('screenShare.formatMainDescription')), 'The desktop keeps the better-quality format by default, beside the compatible one (#858).'); - formatCard('compatible').click(); - await settled(() => encodingStatus.getAttribute('aria-busy') === 'false' - && encodingRequests.at(-1)?.screenFormat === 'compatible', `${locale}/compatible format`); - check(settingsStore.screenFormat === 'compatible' && formatCard('compatible').getAttribute('aria-pressed') === 'true' - && screenCodec.disabled && screenCodec.value === 'h264' && screenCodec.querySelector('[value="av1"]').disabled - && encodingStatus.textContent === language.t('settings.screenEncodingReady', { codec: 'H264' }), - 'The compatible format checks and shows only H.264 (#858).'); - formatCard('main').click(); - await settled(() => encodingStatus.getAttribute('aria-busy') === 'false' - && encodingRequests.at(-1)?.screenFormat === 'main', `${locale}/main format`); - check(settingsStore.screenFormat === 'main' && screenCodec.value === 'av1' && settingsStore.preferredScreenCodec === 'h264', - 'Back in better quality, Automatic resolves AV1 again without touching the manual codec.'); + check(formatSection.hidden && !encodingRequests.some(command => 'screenFormat' in command) + && !!(screenCodec.closest('[data-settings-section="screen-encoding"]').compareDocumentPosition(formatSection) + & Node.DOCUMENT_POSITION_FOLLOWING), + 'The H.264 format sits below the screen encoding and stays hidden while Automatic sends AV1 (#890).'); automatic.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true })); const choicesStatus = quality.querySelector('#screen-encoding-choices'); const encodingSettled = () => encodingStatus.getAttribute('aria-busy') === 'false' @@ -264,6 +258,24 @@ async function runQualitySettingsSmoke() { await settled(() => settingsStore.screenEncodingStrategy === 'manual' && encodingSettled(), `${locale}/manual selection`); check(settingsStore.screenEncodingStrategy === 'manual' && screenCodec.value === 'h264', 'Manual restores the saved exact choices and unlocks the encoding and codec controls.'); + await settled(formatShown, `${locale}/H.264 format shown`); + formatCard('compatible').click(); + check(settingsStore.screenFormat === 'compatible' && formatCard('compatible').getAttribute('aria-pressed') === 'true' + && !screenCodec.disabled && !screenCodec.querySelector('[value="av1"]').disabled, + 'The compatible format never locks the codec choice (#890).'); + screenCodec.value = 'av1'; + screenCodec.dispatchEvent(new Event('change', { bubbles: true })); + await settled(() => encodingSettled() && formatSection.hidden, `${locale}/AV1 hides the format`); + check(settingsStore.preferredScreenCodec === 'av1' && screenCodec.value === 'av1' && settingsStore.screenFormat === 'compatible' + && encodingStatus.textContent === language.t('settings.screenEncodingReady', { codec: 'AV1' }), + 'Manual AV1 works with any saved format and hides the H.264-only format (#890).'); + screenCodec.value = 'h264'; + screenCodec.dispatchEvent(new Event('change', { bubbles: true })); + await settled(() => encodingSettled() && formatShown(), `${locale}/H.264 shows the format`); + formatCard('main').click(); + check(settingsStore.screenFormat === 'main' && settingsStore.preferredScreenCodec === 'h264' + && !encodingRequests.some(command => 'screenFormat' in command), + 'Back in H.264 the format shows again; it never reaches encoder discovery (#890).'); hardwareAvailable = false; screenCodec.value = 'av1'; screenCodec.dispatchEvent(new Event('change', { bubbles: true })); diff --git a/apps/client/test/screenEncodingAdjustment.test.cjs b/apps/client/test/screenEncodingAdjustment.test.cjs index 584f0e21..17587b8e 100644 --- a/apps/client/test/screenEncodingAdjustment.test.cjs +++ b/apps/client/test/screenEncodingAdjustment.test.cjs @@ -218,6 +218,28 @@ for (const failure of ['initial-exception', 'candidate-exception', 'initial-erro }); } +test('a machine without a usable graphics card says so once, instead of asking to retry every encoder', async t => { + const f = fixture('pt-BR'); + t.after(() => f.close()); + configure(f); + const probes = []; + f.controls.encoding = async input => { + probes.push(`${input.encodingMode}:${input.codec}:${input.video.fps}`); + // What Main's IPC rejection carries when adapter 0 is only the software renderer of a virtual machine. + throw new Error('Error invoking remote method \'native-screen:invoke\': Error: The selected encoder is not supported on capture adapter 0' + + ' [monky-screen-failure {"codes":["ERR_SCREEN_CAPTURE_NO_GPU"]}]'); + }; + f.mountQuality(); + for (let turn = 0; turn < 5; turn++) await flush(); + assert.deepEqual(probes, ['hardware:h264:120']); + assert.equal(f.saves, 0); + const status = control(f, 'screen-encoding-status').textContent; + assert.equal(status, f.i18n.t('screenShare.failure.no-gpu')); + assert.notEqual(status, f.i18n.t('settings.screenEncodingProbeFailed')); + assert.equal(control(f, 'select-video-codec').disabled, true); + assert.doesNotMatch(f.document.body.textContent, /ERR_|monky-screen-failure|adapter 0/); +}); + test('no supported lower profile leaves preferences unchanged with localized unavailable status', async t => { const f = fixture('pt-BR'); t.after(() => f.close()); diff --git a/apps/client/test/screenShareFailures.test.ts b/apps/client/test/screenShareFailures.test.ts index 5437be0f..5b3676b6 100644 --- a/apps/client/test/screenShareFailures.test.ts +++ b/apps/client/test/screenShareFailures.test.ts @@ -9,7 +9,7 @@ import { import { NativeScreenNotice, rendererFailure } from '../src/main/nativeScreen/errorDiagnostics'; import { setLanguage, t, type SupportedLanguage, type TranslationKey } from '../src/renderer/i18n'; import { - ScreenShareUserError, screenEncodingFallbackMessage, screenShareFailureMessage, + ScreenShareUserError, isNoGpuScreenFailure, screenEncodingFallbackMessage, screenShareFailureMessage, } from '../src/renderer/utils/screenShareFailure'; const client = path.resolve(__dirname, '..', '..'); @@ -61,6 +61,7 @@ test('every native ERR_SCREEN and ERR_MAC code has a category, and the table nam ['ERR_MAC_AV1_RUNTIME', 'files'], ['ERR_MAC_HOST_START', 'files'], ['ERR_SCREEN_AUDIO_UNAVAILABLE', 'files'], ['ERR_MAC_VIDEO_METAL_UNAVAILABLE', 'encoder'], ['ERR_MAC_CAPTURE_FIRST_FRAME', 'interrupted'], ['ERR_MAC_SCREEN_PERMISSION', 'permission'], ['ERR_MAC_SOURCE_LOST', 'source-lost'], + ['ERR_SCREEN_CAPTURE_NO_GPU', 'no-gpu'], ['ERR_SCREEN_CAPTURE_DEVICE', 'encoder'], ] as const) assert.equal(SCREEN_SHARE_FAILURE_CODES.get(code), category, code); }); @@ -105,6 +106,11 @@ for (const language of ['pt-BR', 'en'] as const satisfies readonly SupportedLang assert.equal(screenShareFailureMessage(wrapped, 'start'), start(t('screenShare.failure.encoder'))); assert.equal(screenShareFailureMessage(crossed(coded('Hook refused', 'ERR_SCREEN_CAPTURE_GAME_UNAVAILABLE')), 'start'), start(t('screenShare.gameCaptureUnavailable'))); + // A virtual machine's software renderer is explained as such, not as a driver to update or a retry. + const noGpu = crossed(coded('Not supported on capture adapter 0', 'ERR_SCREEN_CAPTURE_NO_GPU')); + assert.equal(screenShareFailureMessage(noGpu, 'start'), start(t('screenShare.failure.no-gpu'))); + assert.equal(isNoGpuScreenFailure(noGpu), true); + assert.equal(isNoGpuScreenFailure(crossed(coded('OBS D3D11 device was removed', 'ERR_SCREEN_CAPTURE_DEVICE'))), false); }); test(`every native code reads as plain language in every action (${language})`, () => { diff --git a/apps/client/test/serverSettingsDom.test.cjs b/apps/client/test/serverSettingsDom.test.cjs index d7fc8839..6971a796 100644 --- a/apps/client/test/serverSettingsDom.test.cjs +++ b/apps/client/test/serverSettingsDom.test.cjs @@ -309,8 +309,19 @@ async function* runRegression(language, sharedModule) { network.setActiveNetworkClient(client); const community = new CommunityFeed(client, store); await community.load(); + const { connectionStore } = await import('/stores/connectionStore.ts'); const originalGetActive = sessionManager.getActive; - sessionManager.getActive = () => ({ community }); + // A hosted server of this device, so the saved and created entries carry the password (#881). + connectionStore.saveCreatedServer({ id: 'password-test-hosted', name: 'Server A', port: 47123, voiceChannel: 'Voz', + textChannel: 'geral', createdAt: 1, lastStarted: 1 }); + const activeSession = { community, client, host: '127.0.0.1', port: 47123 }; + const storedPasswords = () => ({ + saved: connectionStore.findSavedServer(activeSession)?.password, + created: connectionStore.createdServers.find((server) => server.id === 'password-test-hosted')?.password, + persisted: JSON.parse(localStorage.getItem('monky_saved_servers') || '[]') + .find((server) => server.host === '127.0.0.1' && server.port === 47123)?.password, + }); + sessionManager.getActive = () => activeSession; const modal = new ServerSettingsModal(); const field = (id) => [...document.querySelectorAll(id)].find(element => !element.closest('[data-ui-closing]')); const change = (id, value) => { @@ -650,16 +661,93 @@ async function* runRegression(language, sharedModule) { acknowledge('SERVER_UPDATE_SETTINGS'); await flush(); tab('security'); + // #881: the password is a status card with Show, Copy, Change and Remove, never an open field. + const passwordForm = () => field('#server-password-form'); + const passwordFormClosed = () => passwordForm().hidden || passwordForm().hasAttribute('data-ui-closing'); + const unsentPassword = () => !requests.some((request) => !request.done && request.type === 'SERVER_UPDATE_SETTINGS'); + check(field('#server-password-card').dataset.state === 'off' + && field('#password-status-title').textContent === t('serverSettings.statusOpen') + && !field('#btn-password-set').hidden && field('#btn-password-change').hidden && field('#btn-password-remove').hidden + && field('#btn-password-reveal').hidden && field('#server-password-value-row').hidden && passwordFormClosed() + && !field('#server-password-card input[type="checkbox"]'), + 'Without a password the card says so and only offers to set one'); + field('#btn-password-set').click(); + await flush(); + check(!passwordFormClosed() && field('#btn-password-set').getAttribute('aria-expanded') === 'true', + 'Set password expands the inline form'); + clearToasts(); + const beforeMismatch = requests.length; change('#input-server-pass', 'New access secret'); + change('#input-server-pass-confirm', 'New access secrex'); + field('#btn-password-save').click(); + await flush(); + check(requests.length === beforeMismatch && failed(t('serverSettings.passwordMismatch')) + && field('#input-server-pass-confirm').getAttribute('aria-invalid') === 'true', + 'Mismatched confirmation is refused in the shared alert without sending'); + change('#input-server-pass-confirm', 'New access secret'); + field('#btn-password-save').click(); + await flush(); + check(pendingRequest('SERVER_UPDATE_SETTINGS').payload.password === 'New access secret' && locked() + && field('#btn-password-save').disabled, 'Saving a confirmed password applies it with the acknowledgement guard'); + acknowledge('SERVER_UPDATE_SETTINGS'); + await flush(); + check(store.serverDetails.hasPassword && field('#server-password-card').dataset.state === 'on' + && field('#password-status-title').textContent === t('serverSettings.statusProtected') + && passwordFormClosed() && field('#input-server-pass').value === '' && field('#input-server-pass-confirm').value === '' + && activeSession.password === 'New access secret' && !field('#btn-password-change').hidden && !field('#btn-password-remove').hidden + && field('#btn-password-set').hidden, 'The acknowledged password turns the card on and clears the secret fields'); + check(Object.values(storedPasswords()).every((password) => password === 'New access secret'), + 'The saved and hosted server entries keep the new password, so the next start signs in with it'); + check(field('#server-password-value').textContent !== 'New access secret' && !field('#btn-password-reveal').disabled + && field('#server-password-unknown').hidden, 'The known password starts masked'); + field('#btn-password-reveal').click(); + check(field('#server-password-value').textContent === 'New access secret' + && field('#btn-password-reveal').getAttribute('aria-pressed') === 'true' + && field('#btn-password-reveal [data-label]').textContent === t('serverSettings.passwordHide'), 'Show reveals the password'); + field('#btn-password-reveal').click(); + check(field('#server-password-value').textContent !== 'New access secret' + && field('#btn-password-reveal').getAttribute('aria-pressed') === 'false', 'Hide masks it again'); + field('#btn-password-copy').click(); + await flush(); + check(copiedVersions.at(-1) === 'New access secret', 'Copy writes the password to the clipboard'); + delete activeSession.password; + activeSession.host = 'password-test.invalid'; + appEvents.emit('server.updated'); await flush(); + check(field('#btn-password-reveal').disabled && field('#btn-password-copy').disabled && !field('#server-password-unknown').hidden + && !field('#btn-password-change').disabled, 'A password this device does not know cannot be shown or copied, only changed'); + activeSession.host = '127.0.0.1'; + field('#btn-password-change').click(); + await flush(); + change('#input-server-pass', 'Second secret'); + change('#input-server-pass-confirm', 'Second secret'); + field('#input-server-pass-confirm').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + await flush(); + check(pendingRequest('SERVER_UPDATE_SETTINGS').payload.password === 'Second secret', 'Enter in the form saves the changed password'); acknowledge('SERVER_UPDATE_SETTINGS'); await flush(); - check(store.serverDetails.hasPassword && !field('#btn-remove-pass').hidden && field('#input-server-pass').value === '', 'Password blur applies and clears secret text after acknowledgement'); - field('#btn-remove-pass').click(); + check(activeSession.password === 'Second secret' && passwordFormClosed() && !field('#btn-password-copy').disabled + && Object.values(storedPasswords()).every((password) => password === 'Second secret'), + 'The changed password becomes the known and stored one'); + field('#btn-password-remove').click(); + await flush(); + check(locked() && document.querySelector('.dialog-card'), 'Removing the password first asks for confirmation'); + document.querySelector('.dialog-card [data-action="cancel"]').click(); + await flush(); + check(!locked() && store.serverDetails.hasPassword && unsentPassword(), 'Declining keeps the password'); + field('#btn-password-remove').click(); + await flush(); + document.querySelector('.dialog-card [data-action="confirm"]').click(); await flush(); - check(pendingRequest('SERVER_UPDATE_SETTINGS').payload.password === null && locked(), 'Password removal is immediate and acknowledged'); + check(pendingRequest('SERVER_UPDATE_SETTINGS').payload.password === null && locked(), 'Confirming removes the password'); acknowledge('SERVER_UPDATE_SETTINGS'); await flush(); + check(!store.serverDetails.hasPassword && field('#server-password-card').dataset.state === 'off' + && activeSession.password === undefined && !field('#btn-password-set').hidden, 'The card returns to no password'); + check(Object.values(storedPasswords()).every((password) => password === undefined), + 'Removal also empties the saved and hosted server password'); + connectionStore.removeCreatedServer('password-test-hosted'); + connectionStore.removeSavedServer('127.0.0.1', 47123); tab('general'); field('#server-icon-wrapper').click(); await flush(); diff --git a/apps/client/test/webBotStorage.test.ts b/apps/client/test/webBotStorage.test.ts new file mode 100644 index 00000000..12b01f71 --- /dev/null +++ b/apps/client/test/webBotStorage.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { BOT_STORAGE_LIMITS, type BotDeviceStorageScope } from '@monky/shared'; +import { + bindWebBotScreenStorage, releaseWebBotScreenStorage, webBotStorage, wipeWebBotStorage, +} from '../src/renderer/platform/web/webBotStorage'; + +/** The slice of IndexedDB that `webStorage.ts` uses, over a sorted map, answering asynchronously like a browser. */ +class Range { + constructor(readonly lower: string, readonly upper: string) {} + static bound(lower: string, upper: string): Range { return new Range(lower, upper); } + includes(key: string): boolean { return key >= this.lower && key <= this.upper; } +} + +const records = new Map(); + +function request(work: () => T) { + const pending: { result?: T; onsuccess?: () => void; onerror?: () => void; error: null } = { error: null }; + setImmediate(() => { pending.result = work(); pending.onsuccess?.(); }); + return pending; +} + +const matching = (range?: Range): string[] => [...records.keys()].filter(key => !range || range.includes(key)).sort(); +const clone = (value: unknown): unknown => structuredClone(value); + +const objectStore = { + get: (key: string) => request(() => clone(records.get(key))), + put: (value: unknown, key: string) => request(() => { records.set(key, clone(value)); }), + delete: (key: string | Range) => request(() => { + for (const stored of typeof key === 'string' ? [key] : matching(key)) records.delete(stored); + }), + getAllKeys: (range?: Range) => request(() => matching(range)), + getAll: (range?: Range) => request(() => matching(range).map(key => clone(records.get(key)))), +}; + +const database = { + objectStoreNames: { contains: () => true }, + transaction: () => ({ objectStore: () => objectStore }), +}; + +Object.assign(globalThis, { + IDBKeyRange: Range, + indexedDB: { open: () => request(() => database) }, +}); + +const scope: BotDeviceStorageScope = { + serverUrl: 'wss://monky.example/', serverId: 'server', serverName: 'Server', userId: 'user', botId: 'bot', botName: 'MonkyBot', +}; +const DOCUMENT = 'https://monky-miniapps.vercel.app/frame.html#document'; + +const botRecords = (): string[] => [...records.keys()].filter(key => key.startsWith('bot-storage/')); + +test('web miniapps keep data on the app origin, with the same consent, quota and forget as the desktop (#861)', async () => { + records.clear(); + const entry = await webBotStorage.get(scope); + assert.equal(entry.decision, 'ask', 'the web asks before a bot keeps anything, like the desktop'); + assert.deepEqual(botRecords(), [], 'reading never stores anything'); + + await bindWebBotScreenStorage(DOCUMENT, { scope, grant: 'none' }); + assert.equal((await webBotStorage.documentState(DOCUMENT)).status, 'denied'); + assert.equal((await webBotStorage.documentRequest(DOCUMENT, { op: 'set', key: 'slot', value: { kind: 'text', text: 'x' }, autosave: false })).ok, false); + releaseWebBotScreenStorage(DOCUMENT); + + await bindWebBotScreenStorage(DOCUMENT, { scope, grant: 'session' }); + assert.equal((await webBotStorage.documentState(DOCUMENT)).status, 'granted'); + assert.equal((await webBotStorage.documentRequest(DOCUMENT, { op: 'set', key: 'slot', value: { kind: 'text', text: 'x' }, autosave: false })).ok, true); + const read = await webBotStorage.documentRequest(DOCUMENT, { op: 'get', key: 'slot' }); + assert.deepEqual(read.ok && read.op === 'get' ? read.value : null, { kind: 'text', text: 'x' }); + assert.ok(botRecords().some(key => key.endsWith(`${entry.id}/index`)), 'the index lives in the app origin\'s IndexedDB'); + assert.equal((await webBotStorage.get(scope)).decision, 'ask', '"Only this session" is not remembered'); + + releaseWebBotScreenStorage(DOCUMENT); + assert.equal((await webBotStorage.documentState(DOCUMENT)).status, 'unavailable', 'leaving the miniapp ends the session grant'); + + await webBotStorage.setQuota(scope, BOT_STORAGE_LIMITS.minQuotaBytes); + await bindWebBotScreenStorage(DOCUMENT, { scope, grant: 'always' }); + assert.equal((await webBotStorage.get(scope)).decision, 'always'); + const big = await webBotStorage.documentRequest(DOCUMENT, { + op: 'set', key: 'big', value: { kind: 'bytes', bytes: new Uint8Array(BOT_STORAGE_LIMITS.minQuotaBytes) }, autosave: false, + }); + assert.deepEqual(big.ok ? null : big.reason, 'quota', 'the quota is enforced on the web too'); + + await webBotStorage.clear(entry.id); + const cleared = await webBotStorage.documentRequest(DOCUMENT, { op: 'get', key: 'slot' }); + assert.equal(cleared.ok && cleared.op === 'get' ? cleared.value : 'still there', null, 'clearing removes the data'); + assert.equal((await webBotStorage.get(scope)).decision, 'always', 'clearing keeps the decision'); + + await webBotStorage.forget(entry.id); + assert.deepEqual(botRecords(), [], 'forgetting removes every record of the bot'); + assert.equal((await webBotStorage.get(scope)).decision, 'ask'); + releaseWebBotScreenStorage(DOCUMENT); +}); + +test('logging out of the web version deletes what every bot kept, and nothing else (#861)', async () => { + records.clear(); + records.set('dm/conversation', 'kept'); + await bindWebBotScreenStorage(DOCUMENT, { scope, grant: 'always' }); + await webBotStorage.documentRequest(DOCUMENT, { op: 'set', key: 'slot', value: { kind: 'text', text: 'x' }, autosave: false }); + assert.notDeepEqual(botRecords(), []); + + await wipeWebBotStorage(); + assert.deepEqual(botRecords(), []); + assert.equal(records.get('dm/conversation'), 'kept'); + assert.equal((await webBotStorage.documentState(DOCUMENT)).status, 'unavailable', 'an open miniapp loses its grant'); + assert.equal((await webBotStorage.get(scope)).decision, 'ask', 'the next account starts from the question again'); +}); diff --git a/docs-site/administrar-servidor.md b/docs-site/administrar-servidor.md index b1bd8b93..57984948 100644 --- a/docs-site/administrar-servidor.md +++ b/docs-site/administrar-servidor.md @@ -19,7 +19,7 @@ disponíveis dependem do seu acesso. | Aba | Para que serve | | --- | --- | | Geral | Nome, imagem e banner, habilitação de eventos, limites, modo de voz e informações do processo servidor | -| Segurança | Senha de entrada | +| Segurança | Senha de entrada: se está ativa, mostrar, copiar, alterar ou remover | | Recursos de Voz e Vídeo | Permissão de Soundboard, cache de áudios recentes, relay de mensagens diretas, canal AFK e relay TURN integrado | | Armazenamento | Limites e uso de anexos | | Notificações | Menções, edição de mensagens e avisos publicados no chat | @@ -29,8 +29,11 @@ disponíveis dependem do seu acesso. ### Aplicação imediata -Switches e seleções aplicam imediatamente. Nomes, senhas e limites aplicam -ao terminar a edição, ao sair do campo ou pressionar `Enter`. **Pronto** +Switches e seleções aplicam imediatamente. Nomes e limites aplicam +ao terminar a edição, ao sair do campo ou pressionar `Enter`. Uma nova senha +é digitada duas vezes e aplica em **Salvar senha**; removê-la pede +confirmação. **Mostrar** e **Copiar** só funcionam num aparelho que conhece a +senha, como o que entrou com ela ou o que hospeda o servidor. **Pronto** apenas fecha a janela; não desfaz o que já foi confirmado. Enquanto houver uma operação pendente, o fechamento fica bloqueado. Um diff --git a/docs-site/arquitetura.md b/docs-site/arquitetura.md index 58143e64..2a71ced4 100644 --- a/docs-site/arquitetura.md +++ b/docs-site/arquitetura.md @@ -90,6 +90,12 @@ delas, ativar o item abre esse convite. Uma regra só decide isso, `featureAvail `utils/desktopOnly.ts`. Só a barra de título e os botões da janela do desktop somem na web. +Quando um recurso existe nas duas, a regra fica num motor só e cada plataforma +fornece o armazenamento. As mensagens diretas usam `packages/dm`. O armazenamento +dos miniapps no aparelho usa `botDeviceStorageEngine.ts`, em `packages/shared`: o +desktop grava arquivos pelo processo main, e a web grava no IndexedDB da origem do +app, nunca na dos miniapps. + Três barreiras protegem essa ponte. A Content-Security-Policy do `index.html` só executa scripts do próprio app, então um HTML injetado por engano não roda; um recurso de tipo novo (outro iframe, worker ou origem) precisa entrar nessa @@ -287,10 +293,10 @@ do próprio navegador (`getDisplayMedia`), cada perfil pedido é uma cópia da captura reduzida pelo navegador, e o envio é H.264 por conexões próprias para cada espectador em P2P ou por um par de producers de tela no SFU, com o mesmo protocolo `monky-native-screen-p2p` e os mesmos dados de rendição. O -formato padrão é o Constrained Baseline (`42e0..`, packetization-mode 1), que -todo receptor decodifica, inclusive o Firefox e o Safari; quem publica pode -escolher o H.264 Main, de imagem melhor, que só o Chrome, o Edge e o desktop -assistem. O codec fica fixo pelo `setCodecPreferences` em P2P e pelo codec do +formato padrão é o H.264 Main, de imagem melhor, que só o Chrome, o Edge e o +desktop assistem; quem publica pode escolher o Constrained Baseline (`42e0..`, +packetization-mode 1), que todo receptor decodifica, inclusive o Firefox e o +Safari. O codec fica fixo pelo `setCodecPreferences` em P2P e pelo codec do producer no SFU, mesmo quando o navegador usaria o encoder de hardware. O renderer acima dele não muda; o seletor é a janela do navegador (`views/screenShare/BrowserScreenSharePicker.ts`), e a versão web assiste @@ -299,8 +305,10 @@ transmissão (o Firefox diante do H.264 Main ou do AV1) recebe uma mensagem que nomeia o formato, e não a falha genérica de conexão. O app desktop oferece o mesmo par de formatos em Qualidade e compartilhamento, -com Melhor qualidade (H.264 Main ou AV1) como padrão. No Compatível, o Main -escolhe só encoders H.264 e passa `--h264-profile=constrained-baseline` ao host +com Melhor qualidade como padrão. O formato é um perfil H.264 e não restringe o +codec: o Automático continua escolhendo o AV1 quando disponível. Quando a tela +sai em H.264 no Compatível, o Main +passa `--h264-profile=constrained-baseline` ao host de captura: x264, NVENC e AMF usam o perfil `baseline` do OBS, e o VideoToolbox usa `H264_ConstrainedBaseline_AutoLevel`. O host e o motor marcam o `constraint_set1` de um SPS Baseline sem grupos de slices nem imagens @@ -481,9 +489,10 @@ A validação dos payloads usa **zod**, com os schemas em `packages/shared` — os mesmos que o cliente usa para validar antes de enviar. ::: warning Compatibilidade negociada, nunca um downgrade irrestrito -O protocolo **26** negocia pisos independentes: **26 para clientes** e **24 para -bots**, além de recursos em `AUTH_CONNECT`/`AUTH_SUCCESS`. O contrato nativo de -tela 4K/80 Mbps exige atualizar cliente e servidor; clientes 24/25 não são +Desde o protocolo **26**, os pisos de clientes e de bots são independentes. Hoje +são **35 para clientes** (`MIN_CLIENT_PROTOCOL`) e **24 para bots** +(`MIN_BOT_PROTOCOL`), além de recursos em `AUTH_CONNECT`/`AUTH_SUCCESS`. O contrato nativo de +tela 4K/80 Mbps exige atualizar cliente e servidor; clientes mais antigos não são rebaixados silenciosamente. Bots 24 continuam usando seu contrato anterior de áudio e comandos. Blocos de mensagem e configuração de limite só são habilitados com as capacidades correspondentes. O SDK pode repetir a autenticação uma vez diff --git a/docs-site/bots-armazenamento.md b/docs-site/bots-armazenamento.md index 8033eddd..ef707378 100644 --- a/docs-site/bots-armazenamento.md +++ b/docs-site/bots-armazenamento.md @@ -118,6 +118,10 @@ a decisão entre **Perguntar**, **Sempre** e **Nunca**, ajusta o limite (50 MB por padrão, de 1 MB a 100 GB) e apaga os dados. A mudança vale na hora, também para um miniapp aberto. +Na versão web, as regras são as mesmas. Os dados ficam no navegador, na origem +do Monky e não na do miniapp, que só os alcança por `monkyScreen.storage`. Sair +da conta na versão web apaga esses dados. + | API | Comportamento | |-----|---------------| | `status` | `'granted'`, `'denied'` ou `'unavailable'` | diff --git a/docs-site/bots-desenvolvimento.md b/docs-site/bots-desenvolvimento.md index dbfe97f7..d3e4500b 100644 --- a/docs-site/bots-desenvolvimento.md +++ b/docs-site/bots-desenvolvimento.md @@ -17,7 +17,9 @@ encaminha as respostas. - Use **Node.js 22 ou 24** e npm. - Tenha um servidor Monky de teste e acesso para gerenciar seus bots. -- Cliente, servidor e SDK de bots precisam usar o mesmo `PROTOCOL_VERSION`. +- O servidor aceita bots a partir do protocolo 24 (`MIN_BOT_PROTOCOL`) e só + habilita os recursos que os dois lados anunciam. Um recurso novo do SDK + exige um servidor que também o ofereça. - Confira `monky-compatibility-.json` na release escolhida antes de conectar. O assistente não muda o protocolo nem atualiza seu servidor. diff --git a/docs-site/configuracoes.md b/docs-site/configuracoes.md index f5774e93..1ae9e66a 100644 --- a/docs-site/configuracoes.md +++ b/docs-site/configuracoes.md @@ -190,13 +190,14 @@ A câmera mantém o teto de 120 FPS, ou 60 FPS em 4K. Ela usa a captura do Chromium e os modos oferecidos pela webcam, um caminho diferente da captura nativa de tela. -O **Formato do vídeo** decide quem consegue assistir à tela. **Melhor qualidade**, -o padrão do app desktop, usa o codec escolhido (H.264 Main ou AV1), que o Chrome, -o Edge e o app desktop assistem. **Compatível**, o padrão da versão web, usa -H.264 Constrained Baseline, que todos assistem, inclusive o Firefox e o Safari. -No Compatível, a tela sai sempre em H.264, e um 4K acima de 64 FPS é reduzido, -porque o Firefox decodifica esse perfil só até o nível 5.2. A escolha vale a -partir do próximo compartilhamento. +O **Formato do vídeo** aparece abaixo de **Codificação de tela** só quando a tela +sai em H.264, e decide quem consegue assistir. **Melhor qualidade**, o padrão no +app desktop e na versão web, usa o H.264 Main, que o Chrome, o Edge e o app +desktop assistem. **Compatível** usa H.264 Constrained Baseline, que todos +assistem, inclusive o Firefox e o Safari; um 4K acima de 64 FPS é reduzido, +porque o Firefox decodifica esse perfil só até o nível 5.2. O formato não muda o +codec: o Automático continua escolhendo o AV1 quando disponível, e com AV1 a +opção some e não tem efeito. A escolha vale a partir do próximo compartilhamento. Qualquer alteração de qualidade — preset, resolução, FPS, bitrate, codec ou Hardware/Software — valida a combinação antes de aplicá-la. Se necessário, o diff --git a/docs-site/en/administrar-servidor.md b/docs-site/en/administrar-servidor.md index 83666e82..aab274eb 100644 --- a/docs-site/en/administrar-servidor.md +++ b/docs-site/en/administrar-servidor.md @@ -19,7 +19,7 @@ depend on your access. | Tab | Purpose | | --- | --- | | General | Name, image and banner, event activation, limits, voice mode and server process information | -| Security | Entry password | +| Security | Entry password: whether it is active, show, copy, change or remove it | | Voice & Video | Soundboard access, recent audio cache, direct-message relay, the AFK channel and the integrated TURN relay | | Storage | Attachment limits and usage | | Notifications | Mentions, message editing and notices published in chat | @@ -29,8 +29,11 @@ depend on your access. ### Immediate application -Switches and selections apply immediately. Names, passwords and limits -apply when you finish editing, leave the field or press `Enter`. **Done** +Switches and selections apply immediately. Names and limits +apply when you finish editing, leave the field or press `Enter`. A new +password is typed twice and applies with **Save password**; removing it asks +for confirmation. **Show** and **Copy** only work on a device that knows the +password, such as the one that connected with it or hosts the server. **Done** only closes the window; it does not undo confirmed changes. Closing is blocked while an operation is pending. A TURN download at 100% diff --git a/docs-site/en/arquitetura.md b/docs-site/en/arquitetura.md index be7ca60e..62c33c25 100644 --- a/docs-site/en/arquitetura.md +++ b/docs-site/en/arquitetura.md @@ -90,6 +90,12 @@ to download the app; elsewhere, activating the item opens that offer. One rule d from `utils/desktopOnly.ts`. Only the desktop's title bar and window buttons are left out on the web. +When a feature exists on both, its rules live in one engine and each platform +supplies the storage. Direct messages use `packages/dm`. Miniapp storage on the +device uses `botDeviceStorageEngine.ts`, in `packages/shared`: the desktop writes +files from the main process, and the web writes to IndexedDB on the app's origin, +never on the miniapps' origin. + Three barriers protect that bridge. The Content-Security-Policy in `index.html` only runs the app's own scripts, so HTML injected by mistake does not execute; a new kind of resource (another iframe, worker or origin) has to be added to @@ -284,10 +290,10 @@ the browser's own dialog (`getDisplayMedia`), each requested profile is a copy of the capture scaled down by the browser, and it is sent as H.264 over a connection of its own per viewer in P2P or through a pair of screen producers on the SFU, with the same `monky-native-screen-p2p` protocol and rendition -data. The default format is Constrained Baseline (`42e0..`, packetization-mode -1), which every receiver decodes, Firefox and Safari included; the publisher -can choose H.264 Main, a better picture that only Chrome, Edge and the desktop -watch. The codec is pinned by `setCodecPreferences` in P2P and by the +data. The default format is H.264 Main, a better picture that only Chrome, Edge +and the desktop watch; the publisher can choose Constrained Baseline (`42e0..`, +packetization-mode 1), which every receiver decodes, Firefox and Safari +included. The codec is pinned by `setCodecPreferences` in P2P and by the producer's codec on the SFU, even when the browser would use its hardware encoder. The renderer above it does not change; the picker is the browser's dialog (`views/screenShare/BrowserScreenSharePicker.ts`), and the web version @@ -296,8 +302,10 @@ profile or codec (Firefox facing H.264 Main or AV1) gets a message naming the format, not the generic connection failure. The desktop app offers the same pair of formats in Quality & sharing, with -Better quality (H.264 Main or AV1) as its default. In Compatible, Main picks -only H.264 encoders and passes `--h264-profile=constrained-baseline` to the +Better quality as its default. The format is an H.264 profile and does not +narrow the codec: Automatic still picks AV1 when available. When the screen is +sent in H.264 with Compatible, Main passes +`--h264-profile=constrained-baseline` to the capture host: x264, NVENC and AMF use OBS's `baseline` profile, and VideoToolbox uses `H264_ConstrainedBaseline_AutoLevel`. The host and the engine set `constraint_set1` on a Baseline SPS without slice groups or redundant @@ -478,9 +486,10 @@ Payload validation uses **zod**, with the schemas in `packages/shared` — the v same ones the client uses to validate before sending. ::: warning Negotiated compatibility, never an unrestricted downgrade -Protocol **26** negotiates independent minimum versions: **26 for clients** and -**24 for bots**, plus feature flags in `AUTH_CONNECT`/`AUTH_SUCCESS`. The native -4K/80 Mbps screen contract requires updating client and server; clients 24/25 +Since protocol **26**, client and bot minimum versions are independent. Today +they are **35 for clients** (`MIN_CLIENT_PROTOCOL`) and **24 for bots** +(`MIN_BOT_PROTOCOL`), plus feature flags in `AUTH_CONNECT`/`AUTH_SUCCESS`. The native +4K/80 Mbps screen contract requires updating client and server; older clients are not silently downgraded. Version 24 bots retain their existing audio and command contract. Message blocks and message-limit settings are enabled only when negotiated. The SDK may retry authentication once using an old server's diff --git a/docs-site/en/bots-armazenamento.md b/docs-site/en/bots-armazenamento.md index ddd97968..5c19332f 100644 --- a/docs-site/en/bots-armazenamento.md +++ b/docs-site/en/bots-armazenamento.md @@ -118,6 +118,10 @@ decision between **Ask**, **Always** and **Never**, sets the limit (50 MB by default, from 1 MB to 100 GB) and deletes the data. Changes apply at once, including to an open miniapp. +The web version follows the same rules. The data stays in the browser, on +Monky's origin and not on the miniapp's, which only reaches it through +`monkyScreen.storage`. Logging out of the web version deletes this data. + | API | Behavior | |-----|----------| | `status` | `'granted'`, `'denied'` or `'unavailable'` | diff --git a/docs-site/en/bots-desenvolvimento.md b/docs-site/en/bots-desenvolvimento.md index 850ef249..25907ee5 100644 --- a/docs-site/en/bots-desenvolvimento.md +++ b/docs-site/en/bots-desenvolvimento.md @@ -17,7 +17,9 @@ responses. - Use **Node.js 22 or 24** and npm. - Have a test Monky server and permission to manage its bots. -- Client, server and bot SDK must use the same `PROTOCOL_VERSION`. +- The server accepts bots from protocol 24 (`MIN_BOT_PROTOCOL`) and only + enables the features both sides advertise. A new SDK feature needs a server + that offers it too. - Check `monky-compatibility-.json` in the selected release before connecting. The assistant does not change the protocol or update your server. diff --git a/docs-site/en/configuracoes.md b/docs-site/en/configuracoes.md index 700384f5..6571e7c2 100644 --- a/docs-site/en/configuracoes.md +++ b/docs-site/en/configuracoes.md @@ -187,13 +187,14 @@ Camera keeps its 120 FPS ceiling, or 60 FPS at 4K. It uses Chromium capture and the modes offered by the webcam, a different path from native screen capture. -The **Video format** decides who can watch the screen. **Better quality**, the -desktop app's default, uses the chosen codec (H.264 Main or AV1), which Chrome, -Edge and the desktop app watch. **Compatible**, the web version's default, uses -H.264 Constrained Baseline, which everyone watches, Firefox and Safari included. -In Compatible, the screen is always sent in H.264, and 4K above 64 FPS is -lowered, because Firefox decodes this profile only up to Level 5.2. The choice -applies from the next share. +The **Video format** appears below **Screen encoding** only while the screen is +sent in H.264, and decides who can watch it. **Better quality**, the default on +the desktop app and the web version, uses H.264 Main, which Chrome, Edge and the +desktop app watch. **Compatible** uses H.264 Constrained Baseline, which +everyone watches, Firefox and Safari included; 4K above 64 FPS is lowered, +because Firefox decodes this profile only up to Level 5.2. The format does not +change the codec: Automatic still picks AV1 when available, and with AV1 the +option is hidden and has no effect. The choice applies from the next share. Every quality change — preset, resolution, FPS, bitrate, codec, or Hardware/Software — validates the combination before applying it. When needed, diff --git a/docs-site/en/faq.md b/docs-site/en/faq.md index afadbe78..b9cf745e 100644 --- a/docs-site/en/faq.md +++ b/docs-site/en/faq.md @@ -172,6 +172,14 @@ chosen provider charges. The app is available for Windows and macOS. See [Download](/en/download). +There is also the web version, at +[monkyweb.vercel.app](https://monkyweb.vercel.app/), which runs in the browser +with nothing to install. It only joins servers with a secure connection +(`wss://`), such as those that turned on the +[automatic secure connection](/en/cli#monky-tls). Creating your own server, +the overlay, push-to-talk, global shortcuts and local network servers stay in +the app. + ### Where can I get help or report a problem? - Check [Troubleshooting](/en/solucao-de-problemas). diff --git a/docs-site/faq.md b/docs-site/faq.md index eecb8ed9..2dc3c31a 100644 --- a/docs-site/faq.md +++ b/docs-site/faq.md @@ -173,6 +173,13 @@ custo do provedor que você escolher. O aplicativo está disponível para Windows e macOS. Veja o [Download](/download). +Também existe a versão web, em +[monkyweb.vercel.app](https://monkyweb.vercel.app/), que roda no navegador sem +instalar nada. Ela só entra em servidores com conexão segura (`wss://`), como +os que ativaram a [conexão segura automática](/cli#monky-tls). Criar o próprio +servidor, overlay, push-to-talk, atalhos globais e servidores na rede local +continuam só no aplicativo. + ### Onde peço ajuda ou relato um problema? - Consulte a [Solução de problemas](/solucao-de-problemas). diff --git a/packages/shared/package.json b/packages/shared/package.json index bf84419a..abf62171 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", - "test": "tsc -p tsconfig.test.json && node dist-test/test/validators.test.js && node --test dist-test/test/channelPermissions.test.js dist-test/test/botScreens.test.js dist-test/test/nativePolls.test.js dist-test/test/botInteractions.test.js dist-test/test/botSettings.test.js dist-test/test/botPermissions.test.js dist-test/test/botStorage.test.js dist-test/test/releaseCompatibility.test.js dist-test/test/botDiagnostics.test.js dist-test/test/serverMonitor.test.js dist-test/test/serverAudit.test.js dist-test/test/localExecution.test.js dist-test/test/localExecutionProtocol.test.js dist-test/test/serverInvites.test.js dist-test/test/serverAuth.test.js dist-test/test/botMessages.test.js dist-test/test/screenSubscriptions.test.js dist-test/test/nativeAudioIpc.test.js dist-test/test/screenSharing.test.js dist-test/test/secretEnvelope.test.js dist-test/test/voiceSchemas.test.js dist-test/test/mentions.test.js dist-test/test/abort.test.js dist-test/test/githubReleases.test.js dist-test/test/reactions.test.js dist-test/test/screenPrivacy.test.js dist-test/test/lruCache.test.js dist-test/test/testInventory.test.js dist-test/test/protocolPayloads.test.js dist-test/test/trustedProxies.test.js", + "test": "tsc -p tsconfig.test.json && node dist-test/test/validators.test.js && node --test dist-test/test/channelPermissions.test.js dist-test/test/botScreens.test.js dist-test/test/nativePolls.test.js dist-test/test/botInteractions.test.js dist-test/test/botSettings.test.js dist-test/test/botPermissions.test.js dist-test/test/botStorage.test.js dist-test/test/botDeviceStorageEngine.test.js dist-test/test/releaseCompatibility.test.js dist-test/test/botDiagnostics.test.js dist-test/test/serverMonitor.test.js dist-test/test/serverAudit.test.js dist-test/test/localExecution.test.js dist-test/test/localExecutionProtocol.test.js dist-test/test/serverInvites.test.js dist-test/test/serverAuth.test.js dist-test/test/botMessages.test.js dist-test/test/screenSubscriptions.test.js dist-test/test/nativeAudioIpc.test.js dist-test/test/screenSharing.test.js dist-test/test/secretEnvelope.test.js dist-test/test/voiceSchemas.test.js dist-test/test/mentions.test.js dist-test/test/abort.test.js dist-test/test/githubReleases.test.js dist-test/test/reactions.test.js dist-test/test/screenPrivacy.test.js dist-test/test/lruCache.test.js dist-test/test/testInventory.test.js dist-test/test/protocolPayloads.test.js dist-test/test/trustedProxies.test.js", "clean": "rimraf dist" }, "dependencies": { diff --git a/packages/shared/src/botDeviceStorageEngine.ts b/packages/shared/src/botDeviceStorageEngine.ts new file mode 100644 index 00000000..6a3a2579 --- /dev/null +++ b/packages/shared/src/botDeviceStorageEngine.ts @@ -0,0 +1,457 @@ +import { + BOT_STORAGE_LIMITS, botDeviceStorageFileSchema, botDeviceStorageRequestSchema, botDeviceStorageScopeIdSchema, + botDeviceStorageScopeSchema, botStorageKeySchema, botStorageLevel, botStoragePrefixSchema, botStorageQuotaSchema, + type BotDeviceStorageDecision, type BotDeviceStorageDocumentState, type BotDeviceStorageEntry, type BotDeviceStorageFailure, + type BotDeviceStorageFile, type BotDeviceStorageGrant, type BotDeviceStorageKeyInfo, type BotDeviceStorageResponse, + type BotDeviceStorageScope, type BotDeviceStorageStatus, type BotDeviceStorageValue, type BotStorageUsage, +} from './botStorage.js'; + +/** + * What bots keep on the device running their miniapps (#861): one rule set for + * the desktop, which writes files, and the web version, which writes IndexedDB. + * Only the backend differs; decisions, quota, clearing and forgetting are here. + */ +export class BotDeviceStorageError extends Error { + constructor(readonly reason: BotDeviceStorageFailure, options?: { cause?: unknown }) { + super(`Bot storage on this computer failed: ${reason}`, options); + } +} + +export function isBotDeviceStorageScopeId(value: unknown): value is string { + return botDeviceStorageScopeIdSchema.safeParse(value).success; +} + +/** + * The text hashed (SHA-256, hex) into a scope id: server address, server, account and bot, + * like the bot preferences of a person. Display names never pick a different scope. + */ +export function botDeviceStorageScopeIdentity(scope: BotDeviceStorageScope): string { + const url = new URL(scope.serverUrl); + return JSON.stringify([url.origin, url.pathname, scope.serverId, scope.userId, scope.botId]); +} + +/** + * Where one platform keeps the scopes. Each scope has an index and objects named by + * the engine (`.bin`), so no key ever reaches a storage name. A scope whose + * index was never written did not finish its first save and holds nothing. + */ +export interface BotDeviceStorageBackend { + /** Lowercase hex SHA-256 of a scope identity. */ + scopeId(identity: string): string; + /** A fresh random UUID. */ + randomId(): string; + /** Prepares the store; called before every operation, so it must be cheap once done. */ + initialize(): Promise; + /** The names of every scope present, including unfinished ones; the engine ignores names that are not scope ids. */ + scopes(): Promise; + /** Whether anything at all is present for the scope. */ + hasScope(scopeId: string): Promise; + /** The parsed, still untrusted index, or null when there is none (leftovers of an unfinished first save are removed). */ + readIndex(scopeId: string): Promise; + /** Replaces the index atomically: readers see the old one or the new one. */ + writeIndex(scopeId: string, index: BotDeviceStorageFile): Promise; + /** Removes whatever an interrupted write left: objects not in `referenced` and staging copies. */ + removeUnreferenced(scopeId: string, referenced: ReadonlySet): Promise; + /** Writes a new object atomically. */ + writeObject(scopeId: string, file: string, bytes: Uint8Array): Promise; + readObject(scopeId: string, file: string): Promise; + /** Removes an object no longer referenced; one already gone is fine, and failures only warn. */ + removeObject(scopeId: string, file: string): Promise; + /** Removes the scope with everything in it. */ + removeScope(scopeId: string): Promise; +} + +type IndexEntry = BotDeviceStorageFile['objects'][number]; + +interface ScopeState { + id: string; + scope: BotDeviceStorageScope; + decision: BotDeviceStorageDecision; + maxBytes: number; + objects: Map; + /** False until something was saved: reading or opening a miniapp never creates anything. */ + persisted: boolean; + /** The last write did not fit; the bot shows as full until space is freed or added. */ + refused: boolean; + updatedAt: number; +} + +function failure(error: unknown): BotDeviceStorageError { + return error instanceof BotDeviceStorageError ? error : new BotDeviceStorageError('failed', { cause: error }); +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** + * The storage rules, over an injected backend. Every scope is serialized, and the + * index is replaced only after the new object was written. + */ +export class BotDeviceStorageEngine { + private readonly states = new Map(); + private readonly queues = new Map>(); + private closed = false; + + constructor( + private readonly backend: BotDeviceStorageBackend, + private readonly changed: (scopeId: string) => void = () => undefined, + ) {} + + /** The id of a scope; throws `invalid` for one that does not validate. */ + scopeId(scope: BotDeviceStorageScope): string { + return this.scope(scope).id; + } + + async entry(input: BotDeviceStorageScope): Promise { + const scope = this.scope(input); + return this.serial(scope.id, async () => this.toEntry(await this.state(scope.id, scope.value))); + } + + async list(): Promise { + let names: string[]; + try { + await this.backend.initialize(); + names = await this.backend.scopes(); + } catch (error) { + throw failure(error); + } + const entries: BotDeviceStorageEntry[] = []; + for (const id of names.filter(isBotDeviceStorageScopeId).slice(0, BOT_STORAGE_LIMITS.deviceScopes)) { + try { + const state = await this.serial(id, () => this.state(id)); + if (state) entries.push(this.toEntry(state)); + } catch (error) { + console.warn('[Bot storage] Ignoring an unreadable storage scope.', error); + } + } + return entries.sort((first, second) => second.updatedAt - first.updatedAt); + } + + /** Access is decided from the stored preference on every operation. */ + async decision(scopeId: string): Promise { + if (!isBotDeviceStorageScopeId(scopeId)) return 'never'; + return this.serial(scopeId, async () => (await this.state(scopeId))?.decision ?? 'ask'); + } + + async setDecision(input: BotDeviceStorageScope, decision: BotDeviceStorageDecision): Promise { + const scope = this.scope(input); + return this.mutate(scope.id, async () => { + const state = await this.state(scope.id, scope.value); + return this.toEntry(await this.save({ ...state, scope: scope.value, decision, updatedAt: Date.now() })); + }); + } + + async setQuota(input: BotDeviceStorageScope, maxBytes: number): Promise { + const scope = this.scope(input); + const quota = botStorageQuotaSchema.safeParse(maxBytes); + if (!quota.success) throw new BotDeviceStorageError('invalid'); + return this.mutate(scope.id, async () => { + const state = await this.state(scope.id, scope.value); + return this.toEntry(await this.save({ ...state, scope: scope.value, maxBytes: quota.data, refused: false, updatedAt: Date.now() })); + }); + } + + /** Deletes the objects and keeps the person's preference for this bot. */ + async clear(scopeId: string): Promise { + if (!isBotDeviceStorageScopeId(scopeId)) throw new BotDeviceStorageError('invalid'); + return this.mutate(scopeId, async () => { + const state = await this.state(scopeId); + if (!state) return; + const files = [...state.objects.values()].map((entry) => entry.file); + await this.save({ ...state, objects: new Map(), refused: false, updatedAt: Date.now() }); + for (const file of files) await this.backend.removeObject(scopeId, file); + }); + } + + /** Deletes the objects and the preferences, as if the bot had never stored anything here. */ + async forget(scopeId: string): Promise { + if (!isBotDeviceStorageScopeId(scopeId)) throw new BotDeviceStorageError('invalid'); + return this.mutate(scopeId, async () => { + await this.backend.initialize(); + this.states.delete(scopeId); + await this.backend.removeScope(scopeId); + }); + } + + async get(input: BotDeviceStorageScope, keyInput: string): Promise<{ value: BotDeviceStorageValue | null; usage: BotStorageUsage }> { + const scope = this.scope(input); + const key = this.key(keyInput); + return this.serial(scope.id, async () => { + const state = await this.state(scope.id, scope.value); + const entry = state.objects.get(key); + if (!entry) return { value: null, usage: this.usageOf(state) }; + let bytes: Uint8Array = new Uint8Array(0); + if (entry.sizeBytes > 0) { + bytes = await this.backend.readObject(scope.id, entry.file); + if (bytes.byteLength !== entry.sizeBytes) throw new BotDeviceStorageError('failed'); + } + const value: BotDeviceStorageValue = entry.kind === 'text' + ? { kind: 'text', text: decoder.decode(bytes) } : { kind: 'bytes', bytes: new Uint8Array(bytes) }; + return { value, usage: this.usageOf(state) }; + }); + } + + async set(input: BotDeviceStorageScope, keyInput: string, value: BotDeviceStorageValue): Promise { + const scope = this.scope(input); + const key = this.key(keyInput); + const bytes = value.kind === 'text' ? encoder.encode(value.text) : value.bytes; + if (bytes.byteLength > BOT_STORAGE_LIMITS.objectBytes) throw new BotDeviceStorageError('invalid'); + return this.mutate(scope.id, async () => { + const state = await this.state(scope.id, scope.value); + const previous = state.objects.get(key); + const usage = this.usageOf(state); + if (usage.usedBytes - (previous?.sizeBytes ?? 0) + bytes.byteLength > state.maxBytes || + (!previous && state.objects.size >= BOT_STORAGE_LIMITS.deviceObjects)) { + if (!state.refused) { + state.refused = true; + this.changed(scope.id); + } + throw new BotDeviceStorageError('quota'); + } + await this.requireRoom(state); + const file = `${this.backend.randomId()}.bin`; + await this.backend.writeObject(scope.id, file, bytes); + const objects = new Map(state.objects); + objects.set(key, { key, file, sizeBytes: bytes.byteLength, kind: value.kind, updatedAt: Date.now() }); + let saved: ScopeState; + try { + saved = await this.save({ ...state, scope: scope.value, objects, refused: false, updatedAt: Date.now() }); + } catch (error) { + await this.backend.removeObject(scope.id, file); + throw error; + } + if (previous) await this.backend.removeObject(scope.id, previous.file); + return this.usageOf(saved); + }); + } + + async remove(input: BotDeviceStorageScope, keyInput: string): Promise<{ removed: boolean; usage: BotStorageUsage }> { + const scope = this.scope(input); + const key = this.key(keyInput); + return this.mutate(scope.id, async () => { + const state = await this.state(scope.id, scope.value); + const previous = state.objects.get(key); + if (!previous) return { removed: false, usage: this.usageOf(state) }; + const objects = new Map(state.objects); + objects.delete(key); + const saved = await this.save({ ...state, objects, refused: false, updatedAt: Date.now() }); + await this.backend.removeObject(scope.id, previous.file); + return { removed: true, usage: this.usageOf(saved) }; + }); + } + + async keys(input: BotDeviceStorageScope, prefixInput = ''): Promise<{ keys: BotDeviceStorageKeyInfo[]; usage: BotStorageUsage }> { + const scope = this.scope(input); + const prefix = botStoragePrefixSchema.safeParse(prefixInput); + if (!prefix.success) throw new BotDeviceStorageError('invalid'); + return this.serial(scope.id, async () => { + const state = await this.state(scope.id, scope.value); + const keys = [...state.objects.values()].filter((entry) => entry.key.startsWith(prefix.data)) + .sort((first, second) => first.key < second.key ? -1 : first.key > second.key ? 1 : 0) + .map(({ key, sizeBytes, updatedAt }) => ({ key, sizeBytes, updatedAt })); + return { keys, usage: this.usageOf(state) }; + }); + } + + async usage(input: BotDeviceStorageScope): Promise { + const scope = this.scope(input); + return this.serial(scope.id, async () => this.usageOf(await this.state(scope.id, scope.value))); + } + + /** Waits for what is running; anything asked afterwards fails as `unavailable`. Idempotent. */ + async dispose(): Promise { + this.closed = true; + await Promise.allSettled([...this.queues.values()]); + this.states.clear(); + } + + private scope(input: BotDeviceStorageScope): { id: string; value: BotDeviceStorageScope } { + const parsed = botDeviceStorageScopeSchema.safeParse(input); + if (!parsed.success) throw new BotDeviceStorageError('invalid'); + return { id: this.backend.scopeId(botDeviceStorageScopeIdentity(parsed.data)), value: parsed.data }; + } + + private key(input: string): string { + const parsed = botStorageKeySchema.safeParse(input); + if (!parsed.success) throw new BotDeviceStorageError('invalid'); + return parsed.data; + } + + private usageOf(state: ScopeState): BotStorageUsage { + let usedBytes = 0; + for (const entry of state.objects.values()) usedBytes += entry.sizeBytes; + return { + usedBytes, maxBytes: state.maxBytes, objectCount: state.objects.size, maxObjects: BOT_STORAGE_LIMITS.deviceObjects, + level: botStorageLevel(usedBytes, state.maxBytes, state.refused), + }; + } + + private toEntry(state: ScopeState): BotDeviceStorageEntry { + return { id: state.id, scope: { ...state.scope }, decision: state.decision, usage: this.usageOf(state), updatedAt: state.updatedAt }; + } + + /** Without `fallback`, an unknown scope is null; with it, a default that is saved only on the first change. */ + private state(id: string): Promise; + private state(id: string, fallback: BotDeviceStorageScope): Promise; + private async state(id: string, fallback?: BotDeviceStorageScope): Promise { + await this.backend.initialize(); + const cached = this.states.get(id); + if (cached) return cached; + // An unreadable index (it may come from a newer Monky) fails closed instead of being replaced. + const raw = await this.backend.readIndex(id); + if (raw !== null && raw !== undefined) { + const data = botDeviceStorageFileSchema.parse(raw); + if (this.backend.scopeId(botDeviceStorageScopeIdentity(data.scope)) !== id) throw new BotDeviceStorageError('failed'); + const loaded: ScopeState = { + id, scope: data.scope, decision: data.decision, maxBytes: data.maxBytes, + objects: new Map(data.objects.map((entry) => [entry.key, entry])), persisted: true, refused: false, updatedAt: data.updatedAt, + }; + await this.backend.removeUnreferenced(id, new Set(data.objects.map((entry) => entry.file))); + this.states.set(id, loaded); + return loaded; + } + if (!fallback) return null; + return { + id, scope: fallback, decision: 'ask', maxBytes: BOT_STORAGE_LIMITS.deviceDefaultBytes, objects: new Map(), + persisted: false, refused: false, updatedAt: Date.now(), + }; + } + + /** Bounds how many server, account and bot scopes one device keeps. */ + private async requireRoom(state: ScopeState): Promise { + if (state.persisted || await this.backend.hasScope(state.id)) return; + const names = await this.backend.scopes(); + if (names.filter(isBotDeviceStorageScopeId).length >= BOT_STORAGE_LIMITS.deviceScopes) throw new BotDeviceStorageError('quota'); + } + + private async save(state: ScopeState): Promise { + await this.requireRoom(state); + const data: BotDeviceStorageFile = { + version: 1, scope: state.scope, decision: state.decision, maxBytes: state.maxBytes, + objects: [...state.objects.values()], updatedAt: state.updatedAt, + }; + await this.backend.writeIndex(state.id, botDeviceStorageFileSchema.parse(data)); + const saved = { ...state, persisted: true }; + this.states.set(state.id, saved); + return saved; + } + + private mutate(scopeId: string, operation: () => Promise): Promise { + return this.serial(scopeId, operation).then((result) => { + this.changed(scopeId); + return result; + }); + } + + private serial(scopeId: string, operation: () => Promise): Promise { + if (this.closed) return Promise.reject(new BotDeviceStorageError('unavailable')); + const previous = this.queues.get(scopeId) ?? Promise.resolve(); + const result = previous.then(operation, operation).catch((error: unknown) => { throw failure(error); }); + const tail = result.then(() => undefined, () => undefined); + this.queues.set(scopeId, tail); + void tail.then(() => { if (this.queues.get(scopeId) === tail) this.queues.delete(scopeId); }); + return result; + } +} + +/** Storage that one opened miniapp document may reach. */ +export interface BotScreenStorageBinding { + scopeId: string; + scope: BotDeviceStorageScope; + /** "Only this session": lasts while this document exists; reopening asks again. */ + session: boolean; +} + +/** What the person chose when opening a miniapp, applied: "always" is remembered, but never overrides a "never". */ +export async function bindBotScreenStorage( + storage: BotDeviceStorageEngine, choice: { scope: BotDeviceStorageScope; grant: BotDeviceStorageGrant }, +): Promise { + const scopeId = storage.scopeId(choice.scope); + let session = choice.grant === 'session'; + if (choice.grant === 'always') { + try { + if (await storage.decision(scopeId) === 'ask') await storage.setDecision(choice.scope, 'always'); + } catch (error) { + // The person allowed it: that holds for this view, and each storage operation reports its own failure. + console.warn('[Bot storage] Could not remember the storage decision; it applies to this view only.', error); + session = true; + } + } + return { scopeId, scope: choice.scope, session }; +} + +async function accessStatus(storage: BotDeviceStorageEngine, binding: BotScreenStorageBinding | null): Promise { + if (!binding) return 'unavailable'; + const decision = await storage.decision(binding.scopeId); + return decision === 'always' || (decision === 'ask' && binding.session) ? 'granted' : 'denied'; +} + +/** The storage state a miniapp document sees; the decision is read again on every call. */ +export async function botScreenStorageState( + storage: BotDeviceStorageEngine, binding: BotScreenStorageBinding | null, +): Promise { + const status = await accessStatus(storage, binding); + return { + status, scopeId: binding?.scopeId ?? null, + usage: binding && status === 'granted' ? await storage.usage(binding.scope) : null, + }; +} + +/** Answers one request of a miniapp document, only while the person allows it and `allow` (the rate limit) agrees. */ +export async function answerBotScreenStorageRequest( + storage: BotDeviceStorageEngine, binding: BotScreenStorageBinding | null, input: unknown, allow: () => boolean = () => true, +): Promise { + const request = botDeviceStorageRequestSchema.safeParse(input); + if (!request.success) return { ok: false, reason: 'invalid', usage: null }; + if (!allow()) return { ok: false, reason: 'failed', usage: null }; + const status = await accessStatus(storage, binding); + if (!binding) return { ok: false, reason: 'unavailable', usage: null }; + if (status !== 'granted') return { ok: false, reason: 'denied', usage: null }; + try { + switch (request.data.op) { + case 'get': { + const { value, usage } = await storage.get(binding.scope, request.data.key); + return { ok: true, op: 'get', value, usage }; + } + case 'set': + return { ok: true, op: 'set', usage: await storage.set(binding.scope, request.data.key, request.data.value) }; + case 'remove': { + const { removed, usage } = await storage.remove(binding.scope, request.data.key); + return { ok: true, op: 'remove', removed, usage }; + } + case 'list': { + const { keys, usage } = await storage.keys(binding.scope, request.data.prefix); + return { ok: true, op: 'list', keys, usage }; + } + case 'usage': + return { ok: true, op: 'usage', usage: await storage.usage(binding.scope) }; + } + } catch (error) { + const reason = error instanceof BotDeviceStorageError ? error.reason : 'failed'; + if (reason === 'failed') console.warn('[Bot storage] A miniapp storage operation failed.', error); + return { ok: false, reason, usage: reason === 'quota' ? await storage.usage(binding.scope).catch(() => null) : null }; + } +} + +/** At most `BOT_STORAGE_LIMITS.operationsPerSecond` requests per document and second. */ +export class BotScreenStorageRateLimit { + private readonly windows = new Map(); + + constructor(private readonly now: () => number = Date.now) {} + + allow(document: string): boolean { + const now = this.now(); + const window = this.windows.get(document); + if (!window || now - window.start >= 1000) { + // Stale windows of closed documents never outlive a second of inactivity. + for (const [key, value] of this.windows) if (now - value.start >= 1000) this.windows.delete(key); + this.windows.set(document, { start: now, count: 1 }); + return true; + } + return ++window.count <= BOT_STORAGE_LIMITS.operationsPerSecond; + } + + clear(): void { + this.windows.clear(); + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 89be01aa..e15f156d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -72,6 +72,7 @@ export * from './localExecution.js'; export * from './localExecutionProtocol.js'; export * from './botPermissions.js'; export * from './botStorage.js'; +export * from './botDeviceStorageEngine.js'; export * from './protocolCompatibility.js'; export * from './botDiagnostics.js'; export * from './botAuth.js'; diff --git a/packages/shared/src/nativeScreenIpc.ts b/packages/shared/src/nativeScreenIpc.ts index 9565b7ca..5d5c417c 100644 --- a/packages/shared/src/nativeScreenIpc.ts +++ b/packages/shared/src/nativeScreenIpc.ts @@ -99,8 +99,6 @@ const screenCommandSchema = (sourceId: z.ZodString) => z.discriminatedUnion('act action: z.literal('probe-encoding'), video: nativeScreenVideoProfileSchema, probeId: uuid, encodingMode: screenEncodingModeSchema, codec: screenCodecPreferenceSchema, encodingStrategy: screenEncodingStrategySchema.default('automatic'), - /** The compatible format checks only H.264 encoders (#858). */ - screenFormat: screenFormatSchema.optional(), }).strict(), z.object({ action: z.literal('cancel-encoding-probe'), probeId: uuid }).strict(), nativeScreenCallSchema.extend({ action: z.literal('join') }).strict(), @@ -122,7 +120,7 @@ const screenCommandSchema = (sourceId: z.ZodString) => z.discriminatedUnion('act video: nativeScreenVideoProfileSchema, audio: z.boolean(), audioBitrateKbps: nativeScreenAudioBitrateSchema, encodingMode: screenEncodingModeSchema.optional(), codec: screenCodecPreferenceSchema.optional(), encodingStrategy: screenEncodingStrategySchema.default('automatic'), - /** The H.264 the screen is shared in; absent means the platform's default. Compatible is always H.264. */ + /** The H.264 profile the screen is shared in; absent means Main. It has no effect on an AV1 screen (#890). */ screenFormat: screenFormatSchema.optional(), }).strict(), callScope.extend({ action: z.literal('source-remove'), shareId: screenShareIdSchema }).strict(), diff --git a/packages/shared/src/screenShareFailures.ts b/packages/shared/src/screenShareFailures.ts index 04aba8a7..f67f0970 100644 --- a/packages/shared/src/screenShareFailures.ts +++ b/packages/shared/src/screenShareFailures.ts @@ -38,7 +38,7 @@ export function nativeErrorCodes(error: unknown): string[] { /** Most specific first: when several codes come together, the first category here explains the failure. */ export const SCREEN_SHARE_FAILURE_CATEGORIES = [ - 'game', 'permission', 'source-lost', 'files', 'encoder', 'quality', 'capacity', 'interrupted', 'unexpected', + 'game', 'permission', 'source-lost', 'files', 'no-gpu', 'encoder', 'quality', 'capacity', 'interrupted', 'unexpected', ] as const; export type ScreenShareFailureCategory = typeof SCREEN_SHARE_FAILURE_CATEGORIES[number]; @@ -59,6 +59,8 @@ const byCategory: Readonly 'ERR_SCREEN_CAPTURE_RUN_DIRECTORY', 'ERR_SCREEN_CAPTURE_STARTUP_MODE', 'ERR_SCREEN_CAPTURE_CORE', 'ERR_MAC_AV1_RUNTIME', 'ERR_MAC_AV1_RUNTIME_PATH', 'ERR_MAC_HOST_START', 'ERR_SCREEN_AUDIO_UNAVAILABLE', ], + // Adapter 0 is only a software renderer, as in a virtual machine: no encoder, not even software, can capture. + 'no-gpu': ['ERR_SCREEN_CAPTURE_NO_GPU'], encoder: [ 'ERR_SCREEN_CAPTURE_ENCODER_UNAVAILABLE', 'ERR_SCREEN_CAPTURE_ENCODER_UNSUPPORTED', 'ERR_SCREEN_CAPTURE_ENCODER_INITIALIZATION', 'ERR_SCREEN_CAPTURE_ENCODER_REROUTE', diff --git a/packages/shared/test/botDeviceStorageEngine.test.ts b/packages/shared/test/botDeviceStorageEngine.test.ts new file mode 100644 index 00000000..1cac3fcb --- /dev/null +++ b/packages/shared/test/botDeviceStorageEngine.test.ts @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test from 'node:test'; +import { + BOT_STORAGE_LIMITS, type BotDeviceStorageFile, type BotDeviceStorageScope, +} from '../src/botStorage.js'; +import { + BotDeviceStorageEngine, BotDeviceStorageError, BotScreenStorageRateLimit, answerBotScreenStorageRequest, + bindBotScreenStorage, botDeviceStorageScopeIdentity, botScreenStorageState, type BotDeviceStorageBackend, +} from '../src/botDeviceStorageEngine.js'; + +/** The same rules over a map, as the desktop does over files and the web over IndexedDB. */ +class MemoryBackend implements BotDeviceStorageBackend { + readonly indexes = new Map(); + readonly objects = new Map>(); + failIndexWrites = false; + + scopeId(identity: string): string { return createHash('sha256').update(identity).digest('hex'); } + randomId(): string { return randomUUID(); } + async initialize(): Promise {} + async scopes(): Promise { return [...new Set([...this.indexes.keys(), ...this.objects.keys()])]; } + async hasScope(scopeId: string): Promise { return this.indexes.has(scopeId) || this.objects.has(scopeId); } + async readIndex(scopeId: string): Promise { + if (this.indexes.has(scopeId)) return structuredClone(this.indexes.get(scopeId)); + this.objects.delete(scopeId); + return null; + } + async writeIndex(scopeId: string, index: BotDeviceStorageFile): Promise { + if (this.failIndexWrites) throw new Error('disk full'); + this.indexes.set(scopeId, structuredClone(index)); + } + async removeUnreferenced(scopeId: string, referenced: ReadonlySet): Promise { + for (const file of this.objects.get(scopeId)?.keys() ?? []) if (!referenced.has(file)) this.objects.get(scopeId)?.delete(file); + } + async writeObject(scopeId: string, file: string, bytes: Uint8Array): Promise { + const files = this.objects.get(scopeId) ?? new Map(); + files.set(file, bytes.slice()); + this.objects.set(scopeId, files); + } + async readObject(scopeId: string, file: string): Promise { + const bytes = this.objects.get(scopeId)?.get(file); + if (!bytes) throw new Error('missing'); + return bytes.slice(); + } + async removeObject(scopeId: string, file: string): Promise { this.objects.get(scopeId)?.delete(file); } + async removeScope(scopeId: string): Promise { + this.indexes.delete(scopeId); + this.objects.delete(scopeId); + } +} + +const scope: BotDeviceStorageScope = { + serverUrl: 'wss://monky.example/', serverId: 'server', serverName: 'Server', userId: 'user', botId: 'bot', botName: 'MonkyBot', +}; + +function fixture() { + const backend = new MemoryBackend(); + const changes: string[] = []; + const storage = new BotDeviceStorageEngine(backend, (scopeId) => changes.push(scopeId)); + return { backend, storage, changes, scopeId: storage.scopeId(scope) }; +} + +function reason(expected: string): (error: unknown) => boolean { + return (error) => error instanceof BotDeviceStorageError && error.reason === expected; +} + +test('scope ids follow server address, server, account and bot, never display names', () => { + const { storage, scopeId } = fixture(); + assert.match(scopeId, /^[a-f0-9]{64}$/); + assert.equal(storage.scopeId({ ...scope, botName: 'Renamed', serverName: 'Renamed' }), scopeId); + assert.equal(storage.scopeId({ ...scope, serverUrl: 'WSS://MONKY.example:443/' }), scopeId); + assert.equal(botDeviceStorageScopeIdentity(scope), JSON.stringify(['wss://monky.example', '/', 'server', 'user', 'bot'])); + for (const other of [{ userId: 'other' }, { serverId: 'other' }, { botId: 'other' }, { serverUrl: 'wss://other.example/' }]) { + assert.notEqual(storage.scopeId({ ...scope, ...other }), scopeId, JSON.stringify(other)); + } + assert.throws(() => storage.scopeId({ ...scope, serverUrl: 'not a url' }), reason('invalid')); +}); + +test('reading never stores anything; the decision starts as "ask" and is remembered', async () => { + const { backend, storage, changes, scopeId } = fixture(); + assert.equal(await storage.decision(scopeId), 'ask'); + assert.equal((await storage.get(scope, 'missing')).value, null); + assert.deepEqual(await storage.list(), []); + assert.equal(backend.indexes.size, 0); + await storage.setDecision(scope, 'never'); + assert.equal(await storage.decision(scopeId), 'never'); + assert.deepEqual(changes, [scopeId]); + assert.equal(await storage.decision('../escape'), 'never'); + const restarted = new BotDeviceStorageEngine(backend); + assert.equal(await restarted.decision(scopeId), 'never'); +}); + +test('values round trip; keys are validated and listed by prefix', async () => { + const { storage } = fixture(); + await storage.set(scope, 'nes/rom/slot-1', { kind: 'bytes', bytes: new Uint8Array([0, 255, 7]) }); + await storage.set(scope, 'nes/rom/empty', { kind: 'bytes', bytes: new Uint8Array() }); + await storage.set(scope, 'doom/save', { kind: 'text', text: 'olá' }); + assert.deepEqual((await storage.get(scope, 'nes/rom/slot-1')).value, { kind: 'bytes', bytes: new Uint8Array([0, 255, 7]) }); + assert.deepEqual((await storage.get(scope, 'nes/rom/empty')).value, { kind: 'bytes', bytes: new Uint8Array() }); + assert.deepEqual((await storage.get(scope, 'doom/save')).value, { kind: 'text', text: 'olá' }); + assert.deepEqual((await storage.keys(scope, 'nes/')).keys.map((entry) => entry.key), ['nes/rom/empty', 'nes/rom/slot-1']); + assert.equal((await storage.remove(scope, 'doom/save')).removed, true); + assert.equal((await storage.remove(scope, 'doom/save')).removed, false); + for (const key of ['../escape', 'a//b', '/abs', '']) { + await assert.rejects(storage.set(scope, key, { kind: 'text', text: 'x' }), reason('invalid'), key); + } + await assert.rejects(storage.set(scope, 'big', { kind: 'bytes', bytes: new Uint8Array(BOT_STORAGE_LIMITS.objectBytes + 1) }), reason('invalid')); +}); + +test('the quota refuses writes without evicting, and the storage shows as full until space is freed', async () => { + const { storage } = fixture(); + await storage.setQuota(scope, BOT_STORAGE_LIMITS.minQuotaBytes); + await storage.set(scope, 'a', { kind: 'bytes', bytes: new Uint8Array(600 * 1024) }); + await storage.set(scope, 'b', { kind: 'bytes', bytes: new Uint8Array(340 * 1024) }); + assert.equal((await storage.usage(scope)).level, 'near_full'); + await assert.rejects(storage.set(scope, 'c', { kind: 'bytes', bytes: new Uint8Array(100 * 1024) }), reason('quota')); + assert.equal((await storage.usage(scope)).level, 'full'); + assert.ok((await storage.get(scope, 'a')).value, 'Nothing is evicted to make room'); + await storage.set(scope, 'a', { kind: 'bytes', bytes: new Uint8Array(100 * 1024) }); + assert.equal((await storage.usage(scope)).level, 'ok'); + await assert.rejects(storage.setQuota(scope, BOT_STORAGE_LIMITS.minQuotaBytes - 1), reason('invalid')); +}); + +test('clearing keeps the preference and the quota; forgetting removes everything', async () => { + const { backend, storage, scopeId } = fixture(); + await storage.setQuota(scope, BOT_STORAGE_LIMITS.minQuotaBytes); + await storage.setDecision(scope, 'always'); + await storage.set(scope, 'kept', { kind: 'text', text: 'state' }); + await storage.clear(scopeId); + const cleared = await storage.entry(scope); + assert.deepEqual([cleared.decision, cleared.usage.usedBytes, cleared.usage.objectCount, cleared.usage.maxBytes], + ['always', 0, 0, BOT_STORAGE_LIMITS.minQuotaBytes]); + assert.equal(backend.objects.get(scopeId)?.size ?? 0, 0); + + await storage.set(scope, 'kept', { kind: 'text', text: 'state' }); + await storage.forget(scopeId); + assert.equal(await backend.hasScope(scopeId), false); + assert.equal(await storage.decision(scopeId), 'ask'); + assert.deepEqual(await storage.list(), []); + await assert.rejects(storage.forget('not-a-scope'), reason('invalid')); + await assert.rejects(storage.clear('not-a-scope'), reason('invalid')); +}); + +test('a failed index write keeps the previous state and leaves no orphan object', async () => { + const { backend, storage, scopeId } = fixture(); + await storage.set(scope, 'kept', { kind: 'text', text: 'old' }); + backend.failIndexWrites = true; + await assert.rejects(storage.set(scope, 'kept', { kind: 'text', text: 'new' }), reason('failed')); + backend.failIndexWrites = false; + assert.equal(backend.objects.get(scopeId)?.size, 1); + assert.deepEqual((await new BotDeviceStorageEngine(backend).get(scope, 'kept')).value, { kind: 'text', text: 'old' }); +}); + +test('unfinished first saves, orphans and corrupt indexes are handled when read', async () => { + const { backend, storage, scopeId } = fixture(); + await backend.writeObject(scopeId, `${randomUUID()}.bin`, new Uint8Array([1])); + assert.deepEqual(await storage.list(), []); + assert.equal(await backend.hasScope(scopeId), false, 'An unfinished scope is removed'); + + await storage.set(scope, 'kept', { kind: 'text', text: 'state' }); + await backend.writeObject(scopeId, `${randomUUID()}.bin`, new Uint8Array([2])); + const restarted = new BotDeviceStorageEngine(backend); + assert.deepEqual((await restarted.get(scope, 'kept')).value, { kind: 'text', text: 'state' }); + assert.equal(backend.objects.get(scopeId)?.size, 1, 'The orphan is removed'); + + backend.indexes.set(scopeId, { version: 1 }); + await assert.rejects(new BotDeviceStorageEngine(backend).get(scope, 'kept'), reason('failed')); +}); + +test('one device keeps a bounded number of bot scopes', async () => { + const { backend, storage } = fixture(); + for (let index = 0; index < BOT_STORAGE_LIMITS.deviceScopes; index++) { + backend.indexes.set(createHash('sha256').update(String(index)).digest('hex'), {}); + } + await assert.rejects(storage.setDecision(scope, 'always'), reason('quota')); +}); + +test('miniapp documents reach storage only while the person allows it', async () => { + const { storage, scopeId } = fixture(); + assert.deepEqual(await botScreenStorageState(storage, null), { status: 'unavailable', scopeId: null, usage: null }); + assert.deepEqual(await answerBotScreenStorageRequest(storage, null, { op: 'usage' }), { ok: false, reason: 'unavailable', usage: null }); + + const session = await bindBotScreenStorage(storage, { scope, grant: 'session' }); + assert.deepEqual([session.scopeId, session.session], [scopeId, true]); + assert.equal(await storage.decision(scopeId), 'ask', '"Only this session" is not remembered'); + assert.equal((await botScreenStorageState(storage, session)).status, 'granted'); + const saved = await answerBotScreenStorageRequest(storage, session, { op: 'set', key: 'slot', value: { kind: 'text', text: 'x' }, autosave: false }); + assert.equal(saved.ok, true); + assert.deepEqual(await answerBotScreenStorageRequest(storage, session, { op: 'get', key: '../x' }), { ok: false, reason: 'invalid', usage: null }); + + // A later "never" in the bot settings wins over the open document. + await storage.setDecision(scope, 'never'); + assert.equal((await botScreenStorageState(storage, session)).status, 'denied'); + assert.deepEqual(await answerBotScreenStorageRequest(storage, session, { op: 'get', key: 'slot' }), { ok: false, reason: 'denied', usage: null }); + const always = await bindBotScreenStorage(storage, { scope, grant: 'always' }); + assert.equal(await storage.decision(scopeId), 'never', '"Always" never overrides a "never"'); + assert.equal((await botScreenStorageState(storage, always)).status, 'denied'); + + await storage.setDecision(scope, 'ask'); + const remembered = await bindBotScreenStorage(storage, { scope, grant: 'always' }); + assert.equal(await storage.decision(scopeId), 'always'); + assert.equal(remembered.session, false); + const read = await answerBotScreenStorageRequest(storage, remembered, { op: 'get', key: 'slot' }); + assert.deepEqual(read.ok && read.op === 'get' ? read.value : null, { kind: 'text', text: 'x' }); + + await storage.setQuota(scope, BOT_STORAGE_LIMITS.minQuotaBytes); + const full = await answerBotScreenStorageRequest(storage, remembered, + { op: 'set', key: 'big', value: { kind: 'bytes', bytes: new Uint8Array(BOT_STORAGE_LIMITS.minQuotaBytes) }, autosave: false }); + assert.equal(full.ok, false); + assert.equal(!full.ok && full.reason, 'quota'); + assert.equal(full.usage?.level, 'full'); +}); + +test('the rate limit counts per document and second', () => { + let now = 0; + const rates = new BotScreenStorageRateLimit(() => now); + for (let index = 0; index < BOT_STORAGE_LIMITS.operationsPerSecond; index++) assert.equal(rates.allow('a'), true); + assert.equal(rates.allow('a'), false); + assert.equal(rates.allow('b'), true, 'Another document has its own budget'); + now = 1000; + assert.equal(rates.allow('a'), true); +}); + +test('a request over the rate limit fails before reaching the storage', async () => { + const { storage } = fixture(); + const binding = await bindBotScreenStorage(storage, { scope, grant: 'session' }); + assert.deepEqual(await answerBotScreenStorageRequest(storage, binding, { op: 'usage' }, () => false), { ok: false, reason: 'failed', usage: null }); + assert.deepEqual(await answerBotScreenStorageRequest(storage, binding, { op: 'nope' }, () => false), { ok: false, reason: 'invalid', usage: null }); +}); + +test('a disposed engine waits for what runs and refuses what comes after', async () => { + const { storage } = fixture(); + const pending = storage.set(scope, 'kept', { kind: 'text', text: 'state' }); + await storage.dispose(); + await pending; + await assert.rejects(storage.get(scope, 'kept'), reason('unavailable')); +}); diff --git a/release-notes/camera-blur-first-preview.json b/release-notes/camera-blur-first-preview.json new file mode 100644 index 00000000..629397ba --- /dev/null +++ b/release-notes/camera-blur-first-preview.json @@ -0,0 +1,5 @@ +{ + "group": "correcoes", + "pt-BR": "Ligar o desfoque da câmera pela primeira vez ficou bem mais rápido: a prévia aparece sem aquela longa espera.", + "en": "Turning on camera blur for the first time is much faster: the preview shows up without that long wait." +} diff --git a/release-notes/friendly-microphone-error.json b/release-notes/friendly-microphone-error.json new file mode 100644 index 00000000..5c454c61 --- /dev/null +++ b/release-notes/friendly-microphone-error.json @@ -0,0 +1,5 @@ +{ + "group": "correcoes", + "pt-BR": "Quando o microfone não inicia ao entrar numa chamada, o aviso agora explica o motivo no idioma do app (nenhum microfone encontrado, microfone em uso ou permissão negada), sem a mensagem técnica do navegador.", + "en": "When the microphone fails to start as you join a call, the notice now explains why in the app language (no microphone found, microphone in use or permission denied) instead of the browser's technical message." +} diff --git a/release-notes/hosted-server-reach-warning.json b/release-notes/hosted-server-reach-warning.json new file mode 100644 index 00000000..f49173fe --- /dev/null +++ b/release-notes/hosted-server-reach-warning.json @@ -0,0 +1,5 @@ +{ + "group": "novidades", + "pt-BR": "Antes de criar um servidor no seu computador, o Monky avisa que os amigos só entram depois de liberar o acesso pela internet, com link para o passo a passo. Já sabe? Marque \"Não mostrar novamente\".", + "en": "Before you create a server on your computer, Monky explains that friends can only join once internet access is open, with a link to the guide. Already know it? Turn on \"Don't show again\"." +} diff --git a/release-notes/native-screen-amf-compatible.json b/release-notes/native-screen-amf-compatible.json new file mode 100644 index 00000000..9f382e82 --- /dev/null +++ b/release-notes/native-screen-amf-compatible.json @@ -0,0 +1,5 @@ +{ + "group": "correcoes", + "pt-BR": "Quem transmite a tela com placa AMD no formato Compatível voltou a ser assistido: antes, quem tentava assistir pelo app desktop ficava carregando para sempre.", + "en": "Screen shares from AMD GPUs in the Compatible format can be watched again: viewers on the desktop app used to be stuck loading forever." +} diff --git a/release-notes/native-screen-av1.json b/release-notes/native-screen-av1.json new file mode 100644 index 00000000..93573028 --- /dev/null +++ b/release-notes/native-screen-av1.json @@ -0,0 +1,5 @@ +{ + "group": "correcoes", + "pt-BR": "Transmitir tela com AV1 voltou a funcionar, e o automático volta a escolher AV1 quando a placa permite. O Formato do vídeo agora só aparece com H.264, abaixo de Codificação de tela, e o padrão é a melhor qualidade.", + "en": "Screen sharing with AV1 works again, and automatic mode picks AV1 again when your GPU supports it. Video format now only shows with H.264, below Screen encoding, and defaults to the best quality." +} diff --git a/release-notes/native-screen-no-gpu-message.json b/release-notes/native-screen-no-gpu-message.json new file mode 100644 index 00000000..0a69bddd --- /dev/null +++ b/release-notes/native-screen-no-gpu-message.json @@ -0,0 +1,5 @@ +{ + "group": "correcoes", + "pt-BR": "Em computadores sem placa de vídeo compatível, como máquinas virtuais, o app agora explica por que não dá para transmitir a tela, em vez de pedir para tentar de novo cada formato.", + "en": "On computers without a compatible graphics card, such as virtual machines, the app now explains why screen sharing is unavailable instead of asking you to retry every format." +} diff --git a/release-notes/server-password-card.json b/release-notes/server-password-card.json new file mode 100644 index 00000000..eb93556b --- /dev/null +++ b/release-notes/server-password-card.json @@ -0,0 +1,5 @@ +{ + "group": "novidades", + "pt-BR": "A senha do servidor agora aparece num cartão na aba Segurança: veja se ela está ativa, mostre, copie, troque (com confirmação) ou remova com um clique. O app lembra a senha nova, então reconectar continua funcionando.", + "en": "The server password now lives in a card on the Security tab: see whether it is on, show it, copy it, change it (with confirmation) or remove it in one click. The app remembers the new one, so reconnecting keeps working." +} diff --git a/release-notes/web-miniapp-storage.json b/release-notes/web-miniapp-storage.json new file mode 100644 index 00000000..c8bc8492 --- /dev/null +++ b/release-notes/web-miniapp-storage.json @@ -0,0 +1,5 @@ +{ + "group": "novidades", + "pt-BR": "Na versão web, os miniapps dos bots agora guardam dados no navegador, com o mesmo consentimento e limite do desktop. Sair da conta apaga tudo o que eles guardaram.", + "en": "In the web version, bot miniapps can now keep data in the browser, with the same consent and limits as the desktop. Logging out deletes everything they kept." +} diff --git a/release-notes/web-secure-server-help.json b/release-notes/web-secure-server-help.json new file mode 100644 index 00000000..aecf6d3d --- /dev/null +++ b/release-notes/web-secure-server-help.json @@ -0,0 +1,5 @@ +{ + "group": "correcoes", + "pt-BR": "Na versão web, entrar num servidor sem conexão segura agora explica o motivo, em vez de dizer que ele está offline, e mostra o link de como o dono pode liberar o acesso.", + "en": "In the web version, joining a server without a secure connection now explains why, instead of calling it offline, and links to how its owner can open access." +} From 159c019493c1633cfed3b9c3982bff375fbd6963 Mon Sep 17 00:00:00 2001 From: Lucas Borges <31556507+lucastaonline@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:07:58 -0300 Subject: [PATCH 2/4] fix: corrige as falhas do CI no build do Windows e nos smokes - contractTest.cpp: evita conversao estreita que o MSVC trata como erro (C4244). - secureServerHelp volta a ser sincrono, para o erro aparecer na hora em que o envio falha. - qualitySettingsSmoke espera o painel Personalizado terminar de se revelar antes de focar a ajuda. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../client/native/screen-share/src/capture/contractTest.cpp | 3 ++- apps/client/src/renderer/utils/secureServerHelp.ts | 6 +++--- apps/client/src/renderer/views/JoinInviteModal.ts | 3 +-- apps/client/src/renderer/views/ServerRailView.ts | 4 ++-- apps/client/src/renderer/views/addServer/AddServerModal.ts | 2 +- apps/client/test/qualitySettingsSmoke.cjs | 2 ++ 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/client/native/screen-share/src/capture/contractTest.cpp b/apps/client/native/screen-share/src/capture/contractTest.cpp index 2aff1e15..b68232d8 100644 --- a/apps/client/native/screen-share/src/capture/contractTest.cpp +++ b/apps/client/native/screen-share/src/capture/contractTest.cpp @@ -524,7 +524,8 @@ int main(int argc, char** argv) { flagged.constraints = 0xc0; check(NormalizeConstrainedBaseline(ParameterSets(baseline, flagged), baseline).empty()); // AMF baseline sets constraint_set5, which WebRTC receivers refuse as Constrained Baseline. - constexpr std::pair amfFlags[]{{0x04, 0x40}, {0x44, 0x40}, {0x48, 0x40}, {0xcc, 0xc0}}; + struct ConstraintFlags { std::uint8_t emitted; std::uint8_t declared; }; + constexpr ConstraintFlags amfFlags[]{{0x04, 0x40}, {0x44, 0x40}, {0x48, 0x40}, {0xcc, 0xc0}}; for (const auto& [emitted, declared] : amfFlags) { TestSpsOptions amf = plain; amf.constraints = emitted; diff --git a/apps/client/src/renderer/utils/secureServerHelp.ts b/apps/client/src/renderer/utils/secureServerHelp.ts index daa3f2c3..0fca28b6 100644 --- a/apps/client/src/renderer/utils/secureServerHelp.ts +++ b/apps/client/src/renderer/utils/secureServerHelp.ts @@ -6,11 +6,11 @@ import { SecureServerRequiredError } from './secureServer'; /** * Shows the friendly explanation, with the docs link, when the failure was a * missing secure connection (#874). Returns false for any other error, which - * the caller reports as before. + * the caller reports as before, in the same tick. */ -export async function showSecureServerHelp(error: unknown): Promise { +export function showSecureServerHelp(error: unknown): boolean { if (!(error instanceof SecureServerRequiredError)) return false; - await showAlert({ + void showAlert({ title: t('network.secureRequiredTitle'), message: error.message, variant: 'warning', diff --git a/apps/client/src/renderer/views/JoinInviteModal.ts b/apps/client/src/renderer/views/JoinInviteModal.ts index f9111afc..b31b3c41 100644 --- a/apps/client/src/renderer/views/JoinInviteModal.ts +++ b/apps/client/src/renderer/views/JoinInviteModal.ts @@ -153,8 +153,7 @@ export class JoinInviteModal { await window.api.maximize?.().catch(() => clientLog.warn('APP', 'Could not maximize after joining the invited server')); } catch (error: unknown) { clientLog.warn('CONNECTION', 'Could not join the invited server'); - if (shell.closed) return; - if (!(await showSecureServerHelp(error))) reportError(errorMessage(error, t('connection.connectError'))); + if (!shell.closed && !showSecureServerHelp(error)) reportError(errorMessage(error, t('connection.connectError'))); } finally { connecting = false; if (!shell.closed) { diff --git a/apps/client/src/renderer/views/ServerRailView.ts b/apps/client/src/renderer/views/ServerRailView.ts index e7876c90..7ec30739 100644 --- a/apps/client/src/renderer/views/ServerRailView.ts +++ b/apps/client/src/renderer/views/ServerRailView.ts @@ -1190,7 +1190,7 @@ export class ServerRailView { try { await this.runConnectToSavedServer(server, isCurrent); } catch (error: unknown) { - if (await showSecureServerHelp(error)) return; + if (showSecureServerHelp(error)) return; await showAlert({ title: t('main.serverOfflineTitle'), message: error instanceof Error && error.message ? error.message : t('connection.connectError'), @@ -1259,7 +1259,7 @@ export class ServerRailView { lastConnected: Date.now(), }); } catch (err: unknown) { - if (await showSecureServerHelp(err)) return; + if (showSecureServerHelp(err)) return; const message = err instanceof Error && err.message ? err.message : t('main.serverOfflineMessage', { name: server.name || server.host }); diff --git a/apps/client/src/renderer/views/addServer/AddServerModal.ts b/apps/client/src/renderer/views/addServer/AddServerModal.ts index e51ec437..8b12f3dc 100644 --- a/apps/client/src/renderer/views/addServer/AddServerModal.ts +++ b/apps/client/src/renderer/views/addServer/AddServerModal.ts @@ -552,7 +552,7 @@ class AddServerModal { clientLog.warn('CONNECTION', 'Could not join the server from the add-server modal', { error: error instanceof Error ? error.message : String(error), }); - if (!this.modalEl || await showSecureServerHelp(error)) return; + if (!this.modalEl || showSecureServerHelp(error)) return; this.showError(errorMessage(error, t('connection.connectError'))); } finally { this.setConnectionPending(false); diff --git a/apps/client/test/qualitySettingsSmoke.cjs b/apps/client/test/qualitySettingsSmoke.cjs index d2c36a16..5d0216b2 100644 --- a/apps/client/test/qualitySettingsSmoke.cjs +++ b/apps/client/test/qualitySettingsSmoke.cjs @@ -459,6 +459,8 @@ async function runQualitySettingsSmoke() { preset.value = 'CUSTOM'; preset.dispatchEvent(new Event('change', { bubbles: true })); await settled(() => quality.querySelectorAll('[data-bitrate-help]').length === 3, `${locale}/custom preset`); + // Opening Custom unfolds its panel and then reveals it; scrolling to a help button before that ends is undone. + await settleMotion(root.querySelector('.settings-content-body'), `${locale}/custom preset reveal`); check(quality.querySelector('#checkbox-screen-telemetry') === toggle, 'Changing quality presets must not recreate or detach telemetry controls.'); check(quality.querySelector('#checkbox-screen-preview-focus') === preview, From 713633b6ad0a2688324696aca05d94e342c4743b Mon Sep 17 00:00:00 2001 From: Lucas Borges <31556507+lucastaonline@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:53:16 -0300 Subject: [PATCH 3/4] =?UTF-8?q?feat(screen):=20trocar=20codec=20durante=20?= =?UTF-8?q?a=20transmiss=C3=A3o=20e=20mostrar=20"Carregando"=20no=20select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mudar codec, modo, estratégia ou formato com a tela ao vivo substitui a fonte nativa com o novo encoder (antes só perfil/bitrate disparavam a troca). - Toast confirma quando a nova codificação foi aplicada às transmissões ativas. - O select de codec mostra "Carregando"/"Indisponível" em vez de ficar em branco. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../webrtc/nativeScreen/LocalScreenSources.ts | 28 +++++++++++++------ apps/client/src/renderer/i18n/locales/en.ts | 3 +- .../client/src/renderer/i18n/locales/pt-BR.ts | 3 +- .../renderer/views/ScreenEncodingControls.ts | 5 ++++ .../views/settings/tabs/QualityTab.ts | 9 ++++-- .../test/nativeScreenController.test.cjs | 21 ++++++++++++++ apps/client/test/qualitySettingsSmoke.cjs | 2 +- apps/client/test/screenSharingUi.test.cjs | 23 +++++++++++++-- release-notes/screen-codec-live-switch.json | 5 ++++ 9 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 release-notes/screen-codec-live-switch.json diff --git a/apps/client/src/renderer/core/webrtc/nativeScreen/LocalScreenSources.ts b/apps/client/src/renderer/core/webrtc/nativeScreen/LocalScreenSources.ts index 7e739d72..0b3a7a89 100644 --- a/apps/client/src/renderer/core/webrtc/nativeScreen/LocalScreenSources.ts +++ b/apps/client/src/renderer/core/webrtc/nativeScreen/LocalScreenSources.ts @@ -28,6 +28,8 @@ export type SourceInput = Omit & { export interface LocalSource { readonly ready: Promise; descriptor: NativeScreenSource | null; + /** The encoding settings Main prepared this source with; changing them replaces the source on air. */ + encoding: string; removing: boolean; reconfiguration?: { retired: boolean }; retirement?: Promise; @@ -44,6 +46,13 @@ export interface LocalSource { }; } +function encodingSettings() { + return { + encodingMode: settingsStore.screenEncodingMode, codec: settingsStore.preferredScreenCodec, + encodingStrategy: settingsStore.screenEncodingStrategy, screenFormat: settingsStore.screenFormat, + }; +} + export function nativeScreenProfile(profile: QualityProfile): NativeScreenVideoProfile | null { if (profile.screenFps > customVideoFpsLimit(profile.screenWidth, profile.screenHeight)) return null; const parsed = nativeScreenVideoProfileSchema.safeParse({ @@ -145,14 +154,13 @@ export class LocalScreenSources { this.calls.current(call); const existing = call.sources.get(input.shareId); if (existing) return existing.removing ? Promise.reject(new Error('The native source is still retiring.')) : existing.ready; + const encoding = encodingSettings(); const entry: LocalSource = { - descriptor: null, removing: false, + descriptor: null, removing: false, encoding: JSON.stringify(encoding), ready: call.api.nativeScreenCommand({ action: 'source-add', callId: call.config.callId, shareId: input.shareId, desktopSourceId: input.desktopSourceId, video: input.video, audio: input.audio, audioBitrateKbps: input.audioBitrateKbps, - preserveAspectRatio: input.preserveAspectRatio ?? true, - encodingMode: settingsStore.screenEncodingMode, codec: settingsStore.preferredScreenCodec, - encodingStrategy: settingsStore.screenEncodingStrategy, screenFormat: settingsStore.screenFormat, + preserveAspectRatio: input.preserveAspectRatio ?? true, ...encoding, ...(input.replacesAudioShareIds?.length ? { replacesAudioShareIds: [...input.replacesAudioShareIds] } : {}), ...(input.captureKind ? { captureKind: input.captureKind } : {}), }).then(result => { @@ -207,8 +215,11 @@ export class LocalScreenSources { // A source may give up its audio in place (#253), never acquire it. const audio = capture.source.audio && selected?.audio !== false; const previous = call.sources.get(shareId); + const encoding = encodingSettings(), encodingKey = JSON.stringify(encoding); + // A codec, encoder or format change also replaces the source on air: Main prepares a new encoder for it. if (previous && !previous.removing && screenShareProfileKey(capture.source.video) === screenShareProfileKey(video) - && capture.audioBitrateKbps === audioBitrateKbps && capture.source.audio === audio) return; + && capture.audioBitrateKbps === audioBitrateKbps && capture.source.audio === audio + && previous.encoding === encodingKey) return; const isCurrentCapture = () => this.calls.active === call && !call.stopping && call.context.isCurrent() && videoService.getNativeScreenCapture(shareId) === capture; if (selected && previous?.descriptor && !previous.removing) { @@ -220,9 +231,7 @@ export class LocalScreenSources { replacesSourceInstanceId: previous.descriptor.instanceId, desktopSourceId: capture.desktopSourceId, captureKind: capture.captureKind, preserveAspectRatio: capture.preserveAspectRatio ?? true, - video, audio, audioBitrateKbps, - encodingMode: settingsStore.screenEncodingMode, codec: settingsStore.preferredScreenCodec, - encodingStrategy: settingsStore.screenEncodingStrategy, screenFormat: settingsStore.screenFormat, + video, audio, audioBitrateKbps, ...encoding, }); this.calls.current(call); if (!isCurrentCapture() || previous.removing || call.sources.get(shareId) !== previous) { @@ -239,7 +248,8 @@ export class LocalScreenSources { return; } const source = { ...result.source, ...(capture.source.audience ? { audience: capture.source.audience } : {}) }; - call.sources.set(shareId, { descriptor: source, ready: Promise.resolve(source), removing: false }); + call.sources.set(shareId, { descriptor: source, ready: Promise.resolve(source), removing: false, + encoding: encodingKey }); videoService.updateNativeScreenCapture({ ...capture, source, audioBitrateKbps }); try { await this.attachPreview(shareId); } catch (error) { reportScreenError(error); } diff --git a/apps/client/src/renderer/i18n/locales/en.ts b/apps/client/src/renderer/i18n/locales/en.ts index af1da6f3..c6b52584 100644 --- a/apps/client/src/renderer/i18n/locales/en.ts +++ b/apps/client/src/renderer/i18n/locales/en.ts @@ -960,7 +960,8 @@ export const en: TranslationMap = { 'settings.screenEncodingHardwareDesc': 'Uses the device’s hardware video encoder, when compatible, to reduce CPU usage.', 'settings.screenEncodingSoftware': 'Software', 'settings.screenEncodingSoftwareDesc': 'Uses the processor to encode the available codecs. May increase CPU usage.', - 'settings.screenEncodingApply': 'Applies to the next share or when you change quality. Does not affect the camera.', + 'settings.screenEncodingApply': 'Also applies to the screen you are already sharing, which restarts with the new encoding. Does not affect the camera.', + 'settings.screenEncodingAppliedLive': 'Encoding applied: your screen share restarted with the new choice.', 'settings.screenEncodingReady': '{codec} available at this quality.', 'screenShare.browserBackend': 'Alternative Chromium capture is disabled. The capture method and encoder must be supported by this device’s native backend.', 'screenShare.comingSoon': 'Coming soon', diff --git a/apps/client/src/renderer/i18n/locales/pt-BR.ts b/apps/client/src/renderer/i18n/locales/pt-BR.ts index d2b61237..73f28a95 100644 --- a/apps/client/src/renderer/i18n/locales/pt-BR.ts +++ b/apps/client/src/renderer/i18n/locales/pt-BR.ts @@ -922,7 +922,8 @@ export const ptBR = { 'settings.screenEncodingHardwareDesc': 'Usa o codificador de vídeo por hardware do dispositivo, quando compatível, para reduzir o uso de CPU.', 'settings.screenEncodingSoftware': 'Software', 'settings.screenEncodingSoftwareDesc': 'Usa o processador para codificar nos codecs disponíveis. Pode aumentar o uso de CPU.', - 'settings.screenEncodingApply': 'Aplica ao próximo compartilhamento ou ao mudar a qualidade. Não altera a câmera.', + 'settings.screenEncodingApply': 'Vale também para a tela que você já está transmitindo, que reinicia com a nova codificação. Não altera a câmera.', + 'settings.screenEncodingAppliedLive': 'Codificação aplicada: sua transmissão de tela reiniciou com a nova escolha.', 'settings.screenEncodingReady': '{codec} disponível para esta qualidade.', 'screenShare.browserBackend': 'A captura alternativa do Chromium está desativada. O método de captura e o codificador precisam ser compatíveis com o backend nativo deste dispositivo.', 'screenShare.comingSoon': 'Em breve', diff --git a/apps/client/src/renderer/views/ScreenEncodingControls.ts b/apps/client/src/renderer/views/ScreenEncodingControls.ts index 0c774bea..63cf2557 100644 --- a/apps/client/src/renderer/views/ScreenEncodingControls.ts +++ b/apps/client/src/renderer/views/ScreenEncodingControls.ts @@ -92,6 +92,7 @@ export class ScreenEncodingControls { @@ -539,6 +540,10 @@ export class ScreenEncodingControls { } codec.value = automatic ? selection?.codec ?? '' : preferences.codec; if (!automatic && codec.querySelector(`option[value="${preferences.codec}"]`)?.disabled) codec.value = ''; + // Without a codec to show, the field says why instead of staying blank. + const placeholder = codec.querySelector('#screen-codec-placeholder'); + const placeholderText = t(this.pending ? 'common.loading' : 'screenShare.unavailable'); + if (placeholder && placeholder.textContent !== placeholderText) placeholder.textContent = placeholderText; } for (const mode of ['hardware', 'software'] as const) { const label = this.container?.querySelector(`#screen-encoding-${mode}-choice`); diff --git a/apps/client/src/renderer/views/settings/tabs/QualityTab.ts b/apps/client/src/renderer/views/settings/tabs/QualityTab.ts index 67e76127..67c1d674 100644 --- a/apps/client/src/renderer/views/settings/tabs/QualityTab.ts +++ b/apps/client/src/renderer/views/settings/tabs/QualityTab.ts @@ -108,7 +108,7 @@ export class QualityTab { } private commitQualityChanges(preset: QualityPresetType, customProfile?: QualityProfile, persist = true, - confirm = false): void { + confirm = false): Promise { const previousPreset = settingsStore.qualityPreset, previousCustom = settingsStore.customProfile; const profile = preset === 'CUSTOM' ? customProfile ?? previousCustom : QUALITY_PRESETS[preset]; webRtcManager.assertScreenSharingSettings(profile); @@ -124,6 +124,7 @@ export class QualityTab { if (persist) settingsStore.save(); void this.encoding.refresh(); if (confirm) this.confirmQualityChange(profile, applied); + return applied; } // Active shares confirm only after their replacement source is live; failures are reported by the share. @@ -146,11 +147,15 @@ export class QualityTab { // A clamp notice or FPS adjustment already explains the applied value; never replace it. const confirm = !!this.requestedQuality && !this.requestedQuality.noticeShown && !adjusted; const encoding = this.encodingKey(); + const encodingChanged = this.appliedEncoding !== encoding; this.requestedQuality = null; try { - this.commitQualityChanges(preset, preset === 'CUSTOM' ? { ...previous, screenFps: profile.fps } : undefined, + const applied = this.commitQualityChanges(preset, preset === 'CUSTOM' ? { ...previous, screenFps: profile.fps } : undefined, persist, confirm); this.appliedEncoding = encoding; + if (encodingChanged && !confirm) void applied.then(shares => { + if (shares) showSuccessToast(t('settings.screenEncodingAppliedLive'), 5000); + }); } catch (error) { this.renderQualityDetails(); throw error; diff --git a/apps/client/test/nativeScreenController.test.cjs b/apps/client/test/nativeScreenController.test.cjs index e3d1e3c3..50666607 100644 --- a/apps/client/test/nativeScreenController.test.cjs +++ b/apps/client/test/nativeScreenController.test.cjs @@ -535,6 +535,27 @@ for (const screenFormat of ['compatible', 'main']) { }); } +for (const [setting, field, value] of [['preferredScreenCodec', 'codec', 'av1'], ['screenEncodingMode', 'encodingMode', 'software'], + ['screenEncodingStrategy', 'encodingStrategy', 'manual'], ['screenFormat', 'screenFormat', 'main']]) { + test(`changing only ${setting} replaces the share on air with the new encoding; an unchanged one stays`, async t => { + const f = fixture(t), old = await f.local(); + await f.controller.applyQuality(profile()); + const additions = () => f.commands.filter(command => command.action === 'source-add'); + const before = f.captures.get(old.shareId).source.instanceId; + await f.controller.applyQuality(profile()); + assert.equal(additions().length, 2, 'Same quality and encoding keep the source as is'); + f.settings[setting] = value; + await f.controller.applyQuality(profile()); + assert.equal(additions().length, 3); + const replacement = additions().at(-1); + assert.equal(replacement[field], value); + assert.equal(replacement.replacesSourceInstanceId, before); + assert.equal(f.commands.some(command => command.action === 'source-remove'), false, 'The share never leaves the call'); + await f.controller.applyQuality(profile()); + assert.equal(additions().length, 3, 'The new encoding is remembered by the replacement'); + }); +} + for (const failure of [null, 'admission', 'retirement', 'cancelled']) { test(`source-start audio handoff gates preview on exact old retirement (${failure ?? 'success'})`, async t => { const f = fixture(t, { capabilities: { diff --git a/apps/client/test/qualitySettingsSmoke.cjs b/apps/client/test/qualitySettingsSmoke.cjs index 5d0216b2..595ad0cc 100644 --- a/apps/client/test/qualitySettingsSmoke.cjs +++ b/apps/client/test/qualitySettingsSmoke.cjs @@ -196,7 +196,7 @@ async function runQualitySettingsSmoke() { check(!av1.disabled && av1.textContent === language.t('settings.codecAv1') && !av1.textContent.includes(language.t('screenShare.comingSoon')), 'AV1 must be enabled and localized as an implemented screen codec.'); - check([...quality.querySelectorAll('#select-video-codec option')].map(option => option.value).join(',') === 'h264,av1', + check([...quality.querySelectorAll('#select-video-codec option')].filter(option => !option.hidden).map(option => option.value).join(',') === 'h264,av1', 'Manual codec selection must offer exact H.264 and AV1 only.'); for (const codec of ['h264', 'av1']) check(!quality.querySelector(`#select-video-codec option[value="${codec}"]`).disabled, diff --git a/apps/client/test/screenSharingUi.test.cjs b/apps/client/test/screenSharingUi.test.cjs index f28be9ff..845c27e1 100644 --- a/apps/client/test/screenSharingUi.test.cjs +++ b/apps/client/test/screenSharingUi.test.cjs @@ -392,7 +392,7 @@ for (const language of ['pt-BR', 'en']) { t.after(() => f.close()); const root = f.mountQuality(); const codec = control(f, 'select-video-codec'); - assert.deepEqual(codec.querySelectorAll('option').map(option => option.value), ['h264', 'av1']); + assert.deepEqual(codec.querySelectorAll('option').filter(option => !option.hidden).map(option => option.value), ['h264', 'av1']); const av1 = codec.querySelector('option[value="av1"]'); assert.equal(av1.disabled, false); assert.equal(av1.textContent.includes(f.i18n.t('screenShare.comingSoon')), false); @@ -1913,7 +1913,8 @@ test('screen codec selection saves only verified choices and offers no camera-on assert.equal(f.settingsStore.preferredScreenCodec, 'h264'); assert.equal(f.saves, 2); assert.equal(codec.querySelector('option[value="auto"]'), null); - assert.equal(codec.querySelector('option[value=""]'), null); + const placeholder = codec.querySelector('option[value=""]'); + assert.ok(placeholder === null || (placeholder.disabled && placeholder.hidden), 'An empty codec value must never be selectable.'); }); test('a late codec error cannot open a dialog after quality settings cleanup', async t => { @@ -2320,6 +2321,24 @@ for (const codec of ['h264', 'av1']) { }); } +test('the codec field says Loading while Automatic resolves and Unavailable without a codec, never blank', async t => { + const f = fixture(); + t.after(() => f.close()); + const check = deferred(); + f.controls.encoding = () => check.promise; + f.mountQuality(); + await flush(); + const field = control(f, 'select-video-codec'); + assert.equal(field.value, ''); + assert.equal(field.querySelector('#screen-codec-placeholder').textContent, f.i18n.t('common.loading')); + check.resolve({ selection: null, hardware: { available: false, reason: 'No encoder' }, fallback: false, reason: 'No encoder' }); + await flush(); + assert.equal(field.value, ''); + assert.equal(field.querySelector('#screen-codec-placeholder').textContent, f.i18n.t('screenShare.unavailable')); + assert.deepEqual([...field.querySelectorAll('option')].filter(option => !option.hidden).map(option => option.value), ['h264', 'av1'], + 'The placeholder is never offered as a choice.'); +}); + test('An old unsupported Manual combination is disabled without saving it again; Automatic rediscovers hardware', async t => { const f = fixture(); t.after(() => f.close()); diff --git a/release-notes/screen-codec-live-switch.json b/release-notes/screen-codec-live-switch.json new file mode 100644 index 00000000..92607ca3 --- /dev/null +++ b/release-notes/screen-codec-live-switch.json @@ -0,0 +1,5 @@ +{ + "group": "melhorias", + "pt-BR": "Agora dá para trocar o codec e o formato da transmissão de tela enquanto você está transmitindo: o Monky reinicia a transmissão com a nova configuração. Enquanto os codecs carregam, o campo mostra \"Carregando\" em vez de ficar vazio.", + "en": "You can now change the screen sharing codec and format while you are sharing: Monky restarts the stream with the new settings. While codecs load, the field shows \"Loading\" instead of staying blank." +} \ No newline at end of file From 65202ef319d8705ea0653138fd04fae42e7ecbf4 Mon Sep 17 00:00:00 2001 From: Lucas Borges <31556507+lucastaonline@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:24:01 -0300 Subject: [PATCH 4/4] =?UTF-8?q?test(screen):=20ignorar=20placeholder=20do?= =?UTF-8?q?=20codec=20no=20dropdown=20smoke=20e=20usar=20grupo=20v=C3=A1li?= =?UTF-8?q?do=20na=20nota?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/client/test/dropdownSmoke.cjs | 2 +- release-notes/screen-codec-live-switch.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/test/dropdownSmoke.cjs b/apps/client/test/dropdownSmoke.cjs index 7a508d8e..e8a46741 100644 --- a/apps/client/test/dropdownSmoke.cjs +++ b/apps/client/test/dropdownSmoke.cjs @@ -433,7 +433,7 @@ async function runDropdownSmoke() { fixture.innerHTML = quality.renderHtml(); } finally { settings.screenEncodingStrategy = strategy; } const codec = fixture.querySelector('#select-video-codec'); - check(!codec.disabled && [...codec.options].map(option => option.value).join(',') === 'h264,av1', + check(!codec.disabled && [...codec.options].filter(option => !option.hidden).map(option => option.value).join(',') === 'h264,av1', 'Trusted codec input uses the editable Manual H.264/AV1 control, not read-only Automatic mode'); codec.value = 'h264'; fixture.insertAdjacentHTML('beforeend', ''); diff --git a/release-notes/screen-codec-live-switch.json b/release-notes/screen-codec-live-switch.json index 92607ca3..845c719e 100644 --- a/release-notes/screen-codec-live-switch.json +++ b/release-notes/screen-codec-live-switch.json @@ -1,5 +1,5 @@ { - "group": "melhorias", + "group": "novidades", "pt-BR": "Agora dá para trocar o codec e o formato da transmissão de tela enquanto você está transmitindo: o Monky reinicia a transmissão com a nova configuração. Enquanto os codecs carregam, o campo mostra \"Carregando\" em vez de ficar vazio.", "en": "You can now change the screen sharing codec and format while you are sharing: Monky restarts the stream with the new settings. While codecs load, the field shows \"Loading\" instead of staying blank." } \ No newline at end of file