feat: add microphone auto-enable setting with session-based opt-in

Implement session-based microphone control with optional auto-enable:
- Microphone disabled by default, only requests permission when enabled
- Added persistent "Auto-enable Microphone" setting in audio settings
- Setting syncs to backend and works across browsers/devices
- Auto-enable respects secure context (HTTPS/localhost only)
- Refactored secure context check into reusable utility function
- Removed unused audioInputEnabled store properties
This commit is contained in:
Alex P 2025-11-03 22:09:39 +02:00
parent d0e6bb1ac6
commit 925b14da1e
15 changed files with 116 additions and 61 deletions

View File

@ -82,6 +82,8 @@
"audio_settings_output_title": "Lydudgang", "audio_settings_output_title": "Lydudgang",
"audio_settings_title": "Lyd", "audio_settings_title": "Lyd",
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk",
"audio_settings_auto_enable_microphone_description": "Aktiver automatisk browsermikrofon ved tilslutning (ellers skal du aktivere det manuelt ved hver session)",
"action_bar_extension": "Udvidelse", "action_bar_extension": "Udvidelse",
"action_bar_fullscreen": "Fuldskærm", "action_bar_fullscreen": "Fuldskærm",
"action_bar_settings": "Indstillinger", "action_bar_settings": "Indstillinger",

View File

@ -82,6 +82,8 @@
"audio_settings_output_title": "Audioausgang", "audio_settings_output_title": "Audioausgang",
"audio_settings_title": "Audio", "audio_settings_title": "Audio",
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Mikrofon automatisch aktivieren",
"audio_settings_auto_enable_microphone_description": "Browser-Mikrofon beim Verbinden automatisch aktivieren (andernfalls müssen Sie es in jeder Sitzung manuell aktivieren)",
"action_bar_extension": "Erweiterung", "action_bar_extension": "Erweiterung",
"action_bar_fullscreen": "Vollbild", "action_bar_fullscreen": "Vollbild",
"action_bar_settings": "Einstellungen", "action_bar_settings": "Einstellungen",

View File

@ -82,6 +82,8 @@
"audio_settings_output_title": "Audio Output", "audio_settings_output_title": "Audio Output",
"audio_settings_title": "Audio", "audio_settings_title": "Audio",
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Auto-enable Microphone",
"audio_settings_auto_enable_microphone_description": "Automatically enable browser microphone when connecting (otherwise you must manually enable each session)",
"action_bar_extension": "Extension", "action_bar_extension": "Extension",
"action_bar_fullscreen": "Fullscreen", "action_bar_fullscreen": "Fullscreen",
"action_bar_settings": "Settings", "action_bar_settings": "Settings",

View File

@ -82,6 +82,8 @@
"audio_settings_output_title": "Salida de audio", "audio_settings_output_title": "Salida de audio",
"audio_settings_title": "Audio", "audio_settings_title": "Audio",
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Habilitar micrófono automáticamente",
"audio_settings_auto_enable_microphone_description": "Habilitar automáticamente el micrófono del navegador al conectar (de lo contrario, debe habilitarlo manualmente en cada sesión)",
"action_bar_extension": "Extensión", "action_bar_extension": "Extensión",
"action_bar_fullscreen": "Pantalla completa", "action_bar_fullscreen": "Pantalla completa",
"action_bar_settings": "Ajustes", "action_bar_settings": "Ajustes",

View File

@ -82,6 +82,8 @@
"audio_settings_output_title": "Sortie audio", "audio_settings_output_title": "Sortie audio",
"audio_settings_title": "Audio", "audio_settings_title": "Audio",
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Activer automatiquement le microphone",
"audio_settings_auto_enable_microphone_description": "Activer automatiquement le microphone du navigateur lors de la connexion (sinon vous devez l'activer manuellement à chaque session)",
"action_bar_extension": "Extension", "action_bar_extension": "Extension",
"action_bar_fullscreen": "Plein écran", "action_bar_fullscreen": "Plein écran",
"action_bar_settings": "Paramètres", "action_bar_settings": "Paramètres",

View File

