package main import ( "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "sync" "time" "client/internal/blocker" "client/internal/guard" "client/internal/screen" "client/internal/winapi" "github.com/gorilla/websocket" "github.com/wailsapp/wails/v2/pkg/runtime" ) var blockedReportLast sync.Map // processName -> time.Time var wifiReportLast sync.Map // ssidKey -> time.Time var encryptionKey = []byte("simple-care-proctor-secretkey32!") // 32-byte key for AES-256 func encrypt(data []byte) ([]byte, error) { block, err := aes.NewCipher(encryptionKey) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } ciphertext := gcm.Seal(nonce, nonce, data, nil) return ciphertext, nil } func decrypt(data []byte) ([]byte, error) { block, err := aes.NewCipher(encryptionKey) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonceSize := gcm.NonceSize() if len(data) < nonceSize { return nil, errors.New("ciphertext too short") } nonce, ciphertext := data[:nonceSize], data[nonceSize:] return gcm.Open(nil, nonce, ciphertext, nil) } type StudentData struct { StudentID int64 `json:"studentId"` FullName string `json:"fullName"` Email string `json:"email"` Avatar string `json:"avatar"` Dob string `json:"dob"` StudentCode string `json:"studentCode"` Phone string `json:"phone"` SystemID int64 `json:"systemId"` } type App struct { ctx context.Context student *StudentData mu sync.Mutex sessionPath string statsPath string wsConn *websocket.Conn wsConnected bool wifiSSID string wifiBSSID string allowedApps string onlineSecs int offlineSecs int unsyncedOn int unsyncedOff int lastSyncTime time.Time isStreamingSc bool streamScStop chan struct{} statusMsg string expectingLogin bool backendOnline bool dashboard StudentDashboardSnapshot wifiEnforce bool acceptedWifi map[string]bool wifiRejected bool quitDialogShown bool monitoringTornDown bool } type LocalStats struct { OnlineSecs int `json:"onlineSecs"` OfflineSecs int `json:"offlineSecs"` UnsyncedOn int `json:"unsyncedOn"` UnsyncedOff int `json:"unsyncedOff"` } type StudentShiftSnapshot struct { Period int `json:"period"` CourseName string `json:"courseName"` StartTime string `json:"startTime"` EndTime string `json:"endTime"` IsActiveNow bool `json:"isActiveNow"` OnlineSeconds int `json:"onlineSeconds"` OfflineSeconds int `json:"offlineSeconds"` AttendanceStatus int `json:"attendanceStatus"` AttendanceLabel string `json:"attendanceLabel"` } type StudentDashboardSnapshot struct { SessionDate string `json:"sessionDate"` MonitorMode string `json:"monitorMode"` MonitorLabel string `json:"monitorLabel"` ClassRkID int64 `json:"classRkId"` ClassName string `json:"className"` ClassCode string `json:"classCode"` CurrentPeriod int `json:"currentPeriod"` CurrentCourseName string `json:"currentCourseName"` CurrentShiftStart string `json:"currentShiftStart"` CurrentShiftEnd string `json:"currentShiftEnd"` InScheduleNow bool `json:"inScheduleNow"` BlockerActive bool `json:"blockerActive"` Shifts []StudentShiftSnapshot `json:"shifts"` } func NewApp() *App { configDir, err := os.UserConfigDir() if err != nil { // Fallback to executable folder if UserConfigDir is unavailable exePath, _ := os.Executable() configDir = filepath.Dir(exePath) } appDir := filepath.Join(configDir, "SimpleCare") _ = os.MkdirAll(appDir, 0755) return &App{ sessionPath: filepath.Join(appDir, "student_session.json"), statsPath: filepath.Join(appDir, "student_stats.json"), lastSyncTime: time.Now(), expectingLogin: false, } } func (a *App) startup(ctx context.Context) { a.ctx = ctx a.loadSession() a.loadStats() // Register blocker callbacks blocker.Instance.OnBlocked = func(procName string, title string) { go a.reportBlockedApp(procName, title) } blocker.Instance.OnKill = func(procName string, title string) { _, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{ Type: runtime.WarningDialog, Title: "Ứng dụng bị đóng", Message: fmt.Sprintf("Hệ thống đã tự động đóng ứng dụng '%s' (%s) do không nằm trong danh sách các ứng dụng được phép sử dụng trong giờ học.", procName, title), }) } // Giám sát môi trường Windows: đa màn hình, desktop ảo, đổi user guard.Start(a.handleGuardViolation) // Khởi chạy HTTP server nhận callback login thành công a.startLocalServer() // Khởi chạy vòng lặp giám sát định kỳ (mỗi 10 giây) go a.monitorLoop() // Khởi chạy vòng lặp đọc local storage của trang Rikkei Portal khi chưa đăng nhập go a.authStorageScanner() } func (a *App) handleGuardViolation(reason string) { a.showQuitDialog("Vi phạm giám sát", reason) } // tearDownBeforeQuit ngắt mọi kênh giám sát ngay — trước khi hiện dialog (tránh treo OK để duy trì kết nối). func (a *App) tearDownBeforeQuit() { a.mu.Lock() if a.monitoringTornDown { a.mu.Unlock() return } a.monitoringTornDown = true a.mu.Unlock() blocker.Instance.Stop() a.stopScreenshotStream() runtime.EventsEmit(a.ctx, "stop_webcam_stream") a.disconnectWS() guard.Stop() } func (a *App) isMonitoringActive() bool { a.mu.Lock() defer a.mu.Unlock() return !a.monitoringTornDown } // showQuitDialog — ngắt giám sát trước, hiện cảnh báo, sinh viên bấm OK rồi app mới thoát. func (a *App) showQuitDialog(title, message string) { a.mu.Lock() if a.quitDialogShown { a.mu.Unlock() return } a.quitDialogShown = true a.mu.Unlock() a.tearDownBeforeQuit() _, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{ Type: runtime.WarningDialog, Title: title, Message: message + "\n\nNhấn OK để đóng ứng dụng.", }) runtime.Quit(a.ctx) } // startLocalServer khởi chạy server lắng nghe callback nhận thông tin sinh viên từ webview func (a *App) startLocalServer() { mux := http.NewServeMux() mux.HandleFunc("/login-success", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") if r.Method == "OPTIONS" { return } data := r.URL.Query().Get("data") if data == "" { w.WriteHeader(http.StatusBadRequest) return } a.mu.Lock() expecting := a.expectingLogin a.mu.Unlock() if !expecting { w.WriteHeader(http.StatusOK) w.Write([]byte("ignored")) return } var st StudentData if err := json.Unmarshal([]byte(data), &st); err == nil && st.StudentID > 0 { a.saveSession(&st) log.Printf("[LOCALSERVER] Student %s logged in successfully.", st.FullName) a.mu.Lock() a.expectingLogin = false a.mu.Unlock() blocker.Instance.Start() // Kiểm tra điều kiện app được phép và giờ học trước khi tải giao diện go a.fetchAllowedApps(st.SystemID) go a.fetchStudentStatus(st.StudentID) go a.fetchWifiPolicy() go a.refreshNetworkStatus() // Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets runtime.WindowReloadApp(a.ctx) w.Write([]byte("ok")) } else { w.WriteHeader(http.StatusBadRequest) } }) server := &http.Server{ Addr: "127.0.0.1:34115", Handler: mux, } go func() { _ = server.ListenAndServe() }() log.Println("[LOCALSERVER] Callback server started on http://127.0.0.1:34115") } // loadSession nạp session từ file cục bộ func (a *App) loadSession() { a.mu.Lock() defer a.mu.Unlock() data, err := os.ReadFile(a.sessionPath) if err == nil { decrypted, errDec := decrypt(data) if errDec == nil { data = decrypted } var s StudentData if err := json.Unmarshal(data, &s); err == nil { a.student = &s log.Printf("[APP] Loaded local session: %s (%s)", s.FullName, s.StudentCode) blocker.Instance.Start() go a.fetchAllowedApps(s.SystemID) go a.fetchStudentStatus(s.StudentID) go a.fetchWifiPolicy() go a.refreshNetworkStatus() go a.syncProfileToServer(&s) } } } // saveSession lưu session ra file func (a *App) saveSession(s *StudentData) { a.mu.Lock() defer a.mu.Unlock() a.student = s data, _ := json.MarshalIndent(s, "", " ") encrypted, err := encrypt(data) if err == nil { _ = os.WriteFile(a.sessionPath, encrypted, 0644) } else { _ = os.WriteFile(a.sessionPath, data, 0644) } go a.syncProfileToServer(s) } func (a *App) syncProfileToServer(s *StudentData) { if s == nil || s.Avatar == "" { return } payload := map[string]any{ "studentRkId": s.StudentID, "classRkId": s.SystemID, "sessionDate": time.Now().Format("2006-01-02"), "avatar": s.Avatar, } bodyBytes, _ := json.Marshal(payload) client := http.Client{Timeout: 5 * time.Second} resp, err := client.Post("http://127.0.0.1:8080/api/student/sync-log", "application/json", bytes.NewBuffer(bodyBytes)) if err != nil { return } _ = resp.Body.Close() } // loadStats nạp stats func (a *App) loadStats() { a.mu.Lock() defer a.mu.Unlock() data, err := os.ReadFile(a.statsPath) if err == nil { var ls LocalStats if err := json.Unmarshal(data, &ls); err == nil { a.onlineSecs = ls.OnlineSecs a.offlineSecs = ls.OfflineSecs a.unsyncedOn = ls.UnsyncedOn a.unsyncedOff = ls.UnsyncedOff } } } // saveStats lưu stats ra file func (a *App) saveStats() { a.mu.Lock() defer a.mu.Unlock() ls := LocalStats{ OnlineSecs: a.onlineSecs, OfflineSecs: a.offlineSecs, UnsyncedOn: a.unsyncedOn, UnsyncedOff: a.unsyncedOff, } data, _ := json.Marshal(ls) _ = os.WriteFile(a.statsPath, data, 0644) } // CheckLoginStatus kiểm tra xem sinh viên đã đăng nhập chưa func (a *App) CheckLoginStatus() bool { a.mu.Lock() defer a.mu.Unlock() return a.student != nil } // GetStudentInfo trả về thông tin sinh viên func (a *App) GetStudentInfo() map[string]any { a.mu.Lock() defer a.mu.Unlock() if a.student == nil { return nil } return map[string]any{ "studentId": a.student.StudentID, "fullName": a.student.FullName, "email": a.student.Email, "avatar": a.student.Avatar, "dob": a.student.Dob, "studentCode": a.student.StudentCode, "phone": a.student.Phone, "systemId": a.student.SystemID, } } // NavigateToLogin chuyển WebView đến trang đăng nhập Rikkei Portal func (a *App) NavigateToLogin() { a.mu.Lock() a.expectingLogin = true a.mu.Unlock() runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'") } // Logout đăng xuất sinh viên func (a *App) Logout() { a.mu.Lock() a.student = nil a.expectingLogin = false a.mu.Unlock() _ = os.Remove(a.sessionPath) _ = os.Remove(a.statsPath) a.mu.Lock() a.onlineSecs = 0 a.offlineSecs = 0 a.unsyncedOn = 0 a.unsyncedOff = 0 a.mu.Unlock() blocker.Instance.Stop() a.disconnectWS() a.stopScreenshotStream() runtime.WindowReloadApp(a.ctx) } // GetStats trả về Wifi, thời gian online/offline và trạng thái giám sát func (a *App) GetStats() map[string]any { a.mu.Lock() defer a.mu.Unlock() shifts := make([]StudentShiftSnapshot, len(a.dashboard.Shifts)) copy(shifts, a.dashboard.Shifts) for i := range shifts { if shifts[i].IsActiveNow { shifts[i].OnlineSeconds += a.unsyncedOn shifts[i].OfflineSeconds += a.unsyncedOff } } return map[string]any{ "wifiSSID": a.wifiSSID, "onlineSecs": a.onlineSecs, "offlineSecs": a.offlineSecs, "wsConnected": a.wsConnected, "serverReachable": a.backendOnline, "allowedApps": a.allowedApps, "statusMsg": a.statusMsg, "sessionDate": a.dashboard.SessionDate, "monitorMode": a.dashboard.MonitorMode, "monitorLabel": a.dashboard.MonitorLabel, "className": a.dashboard.ClassName, "classCode": a.dashboard.ClassCode, "currentPeriod": a.dashboard.CurrentPeriod, "currentCourseName": a.dashboard.CurrentCourseName, "currentShiftStart": a.dashboard.CurrentShiftStart, "currentShiftEnd": a.dashboard.CurrentShiftEnd, "inScheduleNow": a.dashboard.InScheduleNow, "blockerActive": a.dashboard.BlockerActive, "shifts": shifts, } } // SendWebcamFrame truyền webcam frame từ JS frontend lên máy chủ qua WS func (a *App) SendWebcamFrame(frameBase64 string) { if !a.isMonitoringActive() { return } a.mu.Lock() conn := a.wsConn a.mu.Unlock() if conn != nil { _ = conn.WriteJSON(map[string]any{ "event": "webcam_stream_frame", "data": map[string]any{ "imageBuffer": frameBase64, }, }) } } // authStorageScanner chỉ quét localStorage khi người dùng chủ động mở trang đăng nhập func (a *App) authStorageScanner() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { <-ticker.C a.mu.Lock() expecting := a.expectingLogin loggedIn := a.student != nil a.mu.Unlock() if loggedIn || !expecting { continue } runtime.WindowExecJS(a.ctx, ` try { const st = localStorage.getItem("student"); if (st) { fetch("http://127.0.0.1:34115/login-success?data=" + encodeURIComponent(st)); } } catch(e) {} `) } } // monitorLoop định kỳ 10s: check wifi, ping backend, cập nhật online/offline seconds và đồng bộ lên server func (a *App) monitorLoop() { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() a.runMonitorTick() for { <-ticker.C a.runMonitorTick() } } func (a *App) runMonitorTick() { if !a.isMonitoringActive() || !a.CheckLoginStatus() { return } wifi := winapi.GetWifiConnection() a.mu.Lock() a.wifiSSID = wifi.SSID a.wifiBSSID = wifi.BSSID a.mu.Unlock() backendOnline := a.pingBackend() if backendOnline && wifi.SSID != "" && wifi.BSSID != "" { go a.reportWifi(wifi.SSID, wifi.BSSID) a.fetchWifiPolicy() if !a.isWifiAllowed(wifi.SSID, wifi.BSSID) { a.rejectUnauthorizedWifi(wifi.SSID, wifi.BSSID) return } } a.mu.Lock() a.backendOnline = backendOnline if backendOnline { a.onlineSecs += 10 a.unsyncedOn += 10 } else { a.offlineSecs += 10 a.unsyncedOff += 10 } a.mu.Unlock() a.saveStats() if backendOnline { a.connectWS() a.syncLogsToServer() } else { a.disconnectWS() } } // refreshNetworkStatus — đọc WiFi + ping server ngay (không cộng thời gian online/offline). func (a *App) refreshNetworkStatus() { if !a.isMonitoringActive() || !a.CheckLoginStatus() { return } wifi := winapi.GetWifiConnection() backendOnline := a.pingBackend() a.mu.Lock() a.wifiSSID = wifi.SSID a.wifiBSSID = wifi.BSSID a.backendOnline = backendOnline a.mu.Unlock() } func (a *App) pingBackend() bool { client := http.Client{Timeout: 3 * time.Second} resp, err := client.Get("http://127.0.0.1:8080/api/stats") if err != nil { return false } _ = resp.Body.Close() return resp.StatusCode == http.StatusOK } func (a *App) syncLogsToServer() { if !a.isMonitoringActive() { return } a.mu.Lock() student := a.student unsyncedOn := a.unsyncedOn unsyncedOff := a.unsyncedOff ssid := a.wifiSSID a.mu.Unlock() if student == nil { return } go a.fetchStudentStatus(student.StudentID) go a.fetchAllowedApps(student.SystemID) payload := map[string]any{ "studentRkId": student.StudentID, "classRkId": student.SystemID, "sessionDate": time.Now().Format("2006-01-02"), "addOnlineSeconds": unsyncedOn, "addOfflineSeconds": unsyncedOff, "wifiSsid": ssid, "avatar": student.Avatar, } bodyBytes, _ := json.Marshal(payload) client := http.Client{Timeout: 5 * time.Second} resp, err := client.Post("http://127.0.0.1:8080/api/student/sync-log", "application/json", bytes.NewBuffer(bodyBytes)) if err != nil { return } defer resp.Body.Close() if resp.StatusCode == http.StatusOK { a.mu.Lock() a.unsyncedOn -= unsyncedOn a.unsyncedOff -= unsyncedOff a.mu.Unlock() a.saveStats() log.Printf("[SYNC] Synced %ds online, %ds offline to server.", unsyncedOn, unsyncedOff) } } func (a *App) reportBlockedApp(procName string, title string) { if !a.isMonitoringActive() { return } a.mu.Lock() student := a.student a.mu.Unlock() if student == nil || student.StudentID <= 0 { return } procKey := strings.ToLower(strings.TrimSpace(procName)) if procKey == "" { return } if v, ok := blockedReportLast.Load(procKey); ok { if time.Since(v.(time.Time)) < 20*time.Second { return } } blockedReportLast.Store(procKey, time.Now()) payload := map[string]any{ "studentRkId": student.StudentID, "processName": procName, "windowTitle": title, } bodyBytes, _ := json.Marshal(payload) client := http.Client{Timeout: 4 * time.Second} resp, err := client.Post("http://127.0.0.1:8080/api/student/report-blocked-app", "application/json", bytes.NewBuffer(bodyBytes)) if err != nil { log.Printf("[CLIENT] report-blocked-app failed: %v", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) log.Printf("[CLIENT] report-blocked-app status %d: %s", resp.StatusCode, string(body)) return } log.Printf("[CLIENT] report-blocked-app ok: %s", procName) } func (a *App) reportWifi(ssid, bssid string) { if !a.isMonitoringActive() { return } a.mu.Lock() student := a.student a.mu.Unlock() if student == nil || strings.TrimSpace(ssid) == "" || strings.TrimSpace(bssid) == "" { return } key := winapi.BSSIDKey(bssid) if len(key) != 12 { return } if v, ok := wifiReportLast.Load(key); ok { if time.Since(v.(time.Time)) < 30*time.Second { return } } wifiReportLast.Store(key, time.Now()) payload := map[string]any{ "studentRkId": student.StudentID, "ssid": ssid, "bssid": bssid, } bodyBytes, _ := json.Marshal(payload) client := http.Client{Timeout: 4 * time.Second} resp, err := client.Post("http://127.0.0.1:8080/api/student/report-wifi", "application/json", bytes.NewBuffer(bodyBytes)) if err != nil { return } _ = resp.Body.Close() } func (a *App) fetchWifiPolicy() { client := http.Client{Timeout: 4 * time.Second} resp, err := client.Get("http://127.0.0.1:8080/api/student/wifi-policy") if err != nil { return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return } var res struct { Enforce bool `json:"enforce"` Accepted []struct { SSID string `json:"ssid"` BSSID string `json:"bssid"` } `json:"accepted"` } if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { return } allowed := make(map[string]bool, len(res.Accepted)) for _, item := range res.Accepted { k := winapi.BSSIDKey(item.BSSID) if len(k) == 12 { allowed[k] = true } } a.mu.Lock() a.wifiEnforce = res.Enforce a.acceptedWifi = allowed a.mu.Unlock() } func (a *App) isWifiAllowed(ssid, bssid string) bool { a.mu.Lock() enforce := a.wifiEnforce allowed := a.acceptedWifi a.mu.Unlock() if !enforce || len(allowed) == 0 { return true } bKey := winapi.BSSIDKey(bssid) if bKey == "" || len(bKey) != 12 { return false } return allowed[bKey] } func (a *App) rejectUnauthorizedWifi(ssid, bssid string) { a.mu.Lock() if a.wifiRejected { a.mu.Unlock() return } a.wifiRejected = true a.mu.Unlock() a.showQuitDialog( "WiFi không được phép", fmt.Sprintf("Điểm phát WiFi \"%s\" (%s) không được phép.\n\nCó thể là mạng giả mạo (hotspot trùng tên). Hãy kết nối đúng WiFi của trường rồi mở lại ứng dụng.", ssid, bssid), ) } func (a *App) fetchAllowedApps(classId int64) { a.mu.Lock() studentID := int64(0) if a.student != nil { studentID = a.student.StudentID } a.mu.Unlock() client := http.Client{Timeout: 4 * time.Second} resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:8080/api/classes/%d/allowed-apps?studentId=%d", classId, studentID)) if err != nil { return } defer resp.Body.Close() if resp.StatusCode == http.StatusOK { var res struct { Keywords string `json:"keywords"` Exit bool `json:"exit"` Reason string `json:"reason"` } if err := json.NewDecoder(resp.Body).Decode(&res); err == nil { if res.Exit { reason := res.Reason if reason == "" { reason = "Không đáp ứng điều kiện học tập (chưa cấu hình ứng dụng được phép hoặc ngoài giờ học)." } log.Printf("[CLIENT] Conditions not met: %s. Blocker suspended.", reason) a.mu.Lock() a.allowedApps = "" a.statusMsg = reason a.mu.Unlock() blocker.Instance.Stop() return } a.mu.Lock() a.allowedApps = res.Keywords if a.dashboard.MonitorLabel != "" { a.statusMsg = a.dashboard.MonitorLabel } else { a.statusMsg = "Đang trong giờ học" } a.mu.Unlock() blocker.Instance.SetKeywords(res.Keywords) blocker.Instance.Start() } } } func (a *App) fetchStudentStatus(studentID int64) { if studentID <= 0 { return } client := http.Client{Timeout: 4 * time.Second} resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:8080/api/student/status?studentRkId=%d", studentID)) if err != nil { return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return } var res StudentDashboardSnapshot if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { return } a.mu.Lock() a.dashboard = res if res.MonitorLabel != "" { a.statusMsg = res.MonitorLabel } if res.ClassName != "" && res.MonitorMode == "learning" { a.statusMsg = fmt.Sprintf("%s · %s", res.ClassName, res.MonitorLabel) } else if res.ClassName != "" && res.MonitorMode == "outside_schedule" { a.statusMsg = fmt.Sprintf("%s · Ngoài giờ học", res.ClassName) } else if res.ClassName != "" && res.MonitorMode == "not_configured" { a.statusMsg = fmt.Sprintf("%s · %s", res.ClassName, res.MonitorLabel) } a.mu.Unlock() } func (a *App) connectWS() { if !a.isMonitoringActive() { return } a.mu.Lock() if a.wsConnected || a.student == nil { a.mu.Unlock() return } student := a.student a.mu.Unlock() wsUrl := fmt.Sprintf("ws://127.0.0.1:8080/ws?role=student&studentId=%d&classId=%d", student.StudentID, student.SystemID) dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second} conn, _, err := dialer.Dial(wsUrl, nil) if err != nil { return } a.mu.Lock() a.wsConn = conn a.wsConnected = true a.mu.Unlock() log.Println("[WS] Connected to proctor websocket hub.") go func() { defer a.disconnectWS() for { var msg struct { Event string `json:"event"` Data map[string]any `json:"data"` } err := conn.ReadJSON(&msg) if err != nil { break } switch msg.Event { case "start_screenshot_stream": a.startScreenshotStream() case "stop_screenshot_stream": a.stopScreenshotStream() case "start_webcam_stream": runtime.EventsEmit(a.ctx, "start_webcam_stream") case "stop_webcam_stream": runtime.EventsEmit(a.ctx, "stop_webcam_stream") } } }() } func (a *App) disconnectWS() { a.mu.Lock() defer a.mu.Unlock() if a.wsConn != nil { _ = a.wsConn.Close() a.wsConn = nil } a.wsConnected = false } func (a *App) startScreenshotStream() { a.mu.Lock() if a.isStreamingSc { a.mu.Unlock() return } a.isStreamingSc = true a.streamScStop = make(chan struct{}) a.mu.Unlock() go func() { ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() for { select { case <-ticker.C: frame, err := screen.CaptureScreen() if err != nil { continue } a.mu.Lock() conn := a.wsConn a.mu.Unlock() if conn != nil { _ = conn.WriteJSON(map[string]any{ "event": "screenshot_stream_frame", "data": map[string]any{ "imageBuffer": frame, }, }) } case <-a.streamScStop: return } } }() log.Println("[WS] Screenshot screen-streaming started.") } func (a *App) stopScreenshotStream() { a.mu.Lock() defer a.mu.Unlock() if !a.isStreamingSc { return } a.isStreamingSc = false close(a.streamScStop) log.Println("[WS] Screenshot screen-streaming stopped.") }