diff --git a/mobile/README.md b/mobile/README.md index f79ba4d31a..674d188d4b 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -72,7 +72,12 @@ The WebView configuration is set to: - `androidScheme: https` — ensures WebRTC and cookies work correctly on Android - `contentInset: always` — iOS safe area respected so game UI is not obscured by notch -Camera and microphone permission requests are handled natively by each platform. See platform READMEs in `android/` and `ios/` once those PRs land. +Camera and microphone permission requests are handled natively by each platform. iframe allow policies continue to pass through from the web app (`allowPolicy` / `allow` attributes), so existing WA scripting API and embedded-site behavior stays in the server-rendered app. + +Known mobile limitation: +- Screen sharing stays browser-only. Android WebView and WKWebView do not provide a reliable `getDisplayMedia` path for the Universe shell, so the front-end hides the screen-share control when running inside the Capacitor app instead of presenting a broken prompt. + +See platform READMEs in `android/` and `ios/` once those PRs land. ## Fastlane diff --git a/play/src/front/Stores/ScreenSharingStore.ts b/play/src/front/Stores/ScreenSharingStore.ts index 9cbeb89de7..26f2f86dd8 100644 --- a/play/src/front/Stores/ScreenSharingStore.ts +++ b/play/src/front/Stores/ScreenSharingStore.ts @@ -1,6 +1,7 @@ import type { Readable } from "svelte/store"; import { get, derived, readable, writable } from "svelte/store"; import type { DesktopCapturerSource } from "../Interfaces/DesktopAppInterfaces"; +import { isNativeMobileApp } from "../WebRtc/DeviceUtils"; import { localUserStore } from "../Connection/LocalUserStore"; import LL from "../../i18n/i18n-svelte"; import { isSpeakerStore, type LocalStreamStoreValue } from "./MediaStore"; @@ -140,6 +141,10 @@ export const screenSharingConstraintsStore = derived( ); export function isScreenSharingSupported(): boolean { + if (isNativeMobileApp()) { + return false; + } + if (window.WAD?.getDesktopCapturerSources) { return true; } diff --git a/play/src/front/WebRtc/DeviceUtils.test.ts b/play/src/front/WebRtc/DeviceUtils.test.ts new file mode 100644 index 0000000000..30a99d3108 --- /dev/null +++ b/play/src/front/WebRtc/DeviceUtils.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isScreenSharingSupported } from "../Stores/ScreenSharingStore"; +import { getNativeAppPlatform, isNativeMobileApp } from "./DeviceUtils"; + +type TestCapacitor = { + getPlatform?: () => "android" | "ios" | "web"; + isNativePlatform?: () => boolean; +}; + +const originalCapacitor = (window as typeof window & { Capacitor?: TestCapacitor }).Capacitor; +const originalMediaDevices = navigator.mediaDevices; + +function setCapacitor(capacitor?: TestCapacitor) { + Object.defineProperty(window, "Capacitor", { + configurable: true, + value: capacitor, + }); +} + +function setMediaDevices(mediaDevices: MediaDevices | undefined) { + Object.defineProperty(window.navigator, "mediaDevices", { + configurable: true, + value: mediaDevices, + }); +} + +afterEach(() => { + setCapacitor(originalCapacitor); + setMediaDevices(originalMediaDevices); + vi.restoreAllMocks(); +}); + +describe("DeviceUtils", () => { + it("detects Capacitor native mobile platforms", () => { + setCapacitor({ + getPlatform: () => "android", + isNativePlatform: () => true, + }); + + expect(getNativeAppPlatform()).toBe("android"); + expect(isNativeMobileApp()).toBe(true); + }); + + it("treats web or missing Capacitor bridge as non-native", () => { + setCapacitor({ + getPlatform: () => "web", + isNativePlatform: () => false, + }); + + expect(getNativeAppPlatform()).toBeUndefined(); + expect(isNativeMobileApp()).toBe(false); + + setCapacitor(undefined); + + expect(getNativeAppPlatform()).toBeUndefined(); + expect(isNativeMobileApp()).toBe(false); + }); +}); + +describe("isScreenSharingSupported", () => { + it("hides screen sharing inside native Capacitor apps", () => { + setCapacitor({ + getPlatform: () => "ios", + isNativePlatform: () => true, + }); + setMediaDevices({ + getDisplayMedia: vi.fn(), + } as unknown as MediaDevices); + + expect(isScreenSharingSupported()).toBe(false); + }); + + it("keeps screen sharing available in regular browsers with getDisplayMedia", () => { + setCapacitor(undefined); + setMediaDevices({ + getDisplayMedia: vi.fn(), + } as unknown as MediaDevices); + + expect(isScreenSharingSupported()).toBe(true); + }); +}); diff --git a/play/src/front/WebRtc/DeviceUtils.ts b/play/src/front/WebRtc/DeviceUtils.ts index 8d04a1eb3f..8b46b832da 100644 --- a/play/src/front/WebRtc/DeviceUtils.ts +++ b/play/src/front/WebRtc/DeviceUtils.ts @@ -28,6 +28,41 @@ export function isAndroid(): boolean { return window.navigator.userAgent.includes("Android"); } +type CapacitorPlatform = "android" | "ios" | "web"; + +type CapacitorGlobal = { + Capacitor?: { + getPlatform?: () => CapacitorPlatform; + isNativePlatform?: () => boolean; + }; +}; + +function getCapacitorBridge() { + return (window as typeof window & CapacitorGlobal).Capacitor; +} + +export function getNativeAppPlatform(): Exclude | undefined { + const capacitor = getCapacitorBridge(); + const platform = capacitor?.getPlatform?.(); + + if (!platform || platform === "web") { + return undefined; + } + + return platform; +} + +export function isNativeMobileApp(): boolean { + const capacitor = getCapacitorBridge(); + const nativePlatform = getNativeAppPlatform(); + + if (nativePlatform) { + return capacitor?.isNativePlatform?.() ?? true; + } + + return false; +} + export function isFirefox(): boolean { return window.navigator.userAgent.toLowerCase().indexOf("firefox") !== -1; }