@ -82,6 +82,8 @@
"audio_settings_output_title": "Uscita audio", "audio_settings_output_title": "Uscita audio",
"audio_settings_title": "Audio", "audio_settings_title": "Audio",
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Abilita automaticamente il microfono",
"audio_settings_auto_enable_microphone_description": "Abilita automaticamente il microfono del browser durante la connessione (altrimenti devi abilitarlo manualmente ad ogni sessione)",
"action_bar_extension": "Estensione", "action_bar_extension": "Estensione",
"action_bar_fullscreen": "A schermo intero", "action_bar_fullscreen": "A schermo intero",
"action_bar_settings": "Impostazioni", "action_bar_settings": "Impostazioni",

View File

@ -82,6 +82,8 @@
"audio_settings_output_title": "Lydutgang", "audio_settings_output_title": "Lydutgang",
"audio_settings_title": "Lyd", "audio_settings_title": "Lyd",
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk",
"audio_settings_auto_enable_microphone_description": "Aktiver automatisk nettlesermikrofon ved tilkobling (ellers må du aktivere det manuelt hver økt)",
"action_bar_extension": "Forlengelse", "action_bar_extension": "Forlengelse",
"action_bar_fullscreen": "Fullskjerm", "action_bar_fullscreen": "Fullskjerm",
"action_bar_settings": "Innstillinger", "action_bar_settings": "Innstillinger",

View File

@ -82,6 +82,8 @@
"audio_settings_output_title": "Ljudutgång", "audio_settings_output_title": "Ljudutgång",
"audio_settings_title": "Ljud", "audio_settings_title": "Ljud",
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Aktivera mikrofon automatiskt",
"audio_settings_auto_enable_microphone_description": "Aktivera automatiskt webbläsarmikrofon vid anslutning (annars måste du aktivera den manuellt varje session)",
"action_bar_extension": "Förlängning", "action_bar_extension": "Förlängning",
"action_bar_fullscreen": "Helskärm", "action_bar_fullscreen": "Helskärm",
"action_bar_settings": "Inställningar", "action_bar_settings": "Inställningar",

View File

@ -82,6 +82,8 @@
"audio_settings_output_title": "音频输出", "audio_settings_output_title": "音频输出",
"audio_settings_title": "音频", "audio_settings_title": "音频",
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "自动启用麦克风",
"audio_settings_auto_enable_microphone_description": "连接时自动启用浏览器麦克风(否则您必须在每次会话中手动启用)",
"action_bar_extension": "扩展", "action_bar_extension": "扩展",
"action_bar_fullscreen": "全屏", "action_bar_fullscreen": "全屏",
"action_bar_settings": "设置", "action_bar_settings": "设置",

View File

@ -22,6 +22,7 @@ import {
import { keys } from "@/keyboardMappings"; import { keys } from "@/keyboardMappings";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
import { isSecureContext } from "@/utils";
export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) { export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) {
// Video and stream related refs and states // Video and stream related refs and states
@ -33,7 +34,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
const [isPointerLockActive, setIsPointerLockActive] = useState(false); const [isPointerLockActive, setIsPointerLockActive] = useState(false);
const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false); const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false);
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost"; const isPointerLockPossible = isSecureContext();
// Store hooks // Store hooks
const settings = useSettingsStore(); const settings = useSettingsStore();

View File

