2023-04-27 09:03:40 +08:00
|
|
|
package websrv
|
|
|
|
|
|
|
|
import (
|
2024-06-25 21:27:44 +08:00
|
|
|
"bytes"
|
2023-04-27 09:03:40 +08:00
|
|
|
"context"
|
2024-06-25 21:27:44 +08:00
|
|
|
"encoding/json"
|
2023-04-27 09:03:40 +08:00
|
|
|
"fmt"
|
2024-06-15 00:43:05 +08:00
|
|
|
"github.com/iMDT/bbb-graphql-middleware/internal/akka_apps"
|
2024-05-30 04:43:17 +08:00
|
|
|
"github.com/iMDT/bbb-graphql-middleware/internal/bbb_web"
|
2023-05-10 02:37:58 +08:00
|
|
|
"github.com/iMDT/bbb-graphql-middleware/internal/common"
|
2024-05-02 21:45:32 +08:00
|
|
|
"github.com/iMDT/bbb-graphql-middleware/internal/gql_actions"
|
|
|
|
"github.com/iMDT/bbb-graphql-middleware/internal/hasura"
|
2023-05-10 02:37:58 +08:00
|
|
|
"github.com/iMDT/bbb-graphql-middleware/internal/msgpatch"
|
2023-04-27 09:03:40 +08:00
|
|
|
"github.com/iMDT/bbb-graphql-middleware/internal/websrv/reader"
|
|
|
|
"github.com/iMDT/bbb-graphql-middleware/internal/websrv/writer"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"net/http"
|
2023-05-10 02:37:58 +08:00
|
|
|
"nhooyr.io/websocket"
|
2023-11-24 21:49:23 +08:00
|
|
|
"os"
|
2024-05-30 04:43:17 +08:00
|
|
|
"strings"
|
2023-04-27 09:03:40 +08:00
|
|
|
"sync"
|
|
|
|
"time"
|
|
|
|
)
|
|
|
|
|
|
|
|
var lastBrowserConnectionId int
|
|
|
|
|
|
|
|
// Buffer size of the channels
|
|
|
|
var bufferSize = 100
|
|
|
|
|
|
|
|
// active browser connections
|
|
|
|
var BrowserConnections = make(map[string]*common.BrowserConnection)
|
2023-07-06 20:34:48 +08:00
|
|
|
var BrowserConnectionsMutex = &sync.RWMutex{}
|
2023-04-27 09:03:40 +08:00
|
|
|
|
|
|
|
// Handle client connection
|
|
|
|
// This is the connection that comes from browser
|
|
|
|
func ConnectionHandler(w http.ResponseWriter, r *http.Request) {
|
|
|
|
log := log.WithField("_routine", "ConnectionHandler")
|
2024-03-29 04:17:13 +08:00
|
|
|
common.ActivitiesOverviewStarted("__BrowserConnection")
|
|
|
|
defer common.ActivitiesOverviewCompleted("__BrowserConnection")
|
2023-04-27 09:03:40 +08:00
|
|
|
|
|
|
|
// Obtain id for this connection
|
|
|
|
lastBrowserConnectionId++
|
|
|
|
browserConnectionId := "BC" + fmt.Sprintf("%010d", lastBrowserConnectionId)
|
|
|
|
log = log.WithField("browserConnectionId", browserConnectionId)
|
|
|
|
|
|
|
|
// Starts a context that will be dependent on the connection, so we can cancel subroutines when the connection is dropped
|
|
|
|
browserConnectionContext, browserConnectionContextCancel := context.WithCancel(r.Context())
|
|
|
|
defer browserConnectionContextCancel()
|
|
|
|
|
|
|
|
// Add sub-protocol
|
|
|
|
var acceptOptions websocket.AcceptOptions
|
2024-05-23 02:51:12 +08:00
|
|
|
acceptOptions.Subprotocols = append(acceptOptions.Subprotocols, "graphql-transport-ws")
|
2023-11-24 21:49:23 +08:00
|
|
|
bbbOrigin := os.Getenv("BBB_GRAPHQL_MIDDLEWARE_ORIGIN")
|
|
|
|
if bbbOrigin != "" {
|
|
|
|
acceptOptions.OriginPatterns = append(acceptOptions.OriginPatterns, bbbOrigin)
|
|
|
|
}
|
2023-04-27 09:03:40 +08:00
|
|
|
|
2024-03-13 07:12:55 +08:00
|
|
|
browserWsConn, err := websocket.Accept(w, r, &acceptOptions)
|
2024-03-28 02:29:38 +08:00
|
|
|
browserWsConn.SetReadLimit(9999999) //10MB
|
2023-04-27 09:03:40 +08:00
|
|
|
if err != nil {
|
|
|
|
log.Errorf("error: %v", err)
|
|
|
|
}
|
2024-03-13 07:12:55 +08:00
|
|
|
defer browserWsConn.Close(websocket.StatusInternalError, "the sky is falling")
|
2023-04-27 09:03:40 +08:00
|
|
|
|
|
|
|
var thisConnection = common.BrowserConnection{
|
2024-07-06 00:35:08 +08:00
|
|
|
Id: browserConnectionId,
|
|
|
|
Websocket: browserWsConn,
|
|
|
|
BrowserRequestCookies: r.Cookies(),
|
|
|
|
ActiveSubscriptions: make(map[string]common.GraphQlSubscription, 1),
|
|
|
|
Context: browserConnectionContext,
|
|
|
|
ContextCancelFunc: browserConnectionContextCancel,
|
|
|
|
ConnAckSentToBrowser: false,
|
|
|
|
FromBrowserToHasuraChannel: common.NewSafeChannelByte(bufferSize),
|
|
|
|
FromBrowserToGqlActionsChannel: common.NewSafeChannelByte(bufferSize),
|
|
|
|
FromHasuraToBrowserChannel: common.NewSafeChannelByte(bufferSize),
|
2023-04-27 09:03:40 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
BrowserConnectionsMutex.Lock()
|
|
|
|
BrowserConnections[browserConnectionId] = &thisConnection
|
|
|
|
BrowserConnectionsMutex.Unlock()
|
|
|
|
|
|
|
|
defer func() {
|
2023-05-25 21:15:02 +08:00
|
|
|
msgpatch.RemoveConnCacheDir(browserConnectionId)
|
2023-04-27 09:03:40 +08:00
|
|
|
BrowserConnectionsMutex.Lock()
|
2024-03-18 22:28:11 +08:00
|
|
|
_, bcExists := BrowserConnections[browserConnectionId]
|
|
|
|
if bcExists {
|
|
|
|
sessionTokenRemoved := BrowserConnections[browserConnectionId].SessionToken
|
|
|
|
delete(BrowserConnections, browserConnectionId)
|
2024-06-20 03:41:15 +08:00
|
|
|
|
|
|
|
if sessionTokenRemoved != "" {
|
|
|
|
go SendUserGraphqlConnectionClosedSysMsg(sessionTokenRemoved, browserConnectionId)
|
|
|
|
}
|
2024-03-18 22:28:11 +08:00
|
|
|
}
|
2023-04-27 09:03:40 +08:00
|
|
|
BrowserConnectionsMutex.Unlock()
|
2023-09-07 22:54:27 +08:00
|
|
|
|
|
|
|
log.Infof("connection removed")
|
2023-04-27 09:03:40 +08:00
|
|
|
}()
|
|
|
|
|
|
|
|
// Log it
|
2023-09-07 22:54:27 +08:00
|
|
|
log.Infof("connection accepted")
|
2023-04-27 09:03:40 +08:00
|
|
|
|
2024-05-30 04:43:17 +08:00
|
|
|
// Configure the wait group (to hold this routine execution until both are completed)
|
|
|
|
var wgAll sync.WaitGroup
|
2024-06-04 03:02:28 +08:00
|
|
|
wgAll.Add(2)
|
2024-05-30 04:43:17 +08:00
|
|
|
|
|
|
|
// Other wait group to close this connection once Browser Reader dies
|
|
|
|
var wgReader sync.WaitGroup
|
|
|
|
wgReader.Add(1)
|
|
|
|
|
2024-07-06 00:35:08 +08:00
|
|
|
// Reads from browser connection, writes into fromBrowserToHasuraChannel
|
|
|
|
go reader.BrowserConnectionReader(&thisConnection, []*sync.WaitGroup{&wgAll, &wgReader})
|
2024-05-30 04:43:17 +08:00
|
|
|
|
|
|
|
go func() {
|
|
|
|
wgReader.Wait()
|
|
|
|
log.Debug("BrowserConnectionReader finished, closing Write Channel")
|
2024-07-06 00:35:08 +08:00
|
|
|
thisConnection.FromHasuraToBrowserChannel.Close()
|
2024-05-30 04:43:17 +08:00
|
|
|
thisConnection.Disconnected = true
|
|
|
|
}()
|
|
|
|
|
2024-07-06 00:35:08 +08:00
|
|
|
//Check authorization and obtain user session variables from bbb-web
|
|
|
|
if errorOnInitConnection := connectionInitHandler(&thisConnection); errorOnInitConnection != nil {
|
2024-05-30 04:43:17 +08:00
|
|
|
//If the server wishes to reject the connection it is recommended to close the socket with `4403: Forbidden`.
|
|
|
|
//https://github.com/enisdenjo/graphql-ws/blob/63881c3372a3564bf42040e3f572dd74e41b2e49/PROTOCOL.md?plain=1#L36
|
|
|
|
wsError := &websocket.CloseError{
|
|
|
|
Code: websocket.StatusCode(4403),
|
|
|
|
Reason: errorOnInitConnection.Error(),
|
|
|
|
}
|
|
|
|
browserWsConn.Close(wsError.Code, wsError.Reason)
|
|
|
|
browserConnectionContextCancel()
|
|
|
|
}
|
|
|
|
|
2023-04-27 09:03:40 +08:00
|
|
|
// Ensure a hasura client is running while the browser is connected
|
|
|
|
go func() {
|
2023-09-07 22:54:27 +08:00
|
|
|
log.Debugf("starting hasura client")
|
2023-04-27 09:03:40 +08:00
|
|
|
|
|
|
|
BrowserConnectedLoop:
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-browserConnectionContext.Done():
|
|
|
|
break BrowserConnectedLoop
|
|
|
|
default:
|
|
|
|
{
|
2023-09-07 22:54:27 +08:00
|
|
|
log.Debugf("creating hasura client")
|
2023-07-06 20:34:48 +08:00
|
|
|
BrowserConnectionsMutex.RLock()
|
2023-04-27 09:03:40 +08:00
|
|
|
thisBrowserConnection := BrowserConnections[browserConnectionId]
|
2023-07-06 20:34:48 +08:00
|
|
|
BrowserConnectionsMutex.RUnlock()
|
2023-04-28 05:30:36 +08:00
|
|
|
if thisBrowserConnection != nil {
|
2024-01-24 07:20:16 +08:00
|
|
|
log.Debugf("created hasura client")
|
2024-07-06 00:35:08 +08:00
|
|
|
hasura.HasuraClient(thisBrowserConnection)
|
2024-05-02 21:45:32 +08:00
|
|
|
}
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
// Ensure a gql-actions client is running while the browser is connected
|
|
|
|
go func() {
|
|
|
|
log.Debugf("starting gql-actions client")
|
|
|
|
|
|
|
|
BrowserConnectedLoop:
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-browserConnectionContext.Done():
|
|
|
|
break BrowserConnectedLoop
|
|
|
|
default:
|
|
|
|
{
|
|
|
|
log.Debugf("creating gql-actions client")
|
|
|
|
BrowserConnectionsMutex.RLock()
|
|
|
|
thisBrowserConnection := BrowserConnections[browserConnectionId]
|
|
|
|
BrowserConnectionsMutex.RUnlock()
|
|
|
|
if thisBrowserConnection != nil {
|
|
|
|
log.Debugf("created gql-actions client")
|
|
|
|
|
|
|
|
BrowserConnectionsMutex.Lock()
|
|
|
|
thisBrowserConnection.GraphqlActionsContext, thisBrowserConnection.GraphqlActionsContextCancel = context.WithCancel(browserConnectionContext)
|
|
|
|
BrowserConnectionsMutex.Unlock()
|
|
|
|
|
2024-07-06 00:35:08 +08:00
|
|
|
gql_actions.GraphqlActionsClient(thisBrowserConnection)
|
2023-04-28 05:30:36 +08:00
|
|
|
}
|
2024-05-30 04:43:17 +08:00
|
|
|
time.Sleep(1000 * time.Millisecond)
|
2023-04-27 09:03:40 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
2023-12-15 01:01:47 +08:00
|
|
|
// Reads from fromHasuraToBrowserChannel, writes to browser connection
|
2024-07-06 00:35:08 +08:00
|
|
|
go writer.BrowserConnectionWriter(&thisConnection, &wgAll)
|
2023-04-27 09:03:40 +08:00
|
|
|
|
|
|
|
// Wait until all routines are finished
|
|
|
|
wgAll.Wait()
|
|
|
|
}
|
2023-09-30 07:05:23 +08:00
|
|
|
|
|
|
|
func InvalidateSessionTokenConnections(sessionTokenToInvalidate string) {
|
|
|
|
BrowserConnectionsMutex.RLock()
|
2024-03-18 22:28:11 +08:00
|
|
|
connectionsToProcess := make([]*common.BrowserConnection, 0)
|
2023-09-30 07:05:23 +08:00
|
|
|
for _, browserConnection := range BrowserConnections {
|
2024-03-18 22:28:11 +08:00
|
|
|
if browserConnection.SessionToken == sessionTokenToInvalidate {
|
|
|
|
connectionsToProcess = append(connectionsToProcess, browserConnection)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
BrowserConnectionsMutex.RUnlock()
|
|
|
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
for _, browserConnection := range connectionsToProcess {
|
|
|
|
wg.Add(1)
|
|
|
|
go func(bc *common.BrowserConnection) {
|
|
|
|
defer wg.Done()
|
2024-05-02 21:45:32 +08:00
|
|
|
invalidateHasuraConnectionForSessionToken(bc, sessionTokenToInvalidate)
|
2024-03-18 22:28:11 +08:00
|
|
|
}(browserConnection)
|
|
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
}
|
|
|
|
|
2024-05-02 21:45:32 +08:00
|
|
|
func invalidateHasuraConnectionForSessionToken(bc *common.BrowserConnection, sessionToken string) {
|
2024-07-06 00:35:08 +08:00
|
|
|
BrowserConnectionsMutex.RLock()
|
|
|
|
defer BrowserConnectionsMutex.RUnlock()
|
|
|
|
|
2024-03-18 22:28:11 +08:00
|
|
|
if bc.HasuraConnection == nil {
|
|
|
|
return // If there's no Hasura connection, there's nothing to invalidate.
|
|
|
|
}
|
|
|
|
|
2024-07-06 00:35:08 +08:00
|
|
|
log.Debugf("Processing invalidate request for sessionToken %v (hasura connection %v)", sessionToken, bc.HasuraConnection.Id)
|
2024-03-18 22:28:11 +08:00
|
|
|
|
2024-07-06 00:35:08 +08:00
|
|
|
// Stop receiving new messages from the browser.
|
|
|
|
log.Debug("freezing channel fromBrowserToHasuraChannel")
|
|
|
|
bc.FromBrowserToHasuraChannel.FreezeChannel()
|
2024-03-18 22:28:11 +08:00
|
|
|
|
2024-07-06 00:35:08 +08:00
|
|
|
//Update variables for Mutations (gql-actions requests)
|
|
|
|
go refreshUserSessionVariables(bc)
|
2024-03-18 22:28:11 +08:00
|
|
|
|
|
|
|
// Cancel the Hasura connection context to clean up resources.
|
|
|
|
if bc.HasuraConnection != nil && bc.HasuraConnection.ContextCancelFunc != nil {
|
|
|
|
bc.HasuraConnection.ContextCancelFunc()
|
|
|
|
}
|
|
|
|
|
|
|
|
// Send a reconnection confirmation message
|
|
|
|
go SendUserGraphqlReconnectionForcedEvtMsg(sessionToken)
|
|
|
|
}
|
2024-05-30 04:43:17 +08:00
|
|
|
|
|
|
|
func refreshUserSessionVariables(browserConnection *common.BrowserConnection) error {
|
|
|
|
BrowserConnectionsMutex.RLock()
|
2024-07-11 05:30:01 +08:00
|
|
|
sessionToken := browserConnection.SessionToken
|
2024-05-30 04:43:17 +08:00
|
|
|
browserConnectionId := browserConnection.Id
|
|
|
|
BrowserConnectionsMutex.RUnlock()
|
|
|
|
|
|
|
|
// Check authorization
|
2024-07-11 05:30:01 +08:00
|
|
|
sessionVariables, err := akka_apps.AkkaAppsGetSessionVariablesFrom(browserConnectionId, sessionToken)
|
2024-05-30 04:43:17 +08:00
|
|
|
if err != nil {
|
|
|
|
log.Error(err)
|
|
|
|
return fmt.Errorf("error on checking sessionToken authorization")
|
|
|
|
} else {
|
|
|
|
log.Trace("Session variables obtained successfully")
|
|
|
|
}
|
|
|
|
|
2024-06-29 03:53:11 +08:00
|
|
|
if _, exists := sessionVariables["x-hasura-role"]; !exists {
|
|
|
|
return fmt.Errorf("error on checking sessionToken authorization, X-Hasura-Role is missing")
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, exists := sessionVariables["x-hasura-userid"]; !exists {
|
|
|
|
return fmt.Errorf("error on checking sessionToken authorization, X-Hasura-UserId is missing")
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, exists := sessionVariables["x-hasura-meetingid"]; !exists {
|
|
|
|
return fmt.Errorf("error on checking sessionToken authorization, X-Hasura-MeetingId is missing")
|
|
|
|
}
|
|
|
|
|
2024-05-30 04:43:17 +08:00
|
|
|
BrowserConnectionsMutex.Lock()
|
|
|
|
browserConnection.BBBWebSessionVariables = sessionVariables
|
|
|
|
BrowserConnectionsMutex.Unlock()
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2024-07-06 00:35:08 +08:00
|
|
|
func connectionInitHandler(browserConnection *common.BrowserConnection) error {
|
2024-05-30 04:43:17 +08:00
|
|
|
BrowserConnectionsMutex.RLock()
|
|
|
|
browserConnectionId := browserConnection.Id
|
|
|
|
browserConnectionCookies := browserConnection.BrowserRequestCookies
|
|
|
|
BrowserConnectionsMutex.RUnlock()
|
|
|
|
|
|
|
|
// Intercept the fromBrowserMessage channel to get the sessionToken
|
|
|
|
for {
|
2024-07-06 00:35:08 +08:00
|
|
|
fromBrowserMessage, ok := browserConnection.FromBrowserToHasuraChannel.Receive()
|
2024-05-30 04:43:17 +08:00
|
|
|
if !ok {
|
|
|
|
//Received all messages. Channel is closed
|
|
|
|
return fmt.Errorf("error on receiving init connection")
|
|
|
|
}
|
2024-06-25 21:27:44 +08:00
|
|
|
if bytes.Contains(fromBrowserMessage, []byte("\"connection_init\"")) {
|
|
|
|
var fromBrowserMessageAsMap map[string]interface{}
|
|
|
|
if err := json.Unmarshal(fromBrowserMessage, &fromBrowserMessageAsMap); err != nil {
|
|
|
|
log.Errorf("failed to unmarshal message: %v", err)
|
|
|
|
continue
|
|
|
|
}
|
2024-05-30 04:43:17 +08:00
|
|
|
|
|
|
|
var payloadAsMap = fromBrowserMessageAsMap["payload"].(map[string]interface{})
|
|
|
|
var headersAsMap = payloadAsMap["headers"].(map[string]interface{})
|
|
|
|
var sessionToken, existsSessionToken = headersAsMap["X-Session-Token"].(string)
|
|
|
|
if !existsSessionToken {
|
|
|
|
return fmt.Errorf("X-Session-Token header missing on init connection")
|
|
|
|
}
|
|
|
|
|
|
|
|
var clientSessionUUID, existsClientSessionUUID = headersAsMap["X-ClientSessionUUID"].(string)
|
|
|
|
if !existsClientSessionUUID {
|
|
|
|
return fmt.Errorf("X-ClientSessionUUID header missing on init connection")
|
|
|
|
}
|
|
|
|
|
|
|
|
var clientType, existsClientType = headersAsMap["X-ClientType"].(string)
|
|
|
|
if !existsClientType {
|
|
|
|
return fmt.Errorf("X-ClientType header missing on init connection")
|
|
|
|
}
|
|
|
|
|
|
|
|
var clientIsMobile, existsMobile = headersAsMap["X-ClientIsMobile"].(string)
|
|
|
|
if !existsMobile {
|
|
|
|
return fmt.Errorf("X-ClientIsMobile header missing on init connection")
|
|
|
|
}
|
|
|
|
|
2024-06-29 03:53:11 +08:00
|
|
|
var meetingId, userId string
|
|
|
|
var errCheckAuthorization error
|
|
|
|
|
2024-05-30 04:43:17 +08:00
|
|
|
// Check authorization
|
2024-06-29 03:53:11 +08:00
|
|
|
var numOfAttempts = 0
|
|
|
|
for {
|
|
|
|
meetingId, userId, errCheckAuthorization = bbb_web.BBBWebCheckAuthorization(browserConnectionId, sessionToken, browserConnectionCookies)
|
|
|
|
if errCheckAuthorization != nil {
|
|
|
|
log.Error(errCheckAuthorization)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (errCheckAuthorization == nil && meetingId != "" && userId != "") || numOfAttempts > 5 {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
numOfAttempts++
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
}
|
|
|
|
|
|
|
|
if errCheckAuthorization != nil {
|
|
|
|
return fmt.Errorf("error on trying to check authorization")
|
|
|
|
}
|
|
|
|
|
|
|
|
if meetingId == "" {
|
|
|
|
return fmt.Errorf("error to obtain user meetingId from BBBWebCheckAuthorization")
|
2024-05-30 04:43:17 +08:00
|
|
|
}
|
|
|
|
|
2024-06-29 03:53:11 +08:00
|
|
|
if userId == "" {
|
|
|
|
return fmt.Errorf("error to obtain user userId from BBBWebCheckAuthorization")
|
|
|
|
}
|
|
|
|
|
|
|
|
log.Trace("Success on check authorization")
|
|
|
|
|
2024-05-30 04:43:17 +08:00
|
|
|
log.Debugf("[ConnectionInitHandler] intercepted Session Token %v and Client Session UUID %v", sessionToken, clientSessionUUID)
|
|
|
|
BrowserConnectionsMutex.Lock()
|
|
|
|
browserConnection.SessionToken = sessionToken
|
|
|
|
browserConnection.ClientSessionUUID = clientSessionUUID
|
2024-06-15 00:43:05 +08:00
|
|
|
browserConnection.MeetingId = meetingId
|
|
|
|
browserConnection.UserId = userId
|
2024-06-25 21:27:44 +08:00
|
|
|
browserConnection.ConnectionInitMessage = fromBrowserMessage
|
2024-05-30 04:43:17 +08:00
|
|
|
BrowserConnectionsMutex.Unlock()
|
|
|
|
|
2024-06-29 03:53:11 +08:00
|
|
|
if err := refreshUserSessionVariables(browserConnection); err != nil {
|
|
|
|
return fmt.Errorf("error on getting session variables")
|
|
|
|
}
|
2024-06-15 00:43:05 +08:00
|
|
|
|
2024-05-30 04:43:17 +08:00
|
|
|
go SendUserGraphqlConnectionEstablishedSysMsg(
|
|
|
|
sessionToken,
|
|
|
|
clientSessionUUID,
|
|
|
|
clientType,
|
|
|
|
strings.ToLower(clientIsMobile) == "true",
|
|
|
|
browserConnectionId,
|
|
|
|
)
|
2024-07-06 00:35:08 +08:00
|
|
|
|
2024-05-30 04:43:17 +08:00
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|