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 8abb2f84..38a06003 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,17 @@ 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"); + // 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); + Check(browser_plan && browser_plan->profileLevelId == "42c033", + "A Main receiver must admit the Main-compatible Constrained Baseline of browser software encoders"); + auto plain_baseline = small_sps; + plain_baseline[2] = 0x80; + const auto plain_packet = ParserPacket(plain_baseline); + Reject([&] { policy::PlanDecoderSession(std::nullopt, policy::InspectAccessUnit(plain_packet), plain_packet, + {screen_video::H264Profile::Main, 51}, 1920, 1080, 8, options); }, + "ERR_RTC_NEGOTIATED_SPS"); for (const auto flags : {0x08, 0x0c, 0x20, 0x80}) { auto unsupported = *amf_info.sps; unsupported.compatibility = static_cast(flags); diff --git a/apps/client/native/screen-share/src/rtc/inputs/adapters/mac_rtc_adapters.mm b/apps/client/native/screen-share/src/rtc/inputs/adapters/mac_rtc_adapters.mm index ecfd8217..9d13c8ca 100644 --- a/apps/client/native/screen-share/src/rtc/inputs/adapters/mac_rtc_adapters.mm +++ b/apps/client/native/screen-share/src/rtc/inputs/adapters/mac_rtc_adapters.mm @@ -279,7 +279,7 @@ int32_t DecodeLocked(const webrtc::EncodedImage& image, int64_t render_time_ms) if (sps) { Require(sps->width >= 4 && sps->width <= 3840 && sps->height >= 2 && sps->height <= 2160 && !(sps->width & 1) && !(sps->height & 1) && sps->levelIdc <= mode_.level && - sv::MatchesH264Profile(*sps, mode_.profile) && sv::IsBt709LimitedCompatible(*sps), + sv::DecodesAsH264Profile(*sps, mode_.profile) && sv::IsBt709LimitedCompatible(*sps), "ERR_RTC_MAC_DECODER_SPS"); if (width_ != sps->width || height_ != sps->height) { Require(keyframe, "ERR_RTC_MAC_DECODER_RESIZE"); 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 2521a566..a0452595 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 @@ -130,19 +130,22 @@ AccessUnitInfo InspectAccessUnit(std::span bytes) { void ValidateSps(const sv::H264Sps& sps, NegotiatedH264 negotiated, std::uint32_t maximum_width, std::uint32_t maximum_height) { + // Under Main SDP, browsers' software encoder (OpenH264) sends Main-compatible Constrained Baseline. + const bool main_compatible_baseline = negotiated.profile == sv::H264Profile::Main && + sps.profileIdc == 66 && sv::DecodesAsH264Profile(sps, negotiated.profile); if (!sps.progressive || sps.width < 16 || sps.height < 16 || sps.width > 3840 || sps.height > 2160 || sps.width > maximum_width || sps.height > maximum_height || ((sps.width | sps.height | sps.cropLeft | sps.cropTop) & 1) || !IsSupportedLevel(sps.levelIdc) || sps.levelIdc > negotiated.level || - !sv::MatchesH264Profile(sps, negotiated.profile) || + !sv::DecodesAsH264Profile(sps, negotiated.profile) || (sps.profileIdc == 66 && !(sps.compatibility & 0x40)) || !sv::IsBt709LimitedCompatible(sps)) { 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()); - const auto expected = negotiated.profile == sv::H264Profile::Main + 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. // Retain the original SPS; validate geometry and I/P pictures independently @@ -178,7 +181,9 @@ std::optional PlanDecoderSession( config.maxInFlight > 256ull * 1024 * 1024) throw AdapterError("ERR_RTC_DECODER_MODE", "Actual SPS does not fit configured MF rate/bitrate hints or GPU budget"); - sv::H264Bitstream initial(sps.width, sps.height, sps.levelIdc, negotiated.profile); + // Qualify the IDR by what the SPS declares: Main, or the Main-compatible Constrained Baseline ValidateSps admitted. + const auto declared = sv::MatchesH264Profile(sps, negotiated.profile) ? negotiated.profile : sv::H264Profile::ConstrainedBaseline; + sv::H264Bitstream initial(sps.width, sps.height, sps.levelIdc, declared); const auto checked = initial.Convert(packet); if (!checked.hasPicture || !checked.keyFrame) throw AdapterError("ERR_RTC_INITIAL_IDR", "The replacement session requires a complete qualified IDR"); diff --git a/apps/client/native/screen-share/src/rtc/inputs/native_core/h264_bitstream.cc b/apps/client/native/screen-share/src/rtc/inputs/native_core/h264_bitstream.cc index 1018311a..91b4a57a 100644 --- a/apps/client/native/screen-share/src/rtc/inputs/native_core/h264_bitstream.cc +++ b/apps/client/native/screen-share/src/rtc/inputs/native_core/h264_bitstream.cc @@ -155,6 +155,11 @@ bool MatchesH264Profile(const H264Sps& sps, H264Profile profile) { return false; } +bool DecodesAsH264Profile(const H264Sps& sps, H264Profile negotiated) { + return MatchesH264Profile(sps, negotiated) || + (negotiated == H264Profile::Main && MatchesH264Profile(sps, H264Profile::ConstrainedBaseline)); +} + H264Sps ParseH264Sps(std::span nal) { if (Type(nal) != 7 || nal.size() > maxParameterBytes) throw std::runtime_error("Invalid SPS"); Bits bits(nal); diff --git a/apps/client/native/screen-share/src/rtc/inputs/native_core/h264_bitstream.h b/apps/client/native/screen-share/src/rtc/inputs/native_core/h264_bitstream.h index 776e5234..fead13e4 100644 --- a/apps/client/native/screen-share/src/rtc/inputs/native_core/h264_bitstream.h +++ b/apps/client/native/screen-share/src/rtc/inputs/native_core/h264_bitstream.h @@ -46,6 +46,9 @@ H264Sps ParseBaselineSps(std::span nal); H264Sps ParseH264Sps(std::span nal); H264Sps ParseProfileLevelId(const std::string& value); bool MatchesH264Profile(const H264Sps& sps, H264Profile profile); +// What a receiver of the negotiated profile decodes: the profile itself and, for Main, the Constrained +// Baseline that declares Main conformance (constraint_set1, H.264 A.2.1), as browsers' OpenH264 sends. +bool DecodesAsH264Profile(const H264Sps& sps, H264Profile negotiated); bool IsBt709LimitedCompatible(const H264Sps& sps); class H264Bitstream { diff --git a/apps/client/package.json b/apps/client/package.json index cb65b07e..ca6cb7d7 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/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/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/serverAddress.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: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", diff --git a/apps/client/scripts/buildMiniapps.mjs b/apps/client/scripts/buildMiniapps.mjs index 20b7a783..ed0f75f8 100644 --- a/apps/client/scripts/buildMiniapps.mjs +++ b/apps/client/scripts/buildMiniapps.mjs @@ -19,10 +19,34 @@ export const WEB_DEV_PORT = 5174; export const MINIAPPS_DEV_URL = 'http://localhost:5175/'; /** Runs inside the miniapp frame before the document replaces the page. */ -function loader(origins, messages) { +export function loader(origins, messages) { const token = location.hash.slice(1); const host = window.parent; if (host === window || !/^[0-9a-f-]{36}$/.test(token)) return; + // This page is HTTPS: the browser blocks the miniapp's plain http/ws calls (mixed content) beyond the + // user's own machine. The calls go on unchanged; the app only learns which service was refused, once. + const reported = new Set(); + const watch = (value) => { + try { + const url = new URL(String(value instanceof Request ? value.url : value), location.href); + if ((url.protocol !== 'http:' && url.protocol !== 'ws:') + || ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || reported.has(url.origin)) return; + reported.add(url.origin); + host.postMessage({ type: messages.insecure, token, origin: url.origin }, '*'); + } catch { /* Not a URL: the call fails on its own. */ } + }; + const fetch = window.fetch; + window.fetch = function (input, init) { watch(input); return fetch.call(this, input, init); }; + const open = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (method, url, ...rest) { watch(url); return open.call(this, method, url, ...rest); }; + for (const name of ['WebSocket', 'EventSource']) { + const Native = window[name]; + if (typeof Native !== 'function') continue; + const Watched = function (url, ...rest) { watch(url); return new Native(url, ...rest); }; + Watched.prototype = Native.prototype; + Object.setPrototypeOf(Watched, Native); + window[name] = Watched; + } const receive = (event) => { const data = event.data; if (event.source !== host || !origins.includes(event.origin) || !data || data.type !== messages.document || diff --git a/apps/client/src/renderer/core/webrtc/BrowserScreenSubscription.ts b/apps/client/src/renderer/core/webrtc/BrowserScreenSubscription.ts index bb414f40..0e79fc0d 100644 --- a/apps/client/src/renderer/core/webrtc/BrowserScreenSubscription.ts +++ b/apps/client/src/renderer/core/webrtc/BrowserScreenSubscription.ts @@ -56,6 +56,22 @@ export class BrowserScreenSubscription { this.options.onError(error); } + /** + * The first frame never came. Video that arrived yet never decoded is the browser's decoder refusing the + * stream (Firefox accepts AV1 in its capability probe and then decodes none of a screen), not the network. + */ + private async startupExpired(message: string): Promise { + let undecoded = false; + try { + (await this.stats())?.forEach(report => { + if (report.type === 'inbound-rtp' && report.kind === 'video' && report.packetsReceived > 0 && !report.framesDecoded) + undecoded = true; + }); + } catch { /* The stats only refine the reason. */ } + this.fail(undecoded ? new BrowserScreenCodecError(`${message} Its video arrived, but this browser decoded none of it.`) + : new Error(message)); + } + public start(): Promise { if (this.starting) return this.starting; if (this.stopping) return Promise.reject(new DOMException('Screen Watch was retired.', 'AbortError')); @@ -77,7 +93,7 @@ export class BrowserScreenSubscription { } if (this.stopping) throw new DOMException('Screen Watch was retired during codec discovery.', 'AbortError'); this.started = true; - this.startupTimer = setTimeout(() => this.fail(new Error('The browser screen did not present its first frame.')), 30000); + this.startupTimer = setTimeout(() => { void this.startupExpired('The browser screen did not present its first frame.'); }, 30000); await this.options.send(this.envelope({ action: 'watch', backend: 'browser', quality: this.options.quality })); })(); return this.starting; @@ -139,7 +155,7 @@ export class BrowserScreenSubscription { else if (signal.capture.mode === 'game' && !this.isPlaying && !this.gameStartupWait) { this.gameStartupWait = true; if (this.startupTimer) clearTimeout(this.startupTimer); - this.startupTimer = setTimeout(() => this.fail(new Error('Game Capture and its Normal fallback did not produce a frame.')), + this.startupTimer = setTimeout(() => { void this.startupExpired('Game Capture and its Normal fallback did not produce a frame.'); }, NATIVE_SCREEN_GAME_STARTUP_TIMEOUT_MS); } return; diff --git a/apps/client/src/renderer/core/webrtc/browserScreenCodecs.ts b/apps/client/src/renderer/core/webrtc/browserScreenCodecs.ts index 51b55d52..7b10926a 100644 --- a/apps/client/src/renderer/core/webrtc/browserScreenCodecs.ts +++ b/apps/client/src/renderer/core/webrtc/browserScreenCodecs.ts @@ -43,17 +43,21 @@ export function av1ReceiveLevel(parameters: Readonly>, v return av1 !== null && av1.level < minimum ? minimum : null; } -export async function supportsBrowserScreenCodec( - profile: Readonly, selected: ScreenCodec = 'h264', -): Promise { - // For AV1 the advertised level is only a default: the decoder probe below decides. - const main = RTCRtpReceiver.getCapabilities('video')?.codecs.some(codec => +/** Whether this browser's WebRTC receiver lists the codec of Monky screens at all: H.264 Main or AV1 profile 0. */ +export function browserReceivesScreenCodec(selected: ScreenCodec = 'h264'): boolean { + return RTCRtpReceiver.getCapabilities('video')?.codecs.some(codec => selected === 'av1' ? codec.mimeType.toLowerCase() === 'video/av1' && av1Parameters(fmtpParameters(codec.sdpFmtpLine ?? ''))?.profile === 0 : codec.mimeType.toLowerCase() === 'video/h264' && /(?:^|;)\s*profile-level-id=4d[0-9a-f]{4}(?:;|$)/i.test(codec.sdpFmtpLine ?? '') - && /(?:^|;)\s*packetization-mode=1(?:;|$)/.test(codec.sdpFmtpLine ?? '')); - if (!main || !navigator.mediaCapabilities?.decodingInfo) return false; + && /(?:^|;)\s*packetization-mode=1(?:;|$)/.test(codec.sdpFmtpLine ?? '')) ?? false; +} + +export async function supportsBrowserScreenCodec( + profile: Readonly, selected: ScreenCodec = 'h264', +): Promise { + // For AV1 the advertised level is only a default: the decoder probe below decides. + if (!browserReceivesScreenCodec(selected) || !navigator.mediaCapabilities?.decodingInfo) return false; const capability = await navigator.mediaCapabilities.decodingInfo({ type: 'webrtc', video: { diff --git a/apps/client/src/renderer/i18n/locales/en.ts b/apps/client/src/renderer/i18n/locales/en.ts index eb8a140b..9bd49c8d 100644 --- a/apps/client/src/renderer/i18n/locales/en.ts +++ b/apps/client/src/renderer/i18n/locales/en.ts @@ -958,8 +958,10 @@ export const en: TranslationMap = { 'screenShare.nativeReceiverSettingsHint': 'You can select Chromium in Settings → Quality & sharing → Screen reception. Read the limitations before switching, then try watching again.', 'screenShare.chromiumReceiverSettingsHint': 'You are using the Chromium receiver, which cannot receive AV1 at high-resolution profiles. Select Native in Settings → Quality & sharing → Screen reception and try watching again, or ask the publisher to use H.264.', 'screenShare.browserReceiverHint': 'Ask the publisher to use H.264 and try watching again.', + 'screenShare.browserCannotReceive': 'This browser cannot receive {codec} screens, the format of this share. Watch it in Chrome, Edge or the desktop app.', 'desktopOnly.badge': 'Desktop', 'desktopOnly.tooltip': 'Available in the desktop app', + 'desktopOnly.reason.screenShare': 'This browser cannot share screens in Monky\'s format (H.264). Use Chrome or Edge, or the desktop app.', 'desktopOnly.title': 'Available in the desktop app', 'desktopOnly.message': '{feature}: this feature only works in the Monky desktop app. Download the app to use it.', 'desktopOnly.panelNotice': '{feature}: available only in the Monky desktop app. Here you can only look at the options.', @@ -2299,6 +2301,7 @@ export const en: TranslationMap = { 'botSelector.loadError': 'Could not load bot choices. Reopen the channel to retry.', 'botScreen.open': 'Open miniapp: {title}', 'botScreen.openView': 'Open miniapp', + 'botScreen.webInsecureService': 'This miniapp tried to reach {service}, a service without a secure connection. In the web version, miniapps only reach HTTPS/WSS services: open it in the desktop app or ask the bot owner to enable HTTPS on the service.', 'botScreen.permissionTitle': 'Open "{title}"?', 'botScreen.permissionMessage': 'This miniapp is a web app offered by a bot authorized by the server administrator. Continuing runs it in your view, where it may:\n- Connect to the internet and local network, including third-party services.\n- Use web storage, play audio/video and receive keyboard, mouse and game controller input.\n- Read files you choose, such as a ROM, and potentially send them over the network.\n- Offer downloads, fullscreen and pointer capture.\n- Receive your identifier, nickname and language and send your actions to the bot.\n\nIt receives no Node, access to the Monky application or unrestricted file reading. Camera, microphone, screen capture and clipboard access remain blocked. External sites may retain data and background activity after the view closes.\n\nThe administrator defines the bot permissions on the server. This notice only confirms whether you want to open the miniapp; it does not grant the bot new permissions.', 'botScreen.permissionAllow': 'Continue and open', diff --git a/apps/client/src/renderer/i18n/locales/pt-BR.ts b/apps/client/src/renderer/i18n/locales/pt-BR.ts index f5c578a1..1dafb827 100644 --- a/apps/client/src/renderer/i18n/locales/pt-BR.ts +++ b/apps/client/src/renderer/i18n/locales/pt-BR.ts @@ -920,8 +920,10 @@ export const ptBR = { 'screenShare.nativeReceiverSettingsHint': 'Você pode selecionar Chromium em Configurações → Qualidade e compartilhamento → Recepção de tela. Leia o aviso de limitações antes de trocar e tente assistir novamente.', 'screenShare.chromiumReceiverSettingsHint': 'Você está usando o receptor Chromium, que não recebe AV1 em perfis de alta resolução. Selecione Nativo em Configurações → Qualidade e compartilhamento → Recepção de tela e tente assistir novamente, ou peça ao transmissor para usar H.264.', 'screenShare.browserReceiverHint': 'Peça a quem está transmitindo para usar H.264 e tente assistir novamente.', + 'screenShare.browserCannotReceive': 'Este navegador não recebe telas em {codec}, o formato desta transmissão. Assista pelo Chrome, pelo Edge ou pelo app desktop.', 'desktopOnly.badge': 'Desktop', 'desktopOnly.tooltip': 'Disponível no app desktop', + 'desktopOnly.reason.screenShare': 'Este navegador não transmite tela no formato do Monky (H.264). Use o Chrome ou o Edge, ou o app desktop.', 'desktopOnly.title': 'Disponível no app desktop', 'desktopOnly.message': '{feature}: este recurso funciona só no app do Monky para computador. Baixe o app para usar.', 'desktopOnly.panelNotice': '{feature}: disponível só no app do Monky para computador. Aqui você só pode ver as opções.', @@ -2261,6 +2263,7 @@ export const ptBR = { 'botSelector.loadError': 'Não foi possível carregar as opções. Reabra o canal para tentar novamente.', 'botScreen.open': 'Abrir miniapp: {title}', 'botScreen.openView': 'Abrir miniapp', + 'botScreen.webInsecureService': 'Este miniapp tentou acessar {service}, um serviço sem conexão segura. Na versão web, miniapps só acessam serviços HTTPS/WSS: abra-o pelo app desktop ou peça ao dono do bot para ativar HTTPS no serviço.', 'botScreen.permissionTitle': 'Abrir "{title}"?', 'botScreen.permissionMessage': 'Este miniapp é um app web oferecido por um bot autorizado pelo administrador do servidor. Ao continuar, ele será executado na sua visualização e poderá:\n- Conectar-se à internet e à rede local, inclusive a serviços de terceiros.\n- Usar armazenamento web, reproduzir áudio e vídeo e receber teclado, mouse e controles de jogos.\n- Ler arquivos que você escolher, como uma ROM, e eventualmente enviá-los pela rede.\n- Oferecer downloads, tela cheia e captura do cursor.\n- Receber seu identificador, apelido e idioma e enviar suas ações ao bot.\n\nEle não recebe Node, acesso ao aplicativo Monky ou leitura livre dos seus arquivos. Câmera, microfone, captura de tela e área de transferência continuam bloqueados. Sites externos podem manter dados e atividade em segundo plano após fechar a visualização.\n\nAs permissões do bot no servidor são definidas pelo administrador. Este aviso apenas confirma se você quer abrir o miniapp; não concede novas permissões ao bot.', 'botScreen.permissionAllow': 'Continuar e abrir', diff --git a/apps/client/src/renderer/platform/web/webBotScreens.ts b/apps/client/src/renderer/platform/web/webBotScreens.ts index e46bc594..45cda63f 100644 --- a/apps/client/src/renderer/platform/web/webBotScreens.ts +++ b/apps/client/src/renderer/platform/web/webBotScreens.ts @@ -3,6 +3,8 @@ import { type BotScreenDocumentConsent, } from '@monky/shared'; import type { BotScreenDocumentHost } from '../api'; +import { t } from '../../i18n'; +import { reportError } from '../../utils/feedback'; /** * Bot miniapps on the web (#858). The app's own origin and CSP must never run a @@ -15,6 +17,8 @@ interface PendingDocument { /** The loader's origin as seen in its messages: its own, or "null" when opaque. */ origin: string; html: string | null; + /** Whether a plain http/ws service this miniapp was refused has been reported: once is enough. */ + refused: boolean; } const MAX_DOCUMENTS = 2; @@ -45,12 +49,30 @@ function isMiniappFrame(source: MessageEventSource): source is WindowProxy { .some((frame) => frame.contentWindow === source); } +/** The origin of a plain http/ws service a miniapp was refused, or null for anything else. */ +function refusedService(value: unknown): string | null { + if (typeof value !== 'string' || value.length > 256) return null; + try { + const url = new URL(value); + return (url.protocol === 'http:' || url.protocol === 'ws:') && url.origin === value ? url.origin : null; + } catch { return null; } +} + const deliver = (event: MessageEvent): void => { const data = event.data; if (!event.source || typeof data !== 'object' || data === null || !('type' in data) || !('token' in data) || - data.type !== MINIAPP_LOADER_MESSAGES.ready || typeof data.token !== 'string') return; + typeof data.token !== 'string') return; const entry = [...documents.values()].find((candidate) => candidate.token === data.token); - if (!entry || entry.html === null || event.origin !== entry.origin || !isMiniappFrame(event.source)) return; + if (!entry || event.origin !== entry.origin || !isMiniappFrame(event.source)) return; + if (data.type === MINIAPP_LOADER_MESSAGES.insecure) { + // A page served over HTTPS cannot reach plain services; the miniapp only shows its own generic failure. + const service = refusedService('origin' in data ? data.origin : null); + if (!service || entry.refused) return; + entry.refused = true; + reportError(t('botScreen.webInsecureService', { service })); + return; + } + if (data.type !== MINIAPP_LOADER_MESSAGES.ready || entry.html === null) return; const html = entry.html; // Once: a page the miniapp navigates to later cannot ask for it again. entry.html = null; @@ -68,7 +90,7 @@ export function createWebBotScreenDocument(html: string, consent: BotScreenDocum if (documents.size >= MAX_DOCUMENTS) return Promise.reject(new Error('Close another miniapp before opening this one.')); const token = crypto.randomUUID(); const { url, origin } = documentUrl(token); - documents.set(url, { token, origin, html }); + documents.set(url, { token, origin, html, refused: false }); if (!listening) { listening = true; window.addEventListener('message', deliver); diff --git a/apps/client/src/renderer/platform/web/webPlatform.ts b/apps/client/src/renderer/platform/web/webPlatform.ts index bd611ff2..83080127 100644 --- a/apps/client/src/renderer/platform/web/webPlatform.ts +++ b/apps/client/src/renderer/platform/web/webPlatform.ts @@ -21,7 +21,7 @@ import { createWebMediaLibrary, supportsDirectoryPicker } from './webMediaLibrar import { createWebBotScreenDocument, removeWebBotScreenDocument, webBotScreenDocumentHost } from './webBotScreens'; import { createEventCalendar } from '../../utils/eventCalendar'; import { clearWebLogs, exportWebLogs, getWebLogConfig, getWebLogSize, setWebLogConfig, writeWebLog } from './webClientLog'; -import { WebScreenRuntime } from './webScreen/webScreenRuntime'; +import { WebScreenRuntime, webScreenCapabilities } from './webScreen/webScreenRuntime'; /** * The platform bridge of the web version (#858). It answers the same contract @@ -30,7 +30,7 @@ import { WebScreenRuntime } from './webScreen/webScreenRuntime'; * Methods the app calls on its own (start-up, settings sync) answer the * neutral value instead: nothing to report, nothing registered. */ -function webCapabilities(): PlatformCapabilities { +export function webCapabilities(): PlatformCapabilities { // Chrome and Edge offer an always-on-top window to a click; other browsers do not. const documentPictureInPicture = 'documentPictureInPicture' in window; return { @@ -48,7 +48,7 @@ function webCapabilities(): PlatformCapabilities { autoUpdate: false, gamePresence: false, nativeScreenAudio: false, - screenShare: typeof navigator.mediaDevices?.getDisplayMedia === 'function', + screenShare: webScreenCapabilities().capture, browserScreenPicker: true, screenEncoderChoice: false, localExecution: false, diff --git a/apps/client/src/renderer/utils/desktopOnly.ts b/apps/client/src/renderer/utils/desktopOnly.ts index 182f4fbc..8a786823 100644 --- a/apps/client/src/renderer/utils/desktopOnly.ts +++ b/apps/client/src/renderer/utils/desktopOnly.ts @@ -49,6 +49,16 @@ export function desktopFeatureName(feature: DesktopFeature): string { return t(FEATURE_NAMES[feature]); } +/** Features a browser lacks for a reason worth naming: this browser, not the web version, cannot do them. */ +const FEATURE_REASONS: Partial>> = { + screenShare: 'desktopOnly.reason.screenShare', +}; + +/** The tooltip of a locked `feature`: why it is locked, or just where it works. */ +function lockedTooltip(feature: DesktopFeature): string { + return t(FEATURE_REASONS[feature] ?? 'desktopOnly.tooltip'); +} + /** Whether `capability` is shown locked here, rather than working or hidden. */ export function isDesktopOnly(capability: keyof PlatformCapabilities): boolean { return featureAvailability(capability) === 'desktop-only'; @@ -61,7 +71,7 @@ export function isShown(capability: keyof PlatformCapabilities): boolean { /** Attributes of one locked control. Never `disabled`: it would block hover, focus and click. */ export function desktopOnlyAttributes(feature: DesktopFeature): string { - return `aria-disabled="true" data-desktop-only="${feature}" data-tooltip="${escapeHtml(t('desktopOnly.tooltip'))}"`; + return `aria-disabled="true" data-desktop-only="${feature}" data-tooltip="${escapeHtml(lockedTooltip(feature))}"`; } /** `desktopOnlyAttributes` when `capability` is locked here, nothing otherwise. */ @@ -167,7 +177,7 @@ export function setDesktopOnly(control: HTMLElement, feature: DesktopFeature | n } control.setAttribute('aria-disabled', 'true'); control.dataset.desktopOnly = feature; - control.dataset.tooltip = t('desktopOnly.tooltip'); + control.dataset.tooltip = lockedTooltip(feature); if (!badge) badgeHost.insertAdjacentHTML('beforeend', desktopOnlyBadge()); } @@ -192,7 +202,8 @@ export function showDesktopOnly(feature: DesktopFeature, returnFocus?: Element): -

${escapeHtml(t('desktopOnly.message', { feature: desktopFeatureName(feature) }))}

+

${escapeHtml(FEATURE_REASONS[feature] ? lockedTooltip(feature) + : t('desktopOnly.message', { feature: desktopFeatureName(feature) }))}