package main import ( "archive/zip" "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/json" "encoding/base64" "errors" "fmt" "html" "io" "log" "mime/multipart" "net/http" "net/url" "os" "os/exec" "path/filepath" "strings" "sync" "time" "client/internal/blocker" "client/internal/camera" "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 lastKillDialogTime time.Time var killDialogMu sync.Mutex var API_BASE = getEnv("API_BASE", "https://sv.rikkeiraia.org") func getEnv(key, defaultVal string) string { if val := os.Getenv(key); val != "" { return val } return defaultVal } func getWsUrl(apiBase string) string { if strings.HasPrefix(apiBase, "https://") { return strings.Replace(apiBase, "https://", "wss://", 1) } if strings.HasPrefix(apiBase, "http://") { return strings.Replace(apiBase, "http://", "ws://", 1) } return "ws://" + apiBase } 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 webviewDataPath 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{} isStreamingCam bool streamCamStop chan struct{} statusMsg string expectingLogin bool needsClearPortalStorage bool backendOnline bool dashboard StudentDashboardSnapshot wifiEnforce bool acceptedWifi map[string]bool wifiRejected bool quitDialogShown bool monitoringTornDown bool chatUnread int replyStaffID uint fetchAppsMu sync.Mutex } 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 StudentExamSnapshot struct { ExamRoomID uint `json:"examRoomId"` ExamName string `json:"examName"` QuizURL string `json:"quizUrl"` PaperSent bool `json:"paperSent"` PaperTitle string `json:"paperTitle"` Submitted bool `json:"submitted"` } 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"` Exam *StudentExamSnapshot `json:"exam,omitempty"` } 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") webviewDir := filepath.Join(appDir, "WebView2") _ = os.MkdirAll(appDir, 0755) _ = os.MkdirAll(webviewDir, 0755) return &App{ sessionPath: filepath.Join(appDir, "student_session.json"), statsPath: filepath.Join(appDir, "student_stats.json"), webviewDataPath: webviewDir, lastSyncTime: time.Now(), expectingLogin: false, } } func (a *App) startup(ctx context.Context) { a.ctx = ctx a.loadSession() a.loadStats() // Request Location Access (macOS) winapi.RequestLocationAccess() winapi.RequestCameraAndMicAccess() winapi.RequestScreenCaptureAccess() // Log initial permission statuses log.Printf("[PERMISSIONS] Camera Status: %d (0=NotDetermined, 1=Restricted, 2=Denied, 3=Authorized)", winapi.GetCameraPermission()) log.Printf("[PERMISSIONS] Microphone Status: %d (0=NotDetermined, 1=Restricted, 2=Denied, 3=Authorized)", winapi.GetMicrophonePermission()) log.Printf("[PERMISSIONS] Screen Capture Status: %d (0=NoAccess, 1=Authorized, -1=NotSupported)", winapi.GetScreenCapturePermission()) // Register blocker callbacks blocker.Instance.OnBlocked = func(procName string, title string) { go a.reportBlockedApp(procName, title) } blocker.Instance.OnKill = func(procName string, title string) { killDialogMu.Lock() if time.Since(lastKillDialogTime) < 5*time.Second { killDialogMu.Unlock() return } lastKillDialogTime = time.Now() killDialogMu.Unlock() winapi.ShowWarningMessageBox( "Ứng dụng bị đóng", 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() a.stopWebcamStream() 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() // 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.refreshStudentData() // Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets guard.SuppressFor(5 * time.Second) runtime.WindowReloadApp(a.ctx) w.Write([]byte("ok")) } else { w.WriteHeader(http.StatusBadRequest) } }) server := &http.Server{ Addr: "127.0.0.1:34115", Handler: mux, } mux.HandleFunc("/exam-view", func(w http.ResponseWriter, r *http.Request) { target := strings.TrimSpace(r.URL.Query().Get("url")) title := strings.TrimSpace(r.URL.Query().Get("title")) if target == "" { http.Error(w, "missing url", http.StatusBadRequest) return } if title == "" { title = "Xem trong Simple Care" } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = fmt.Fprintf(w, examViewHTML, html.EscapeString(title), html.EscapeString(title), html.EscapeString(target)) }) mux.HandleFunc("/exam-close", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") if r.Method == "OPTIONS" { w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") return } go func() { guard.SuppressFor(5 * time.Second) runtime.WindowReloadApp(a.ctx) }() w.Write([]byte("ok")) }) go func() { _ = server.ListenAndServe() }() log.Println("[LOCALSERVER] Callback server started on http://127.0.0.1:34115") } const examViewHTML = ` %s
%s
` // 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) go a.refreshStudentData() go a.fetchWifiPolicy() 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(API_BASE+"/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() guard.SuppressFor(5 * time.Second) 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 = true a.needsClearPortalStorage = true 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() guard.SuppressFor(5 * time.Second) runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'") } // 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, "exam": a.dashboard.Exam, } } // 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 needsClear := a.needsClearPortalStorage loggedIn := a.student != nil a.mu.Unlock() if needsClear && expecting { runtime.WindowExecJS(a.ctx, ` try { localStorage.removeItem("student"); localStorage.removeItem("token"); } catch(e) {} `) a.mu.Lock() a.needsClearPortalStorage = false a.mu.Unlock() continue } if loggedIn || !expecting { continue } runtime.WindowExecJS(a.ctx, ` try { const st = localStorage.getItem("student"); if (st && !window.__redirecting) { window.__redirecting = true; window.location.href = "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() { for { a.runMonitorTick() a.mu.Lock() wifiEmpty := a.wifiSSID == "" || strings.Contains(a.wifiSSID, "Chưa cấp quyền") || strings.Contains(a.wifiSSID, "redacted") expecting := a.expectingLogin a.mu.Unlock() sleepInterval := 10 * time.Second if wifiEmpty && !expecting { sleepInterval = 2 * time.Second } time.Sleep(sleepInterval) } } func (a *App) runMonitorTick() { if !a.isMonitoringActive() || !a.CheckLoginStatus() { return } wifi := winapi.GetWifiConnection() a.mu.Lock() if strings.Contains(strings.ToLower(wifi.SSID), "redacted") { a.wifiSSID = "Chưa cấp quyền Vị Trí (macOS)" } else { a.wifiSSID = wifi.SSID } if strings.Contains(strings.ToLower(wifi.BSSID), "redacted") { a.wifiBSSID = "Chưa cấp quyền Vị Trí (macOS)" } else { 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) resolveClassRkID() int64 { a.mu.Lock() defer a.mu.Unlock() if a.dashboard.ClassRkID > 0 { return a.dashboard.ClassRkID } return 0 } func (a *App) refreshStudentData() { a.mu.Lock() student := a.student a.mu.Unlock() if student == nil { return } a.fetchStudentStatus(student.StudentID) classID := a.resolveClassRkID() if classID > 0 { a.fetchAllowedApps(classID) } else { a.fetchAllowedApps(0) } a.refreshNetworkStatus() } // 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() if strings.Contains(strings.ToLower(wifi.SSID), "redacted") { a.wifiSSID = "Chưa cấp quyền Vị Trí (macOS)" } else { a.wifiSSID = wifi.SSID } if strings.Contains(strings.ToLower(wifi.BSSID), "redacted") { a.wifiBSSID = "Chưa cấp quyền Vị Trí (macOS)" } else { a.wifiBSSID = wifi.BSSID } a.backendOnline = backendOnline a.mu.Unlock() if backendOnline { a.connectWS() } } func (a *App) pingBackend() bool { client := http.Client{Timeout: 3 * time.Second} resp, err := client.Get(API_BASE + "/api/health") 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 func() { a.fetchStudentStatus(student.StudentID) classID := a.resolveClassRkID() if classID <= 0 { classID = student.SystemID } a.fetchAllowedApps(classID) }() payload := map[string]any{ "studentRkId": student.StudentID, "classRkId": a.resolveClassRkID(), "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(API_BASE+"/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(API_BASE+"/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(API_BASE+"/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(API_BASE + "/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 } // Bypass Wi-Fi check if it's redacted by macOS privacy controls if strings.Contains(strings.ToLower(ssid), "redacted") || strings.Contains(strings.ToLower(bssid), "redacted") { 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.fetchAppsMu.Lock() defer a.fetchAppsMu.Unlock() a.mu.Lock() studentID := int64(0) if a.student != nil { studentID = a.student.StudentID } a.mu.Unlock() url := fmt.Sprintf("%s/api/classes/%d/allowed-apps?studentId=%d", API_BASE, classId, studentID) client := http.Client{Timeout: 4 * time.Second} resp, err := client.Get(url) 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 { a.mu.Lock() stillExam := a.dashboard.MonitorMode == "exam" a.mu.Unlock() if stillExam { return } 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() log.Printf("[CLIENT] Allowed apps fetched from server: %s", res.Keywords) 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("%s/api/student/status?studentRkId=%d", API_BASE, 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() prevMode := a.dashboard.MonitorMode 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) } else if res.MonitorMode == "exam" { a.statusMsg = res.MonitorLabel } a.mu.Unlock() if prevMode != res.MonitorMode { guard.SuppressFor(4 * time.Second) } } func (a *App) connectWS() { if !a.CheckLoginStatus() { return } a.mu.Lock() if a.wsConnected || a.student == nil { a.mu.Unlock() return } student := a.student classID := a.dashboard.ClassRkID if classID <= 0 { classID = student.SystemID } a.mu.Unlock() wsUrl := fmt.Sprintf("%s/ws?role=student&studentId=%d&classId=%d", getWsUrl(API_BASE), student.StudentID, classID) 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": a.startWebcamStream() case "stop_webcam_stream": a.stopWebcamStream() case "chat:message": senderRole, _ := msg.Data["senderRole"].(string) if senderRole == "staff" { from := "Giảng viên" if n, ok := msg.Data["staffName"].(string); ok && n != "" { from = n } preview := "" if b, ok := msg.Data["body"].(string); ok { preview = b } if staffVal, ok := msg.Data["staffId"].(float64); ok { a.mu.Lock() a.replyStaffID = uint(staffVal) a.chatUnread++ a.mu.Unlock() } a.alertChatIncoming(from, preview) } runtime.EventsEmit(a.ctx, "chat:message", msg.Data) case "exam:paper-sent": runtime.EventsEmit(a.ctx, "exam:paper-sent", msg.Data) a.mu.Lock() if a.dashboard.Exam != nil { a.dashboard.Exam.PaperSent = true if t, ok := msg.Data["paperTitle"].(string); ok { a.dashboard.Exam.PaperTitle = t } } a.mu.Unlock() } } }() } 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.") } func (a *App) startWebcamStream() { a.mu.Lock() if a.isStreamingCam { a.mu.Unlock() return } a.isStreamingCam = true a.streamCamStop = make(chan struct{}) a.mu.Unlock() if err := camera.StartCapture(); err != nil { log.Printf("[WS] Failed to start native camera: %v", err) a.mu.Lock() a.isStreamingCam = false a.mu.Unlock() return } go func() { ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() for { select { case <-ticker.C: frame := camera.GetFrame() if frame == "" { continue } 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": frame, }, }) } case <-a.streamCamStop: return } } }() log.Println("[WS] Webcam native streaming started.") } func (a *App) stopWebcamStream() { a.mu.Lock() defer a.mu.Unlock() if !a.isStreamingCam { return } a.isStreamingCam = false close(a.streamCamStop) camera.StopCapture() log.Println("[WS] Webcam native streaming stopped.") } func (a *App) alertChatIncoming(from, preview string) { if preview == "" { preview = "Bạn có tin nhắn mới" } if len(preview) > 120 { preview = preview[:120] + "…" } winapi.PlayNotifySound() winapi.ActivateAppWindow("Simple Care") runtime.WindowUnminimise(a.ctx) runtime.WindowShow(a.ctx) a.mu.Lock() unread := a.chatUnread staffID := a.replyStaffID a.mu.Unlock() runtime.EventsEmit(a.ctx, "chat:notify", map[string]any{ "unread": unread, "preview": preview, "from": from, "staffId": staffID, }) } // UnlockChatAudio — gọi từ UI sau lần click đầu để mở khóa Web Audio (dự phòng) func (a *App) UnlockChatAudio() {} func (a *App) GetChatUnread() int { a.mu.Lock() defer a.mu.Unlock() return a.chatUnread } func (a *App) ClearChatUnread() { a.mu.Lock() a.chatUnread = 0 a.mu.Unlock() } func (a *App) GetChatConversations() ([]map[string]any, error) { a.mu.Lock() student := a.student a.mu.Unlock() if student == nil { return nil, errors.New("chưa đăng nhập") } url := fmt.Sprintf("%s/api/student/chat/conversations?studentRkId=%d", API_BASE, student.StudentID) resp, err := http.Get(url) if err != nil { return nil, err } defer resp.Body.Close() var payload struct { Data []map[string]any `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return nil, err } return payload.Data, nil } func (a *App) GetChatMessages(staffID uint) ([]map[string]any, error) { a.mu.Lock() student := a.student a.mu.Unlock() if student == nil { return nil, errors.New("chưa đăng nhập") } url := fmt.Sprintf("%s/api/student/chat/messages?studentRkId=%d&staffId=%d", API_BASE, student.StudentID, staffID) resp, err := http.Get(url) if err != nil { return nil, err } defer resp.Body.Close() var payload struct { Data []map[string]any `json:"data"` ReplyStaffID uint `json:"replyStaffId"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return nil, err } if staffID > 0 { a.mu.Lock() a.replyStaffID = staffID a.mu.Unlock() } else if payload.ReplyStaffID > 0 { a.mu.Lock() a.replyStaffID = payload.ReplyStaffID a.mu.Unlock() } a.ClearChatUnread() return payload.Data, nil } func (a *App) SendChatMessage(body string) error { body = strings.TrimSpace(body) if body == "" { return errors.New("tin nhắn trống") } a.mu.Lock() student := a.student staffID := a.replyStaffID a.mu.Unlock() if student == nil { return errors.New("chưa đăng nhập") } payload := map[string]any{ "studentRkId": student.StudentID, "staffId": staffID, "body": body, } b, _ := json.Marshal(payload) resp, err := http.Post(API_BASE+"/api/student/chat/messages", "application/json", bytes.NewReader(b)) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { var errBody map[string]any _ = json.NewDecoder(resp.Body).Decode(&errBody) if msg, ok := errBody["error"].(string); ok { return errors.New(msg) } return fmt.Errorf("gửi tin thất bại (%d)", resp.StatusCode) } return nil } func (a *App) examStudentContext() (int64, *StudentExamSnapshot, error) { a.mu.Lock() defer a.mu.Unlock() if a.student == nil { return 0, nil, errors.New("chưa đăng nhập") } if a.dashboard.Exam == nil { return 0, nil, errors.New("không trong giờ thi") } return a.student.StudentID, a.dashboard.Exam, nil } // ReturnToDashboard quay lại giao diện chính của app (giữ cookie WebView2). func (a *App) ReturnToDashboard() { guard.SuppressFor(5 * time.Second) runtime.WindowReloadApp(a.ctx) } func isLocalExamURL(raw string) bool { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u.Host == "" { return false } host := strings.ToLower(u.Hostname()) return host == "127.0.0.1" || host == "localhost" } func (a *App) openExamWebView(targetURL, title string) error { targetURL = strings.TrimSpace(targetURL) if targetURL == "" { return errors.New("không có nội dung để mở") } if isLocalExamURL(targetURL) { wrapper := fmt.Sprintf( "http://127.0.0.1:34115/exam-view?url=%s&title=%s", url.QueryEscape(targetURL), url.QueryEscape(title), ) guard.SuppressFor(5 * time.Second) runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper)) return nil } // Trang thi bên ngoài: mở trực tiếp trong WebView (first-party cookies → giữ phiên đăng nhập). guard.SuppressFor(5 * time.Second) runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL)) return nil } func (a *App) GetExamPaperViewURL() (string, error) { studentID, exam, err := a.examStudentContext() if err != nil { return "", err } if !exam.PaperSent { return "", errors.New("Giảng viên chưa gửi đề") } return fmt.Sprintf("%s/api/student/exam/download?studentRkId=%d&kind=pdf&view=1", API_BASE, studentID), nil } func (a *App) GetExamQuizViewURL() (string, error) { _, exam, err := a.examStudentContext() if err != nil { return "", err } quizURL := strings.TrimSpace(exam.QuizURL) if quizURL == "" { return "", errors.New("Phòng thi chưa cấu hình link trắc nghiệm") } return quizURL, nil } func (a *App) GetExamPaperFiles() (map[string]any, error) { studentID, exam, err := a.examStudentContext() if err != nil { return nil, err } if !exam.PaperSent { return nil, errors.New("Giảng viên chưa gửi đề") } apiURL := fmt.Sprintf("%s/api/student/exam/paper-files?studentRkId=%d", API_BASE, studentID) resp, err := http.Get(apiURL) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, errors.New("không tải được danh sách tài nguyên") } var payload map[string]any if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return nil, err } base := API_BASE if pdf, ok := payload["pdfUrl"].(string); ok && pdf != "" && !strings.HasPrefix(pdf, "http") { payload["pdfUrl"] = base + pdf } if raw, ok := payload["resources"].([]any); ok { for i, item := range raw { m, ok := item.(map[string]any) if !ok { continue } if u, ok := m["url"].(string); ok && u != "" && !strings.HasPrefix(u, "http") { m["url"] = base + u } if u, ok := m["downloadUrl"].(string); ok && u != "" && !strings.HasPrefix(u, "http") { m["downloadUrl"] = base + u } raw[i] = m } payload["resources"] = raw } return payload, nil } func (a *App) OpenExamPaper() error { viewURL, err := a.GetExamPaperViewURL() if err != nil { return err } title := "Đề thi" a.mu.Lock() if a.dashboard.Exam != nil && strings.TrimSpace(a.dashboard.Exam.PaperTitle) != "" { title = strings.TrimSpace(a.dashboard.Exam.PaperTitle) } a.mu.Unlock() return a.openExamWebView(viewURL, title) } func (a *App) OpenExamQuiz() error { viewURL, err := a.GetExamQuizViewURL() if err != nil { return err } return a.openExamWebView(viewURL, "Làm trắc nghiệm") } func (a *App) OpenExamResource(fileID uint) error { studentID, _, err := a.examStudentContext() if err != nil { return err } if fileID == 0 { return errors.New("tài nguyên không hợp lệ") } viewURL := fmt.Sprintf( "%s/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d&view=1", API_BASE, studentID, fileID, ) title := "Tài nguyên đề thi" files, err := a.GetExamPaperFiles() if err == nil { if raw, ok := files["resources"].([]any); ok { for _, item := range raw { m, ok := item.(map[string]any) if !ok { continue } id, _ := m["id"].(float64) if uint(id) == fileID { if name, ok := m["fileName"].(string); ok && name != "" { title = name } break } } } } return a.openExamWebView(viewURL, title) } func examResourceDownloadDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } candidates := []string{ filepath.Join(home, "Downloads"), filepath.Join(home, "Desktop"), home, } for _, dir := range candidates { if st, err := os.Stat(dir); err == nil && st.IsDir() { return dir, nil } } return "", errors.New("không tìm thấy thư mục Downloads hoặc Desktop") } func uniqueFilePath(path string) string { if _, err := os.Stat(path); os.IsNotExist(err) { return path } ext := filepath.Ext(path) base := strings.TrimSuffix(filepath.Base(path), ext) dir := filepath.Dir(path) for i := 1; i < 100; i++ { candidate := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext)) if _, err := os.Stat(candidate); os.IsNotExist(err) { return candidate } } return filepath.Join(dir, fmt.Sprintf("%s_%d%s", base, time.Now().Unix(), ext)) } func openFolderInExplorer(filePath string) { guard.SuppressFor(5 * time.Second) dir := filepath.Dir(filePath) _ = exec.Command("explorer", dir).Start() } func (a *App) DownloadExamResource(fileID uint) (string, error) { studentID, _, err := a.examStudentContext() if err != nil { return "", err } if fileID == 0 { return "", errors.New("tài nguyên không hợp lệ") } fileName := fmt.Sprintf("resource_%d", fileID) files, err := a.GetExamPaperFiles() if err == nil { if raw, ok := files["resources"].([]any); ok { for _, item := range raw { m, ok := item.(map[string]any) if !ok { continue } id, _ := m["id"].(float64) if uint(id) == fileID { if name, ok := m["fileName"].(string); ok && name != "" { fileName = filepath.Base(name) } break } } } } dlURL := fmt.Sprintf( "%s/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d", API_BASE, studentID, fileID, ) resp, err := http.Get(dlURL) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", errors.New("không tải được tài nguyên") } dir, err := examResourceDownloadDir() if err != nil { return "", err } dest := uniqueFilePath(filepath.Join(dir, fileName)) f, err := os.Create(dest) if err != nil { return "", err } if _, err := io.Copy(f, resp.Body); err != nil { f.Close() return "", err } f.Close() openFolderInExplorer(dest) return dest, nil } // LoadExamViewFile tải file đề/tài nguyên qua Go (không mở URL trực tiếp trong WebView). func (a *App) LoadExamViewFile(fileURL string) (map[string]any, error) { fileURL = strings.TrimSpace(fileURL) if !strings.HasPrefix(fileURL, API_BASE + "/api/student/exam/download") { return nil, errors.New("URL không hợp lệ") } if _, _, err := a.examStudentContext(); err != nil { return nil, err } resp, err := http.Get(fileURL) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, errors.New("không tải được file") } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } mime := strings.TrimSpace(resp.Header.Get("Content-Type")) if mime == "" { mime = "application/octet-stream" } // Bỏ charset nếu có if i := strings.Index(mime, ";"); i >= 0 { mime = strings.TrimSpace(mime[:i]) } return map[string]any{ "data": base64.StdEncoding.EncodeToString(body), "mime": mime, }, nil } func (a *App) SubmitExamWork() (string, error) { a.mu.Lock() student := a.student exam := a.dashboard.Exam a.mu.Unlock() if student == nil { return "", errors.New("chưa đăng nhập") } if exam == nil { return "", errors.New("không trong giờ thi") } dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ Title: "Chọn folder bài làm để nộp", }) if err != nil { return "", err } if dir == "" { return "", errors.New("đã hủy") } zipPath := filepath.Join(os.TempDir(), fmt.Sprintf("exam_submit_%d_%d.zip", exam.ExamRoomID, time.Now().Unix())) if err := zipFolder(dir, zipPath); err != nil { return "", err } defer os.Remove(zipPath) f, err := os.Open(zipPath) if err != nil { return "", err } defer f.Close() var body bytes.Buffer w := multipart.NewWriter(&body) _ = w.WriteField("studentRkId", fmt.Sprintf("%d", student.StudentID)) part, err := w.CreateFormFile("submission", filepath.Base(zipPath)) if err != nil { return "", err } if _, err := io.Copy(part, f); err != nil { return "", err } _ = w.Close() req, err := http.NewRequest("POST", API_BASE + "/api/student/exam/submit", &body) if err != nil { return "", err } req.Header.Set("Content-Type", w.FormDataContentType()) resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() var res struct { OK bool `json:"ok"` FileName string `json:"fileName"` Error string `json:"error"` } _ = json.NewDecoder(resp.Body).Decode(&res) if resp.StatusCode >= 300 || !res.OK { if res.Error != "" { return "", errors.New(res.Error) } return "", errors.New("nộp bài thất bại") } a.mu.Lock() if a.dashboard.Exam != nil { a.dashboard.Exam.Submitted = true } a.mu.Unlock() return res.FileName, nil } func zipFolder(srcDir, destZip string) error { out, err := os.Create(destZip) if err != nil { return err } defer out.Close() zw := zip.NewWriter(out) defer zw.Close() return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return err } rel, err := filepath.Rel(srcDir, path) if err != nil { return err } rel = filepath.ToSlash(rel) hdr, err := zip.FileInfoHeader(info) if err != nil { return err } hdr.Name = rel hdr.Method = zip.Deflate w, err := zw.CreateHeader(hdr) if err != nil { return err } rf, err := os.Open(path) if err != nil { return err } _, copyErr := io.Copy(w, rf) rf.Close() return copyErr }) }