@ -1,20 +1,22 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useSettingsStore } from "@/hooks/stores";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { SettingsItem } from "@components/SettingsItem"; import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import Checkbox from "@components/Checkbox"; import Checkbox from "@components/Checkbox";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
import { isSecureContext } from "@/utils";
export default function AudioPopover() { export default function AudioPopover() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { microphoneEnabled, setMicrophoneEnabled } = useSettingsStore();
const [audioOutputEnabled, setAudioOutputEnabled] = useState<boolean>(true); const [audioOutputEnabled, setAudioOutputEnabled] = useState<boolean>(true);
const [audioInputEnabled, setAudioInputEnabled] = useState<boolean>(true);
const [usbAudioEnabled, setUsbAudioEnabled] = useState<boolean>(false); const [usbAudioEnabled, setUsbAudioEnabled] = useState<boolean>(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const isHttps = window.location.protocol === "https:" || window.location.hostname === "localhost"; const isHttps = isSecureContext();
useEffect(() => { useEffect(() => {
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => { send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
@ -25,14 +27,6 @@ export default function AudioPopover() {
} }
}); });
send("getAudioInputEnabled", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to load audio input enabled:", resp.error);
} else {
setAudioInputEnabled(resp.result as boolean);
}
});
send("getUsbDevices", {}, (resp: JsonRpcResponse) => { send("getUsbDevices", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
console.error("Failed to load USB devices:", resp.error); console.error("Failed to load USB devices:", resp.error);
@ -60,23 +54,6 @@ export default function AudioPopover() {
}); });
}, [send]); }, [send]);
const handleAudioInputEnabledToggle = useCallback((enabled: boolean) => {
setLoading(true);
send("setAudioInputEnabled", { enabled }, (resp: JsonRpcResponse) => {
setLoading(false);
if ("error" in resp) {
const errorMsg = enabled
? m.audio_input_failed_enable({ error: String(resp.error.data || m.unknown_error()) })
: m.audio_input_failed_disable({ error: String(resp.error.data || m.unknown_error()) });
notifications.error(errorMsg);
} else {
setAudioInputEnabled(enabled);
const successMsg = enabled ? m.audio_input_enabled() : m.audio_input_disabled();
notifications.success(successMsg);
}
});
}, [send]);
return ( return (
<GridCard> <GridCard>
<div className="space-y-4 p-4 py-3"> <div className="space-y-4 p-4 py-3">
@ -103,7 +80,6 @@ export default function AudioPopover() {
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" /> <div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsItem <SettingsItem
loading={loading}
title={m.audio_microphone_title()} title={m.audio_microphone_title()}
description={m.audio_microphone_description()} description={m.audio_microphone_description()}
badge={!isHttps ? m.audio_https_only() : undefined} badge={!isHttps ? m.audio_https_only() : undefined}
@ -111,9 +87,9 @@ export default function AudioPopover() {
badgeLink={!isHttps ? "settings/access" : undefined} badgeLink={!isHttps ? "settings/access" : undefined}
> >
<Checkbox <Checkbox
checked={audioInputEnabled} checked={microphoneEnabled}
disabled={!isHttps} disabled={!isHttps}
onChange={(e) => handleAudioInputEnabledToggle(e.target.checked)} onChange={(e) => setMicrophoneEnabled(e.target.checked)}
/> />
</SettingsItem> </SettingsItem>
</> </>

View File

@ -140,6 +140,9 @@ export interface RTCState {
transceiver: RTCRtpTransceiver | null; transceiver: RTCRtpTransceiver | null;
setTransceiver: (transceiver: RTCRtpTransceiver) => void; setTransceiver: (transceiver: RTCRtpTransceiver) => void;
audioTransceiver: RTCRtpTransceiver | null;
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => void;
mediaStream: MediaStream | null; mediaStream: MediaStream | null;
setMediaStream: (stream: MediaStream) => void; setMediaStream: (stream: MediaStream) => void;
@ -198,6 +201,9 @@ export const useRTCStore = create<RTCState>(set => ({
transceiver: null, transceiver: null,
setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }), setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }),
audioTransceiver: null,
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => set({ audioTransceiver: transceiver }),
peerConnectionState: null, peerConnectionState: null,
setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }), setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }),
@ -372,8 +378,10 @@ export interface SettingsState {
// Audio settings // Audio settings
audioOutputEnabled: boolean; audioOutputEnabled: boolean;
setAudioOutputEnabled: (enabled: boolean) => void; setAudioOutputEnabled: (enabled: boolean) => void;
audioInputEnabled: boolean; microphoneEnabled: boolean;
setAudioInputEnabled: (enabled: boolean) => void; setMicrophoneEnabled: (enabled: boolean) => void;
audioInputAutoEnable: boolean;
setAudioInputAutoEnable: (enabled: boolean) => void;
} }
export const useSettingsStore = create( export const useSettingsStore = create(
@ -425,8 +433,10 @@ export const useSettingsStore = create(
// Audio settings with defaults // Audio settings with defaults
audioOutputEnabled: true, audioOutputEnabled: true,
setAudioOutputEnabled: (enabled: boolean) => set({ audioOutputEnabled: enabled }), setAudioOutputEnabled: (enabled: boolean) => set({ audioOutputEnabled: enabled }),
audioInputEnabled: true, microphoneEnabled: false,
setAudioInputEnabled: (enabled: boolean) => set({ audioInputEnabled: enabled }), setMicrophoneEnabled: (enabled: boolean) => set({ microphoneEnabled: enabled }),
audioInputAutoEnable: false,
setAudioInputAutoEnable: (enabled: boolean) => set({ audioInputAutoEnable: enabled }),
}), }),
{ {
name: "settings", name: "settings",

View File

@ -26,7 +26,7 @@ export default function SettingsAudioRoute() {
if ("error" in resp) { if ("error" in resp) {
return; return;
} }
settings.setAudioInputEnabled(resp.result as boolean); settings.setAudioInputAutoEnable(resp.result as boolean);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [send]); }, [send]);
@ -46,7 +46,7 @@ export default function SettingsAudioRoute() {
}); });
}; };
const handleAudioInputEnabledChange = (enabled: boolean) => { const handleAudioInputAutoEnableChange = (enabled: boolean) => {
send("setAudioInputEnabled", { enabled }, (resp: JsonRpcResponse) => { send("setAudioInputEnabled", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
const errorMsg = enabled const errorMsg = enabled
@ -55,7 +55,7 @@ export default function SettingsAudioRoute() {
notifications.error(errorMsg); notifications.error(errorMsg);
return; return;
} }
settings.setAudioInputEnabled(enabled); settings.setAudioInputAutoEnable(enabled);
const successMsg = enabled ? m.audio_input_enabled() : m.audio_input_disabled(); const successMsg = enabled ? m.audio_input_enabled() : m.audio_input_disabled();
notifications.success(successMsg); notifications.success(successMsg);
}); });
@ -79,12 +79,12 @@ export default function SettingsAudioRoute() {
</SettingsItem> </SettingsItem>
<SettingsItem <SettingsItem
title={m.audio_settings_input_title()} title={m.audio_settings_auto_enable_microphone_title()}
description={m.audio_settings_input_description()} description={m.audio_settings_auto_enable_microphone_description()}
> >
<Checkbox <Checkbox
checked={settings.audioInputEnabled || false} checked={settings.audioInputAutoEnable || false}
onChange={(e) => handleAudioInputEnabledChange(e.target.checked)} onChange={(e) => handleAudioInputAutoEnableChange(e.target.checked)}
/> />
</SettingsItem> </SettingsItem>
</div> </div>

View File

@ -29,6 +29,7 @@ import {
useNetworkStateStore, useNetworkStateStore,
User, User,
useRTCStore, useRTCStore,
useSettingsStore,
useUiStore, useUiStore,
useUpdateStore, useUpdateStore,
useVideoStore, useVideoStore,
@ -51,6 +52,7 @@ import {
} from "@components/VideoOverlay"; } from "@components/VideoOverlay";
import { FeatureFlagProvider } from "@providers/FeatureFlagProvider"; import { FeatureFlagProvider } from "@providers/FeatureFlagProvider";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
import { isSecureContext } from "@/utils";
export type AuthMode = "password" | "noPassword" | null; export type AuthMode = "password" | "noPassword" | null;
@ -111,6 +113,7 @@ export default function KvmIdRoute() {
const params = useParams() as { id: string }; const params = useParams() as { id: string };
const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore(); const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore();
const { microphoneEnabled, setMicrophoneEnabled, audioInputAutoEnable, setAudioInputAutoEnable } = useSettingsStore();
const [queryParams, setQueryParams] = useSearchParams(); const [queryParams, setQueryParams] = useSearchParams();
const { const {
@ -121,6 +124,8 @@ export default function KvmIdRoute() {
isTurnServerInUse, setTurnServerInUse, isTurnServerInUse, setTurnServerInUse,
rpcDataChannel, rpcDataChannel,
setTransceiver, setTransceiver,
setAudioTransceiver,
audioTransceiver,
setRpcHidChannel, setRpcHidChannel,
setRpcHidUnreliableNonOrderedChannel, setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel, setRpcHidUnreliableChannel,
@ -530,26 +535,12 @@ export default function KvmIdRoute() {
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" })); setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
const audioTransceiver = pc.addTransceiver("audio", { direction: "sendrecv" }); const audioTrans = pc.addTransceiver("audio", { direction: "sendrecv" });
setAudioTransceiver(audioTrans);
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { // Enable microphone if auto-enable is on (only works over HTTPS or localhost)
navigator.mediaDevices.getUserMedia({ if (audioInputAutoEnable && isSecureContext()) {
audio: { setMicrophoneEnabled(true);
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 2, // Request stereo input if available
}
}).then((stream) => {
const audioTrack = stream.getAudioTracks()[0];
if (audioTrack && audioTransceiver.sender) {
audioTransceiver.sender.replaceTrack(audioTrack);
}
}).catch((err) => {
console.warn("Microphone access denied or unavailable:", err.message);
});
} else {
console.warn("navigator.mediaDevices.getUserMedia is not available in this browser/context");
} }
const rpcDataChannel = pc.createDataChannel("rpc"); const rpcDataChannel = pc.createDataChannel("rpc");
@ -603,6 +594,9 @@ export default function KvmIdRoute() {
setRpcHidUnreliableNonOrderedChannel, setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel, setRpcHidUnreliableChannel,
setTransceiver, setTransceiver,
setAudioTransceiver,
audioInputAutoEnable,
setMicrophoneEnabled,
]); ]);
useEffect(() => { useEffect(() => {
@ -612,6 +606,48 @@ export default function KvmIdRoute() {
} }
}, [peerConnectionState, cleanupAndStopReconnecting]); }, [peerConnectionState, cleanupAndStopReconnecting]);
// Handle dynamic microphone enable/disable
useEffect(() => {
if (!audioTransceiver || !peerConnection) return;
if (microphoneEnabled) {
// Request microphone access
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 2,
}
}).then((stream) => {
const audioTrack = stream.getAudioTracks()[0];
if (audioTrack && audioTransceiver.sender) {
audioTransceiver.sender.replaceTrack(audioTrack);
console.log("Microphone enabled");
}
}).catch((err) => {
console.warn("Microphone access denied or unavailable:", err.message);
setMicrophoneEnabled(false);
});
}
} else {
// Disable microphone by removing the track
if (audioTransceiver.sender.track) {
audioTransceiver.sender.track.stop();
audioTransceiver.sender.replaceTrack(null);
console.log("Microphone disabled");
}
}
}, [microphoneEnabled, audioTransceiver, peerConnection, setMicrophoneEnabled]);
// Auto-enable microphone when setting is loaded from backend
useEffect(() => {
if (audioInputAutoEnable && audioTransceiver && peerConnection && !microphoneEnabled && isSecureContext()) {
setMicrophoneEnabled(true);
}
}, [audioInputAutoEnable, audioTransceiver, peerConnection, microphoneEnabled, setMicrophoneEnabled]);
// Cleanup effect // Cleanup effect
const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore(); const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore();
@ -770,6 +806,16 @@ export default function KvmIdRoute() {
const { send } = useJsonRpc(onJsonRpcRequest); const { send } = useJsonRpc(onJsonRpcRequest);
// Load audio input auto-enable setting from backend on mount
useEffect(() => {
send("getAudioInputEnabled", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
return;
}
setAudioInputAutoEnable(resp.result as boolean);
});
}, [send, setAudioInputAutoEnable]);
useEffect(() => { useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
console.log("Requesting video state"); console.log("Requesting video state");

View File

@ -301,3 +301,7 @@ export function deleteCookie(name: string, domain?: string, path = "/") {
export function sleep(ms: number): Promise<void> { export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
export function isSecureContext(): boolean {
return window.location.protocol === "https:" || window.location.hostname === "localhost";
}