package main import ( "archive/zip" "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/json" "encoding/base64" "errors" "fmt" "html" "io" "log" "mime/multipart" "net" "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 func getDynamicEncryptionKey() []byte { base := "simple-care-proctor-secretkey32!" hostname, _ := os.Hostname() home, _ := os.UserHomeDir() combined := fmt.Sprintf("%s-%s-%s", base, hostname, home) hasher := sha256.New() hasher.Write([]byte(combined)) return hasher.Sum(nil) } func encrypt(data []byte) ([]byte, error) { key := getDynamicEncryptionKey() block, err := aes.NewCipher(key) 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) { key := getDynamicEncryptionKey() block, err := aes.NewCipher(key) 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 pendingViolPath string runLockPath string wsConn *websocket.Conn wsConnected bool wsConnecting bool wsBackoff time.Duration wsWriteMu sync.Mutex wifiSSID string wifiBSSID string allowedApps string onlineSecs int offlineSecs int unsyncedOn int unsyncedOff int lastSyncTime time.Time isStreamingSc bool scIntervalMs int streamScStop chan struct{} isStreamingCam bool camIntervalMs int 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 localBrowserActive bool // trình duyệt local — chỉ cho phép localhost 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, pendingViolPath: filepath.Join(appDir, "pending_violations.json"), runLockPath: filepath.Join(appDir, "monitoring.lock"), lastSyncTime: time.Now(), expectingLogin: false, } } func (a *App) forceLightTheme() { if a.ctx == nil { return } runtime.WindowSetLightTheme(a.ctx) } func (a *App) startup(ctx context.Context) { a.ctx = ctx a.forceLightTheme() a.loadSession() a.loadStats() // Task Manager / kill process lần trước → còn file lock → báo unclean_shutdown a.detectUncleanShutdown() go a.flushPendingViolations() // 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() // Chặn thoát localhost khi đang dùng trình duyệt local go a.localBrowserGuardLoop() } func (a *App) handleGuardViolation(kind, reason string) { if kind == "" { kind = "guard" } a.reportViolation(kind, reason) a.showQuitDialog("Vi phạm giám sát", reason) } // HandleBeforeClose — SV bấm X / Alt+F4 khi đang giám sát = vi phạm. // Trả về false để cho phép đóng sau khi đã báo cáo. func (a *App) HandleBeforeClose() (prevent bool) { if !a.CheckLoginStatus() || !a.isMonitoringActive() { a.clearRunLock() return false } a.mu.Lock() mode := a.dashboard.MonitorMode a.mu.Unlock() if !shouldRecordViolation(mode) { a.clearRunLock() return false } a.reportViolation("app_closed", "Sinh viên tự đóng ứng dụng khi đang giám sát ("+mode+")") a.tearDownBeforeQuit() a.clearRunLock() return false } // 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() a.clearRunLock() } 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) } type pendingViolation struct { Kind string `json:"kind"` Reason string `json:"reason"` MonitorMode string `json:"monitorMode"` ClientAt string `json:"clientAt"` StudentRkID int64 `json:"studentRkId"` } // shouldRecordViolation — chỉ lưu vi phạm khi đang thi hoặc đang học (trong giờ). func shouldRecordViolation(monitorMode string) bool { switch strings.TrimSpace(monitorMode) { case "exam", "learning": return true default: return false } } func (a *App) markRunLock() { a.mu.Lock() studentID := int64(0) mode := a.dashboard.MonitorMode if a.student != nil { studentID = a.student.StudentID } a.mu.Unlock() if studentID <= 0 || !shouldRecordViolation(mode) { return } payload, _ := json.Marshal(map[string]any{ "studentRkId": studentID, "monitorMode": mode, "startedAt": time.Now().Format(time.RFC3339), }) _ = os.WriteFile(a.runLockPath, payload, 0644) } func (a *App) clearRunLock() { _ = os.Remove(a.runLockPath) } func (a *App) detectUncleanShutdown() { data, err := os.ReadFile(a.runLockPath) if err != nil { return } _ = os.Remove(a.runLockPath) var meta struct { StudentRkID int64 `json:"studentRkId"` MonitorMode string `json:"monitorMode"` StartedAt string `json:"startedAt"` } _ = json.Unmarshal(data, &meta) if meta.StudentRkID <= 0 { a.mu.Lock() if a.student != nil { meta.StudentRkID = a.student.StudentID } a.mu.Unlock() } if meta.StudentRkID <= 0 || !shouldRecordViolation(meta.MonitorMode) { return } reason := "Ứng dụng bị tắt đột ngột (Task Manager / kill process) khi đang giám sát" if meta.MonitorMode != "" { reason += " (" + meta.MonitorMode + ")" } a.enqueuePendingViolation(pendingViolation{ Kind: "unclean_shutdown", Reason: reason, MonitorMode: meta.MonitorMode, ClientAt: time.Now().Format(time.RFC3339), StudentRkID: meta.StudentRkID, }) } func (a *App) enqueuePendingViolation(v pendingViolation) { list := a.loadPendingViolations() list = append(list, v) raw, _ := json.Marshal(list) _ = os.WriteFile(a.pendingViolPath, raw, 0644) } func (a *App) loadPendingViolations() []pendingViolation { data, err := os.ReadFile(a.pendingViolPath) if err != nil { return nil } var list []pendingViolation if json.Unmarshal(data, &list) != nil { return nil } return list } func (a *App) flushPendingViolations() { list := a.loadPendingViolations() if len(list) == 0 { return } remaining := make([]pendingViolation, 0) for _, v := range list { if !a.postViolation(v) { remaining = append(remaining, v) } } if len(remaining) == 0 { _ = os.Remove(a.pendingViolPath) return } raw, _ := json.Marshal(remaining) _ = os.WriteFile(a.pendingViolPath, raw, 0644) } func (a *App) postViolation(v pendingViolation) bool { if v.StudentRkID <= 0 || !shouldRecordViolation(v.MonitorMode) { return true // bỏ qua — không thi / không học thì không lưu } payload := map[string]any{ "studentRkId": v.StudentRkID, "kind": v.Kind, "reason": v.Reason, "monitorMode": v.MonitorMode, "clientAt": v.ClientAt, } bodyBytes, _ := json.Marshal(payload) client := http.Client{Timeout: 5 * time.Second} resp, err := client.Post(API_BASE+"/api/student/report-violation", "application/json", bytes.NewBuffer(bodyBytes)) if err != nil { log.Printf("[VIOLATION] report failed: %v", err) return false } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) log.Printf("[VIOLATION] report status %d: %s", resp.StatusCode, string(body)) return false } log.Printf("[VIOLATION] reported kind=%s student=%d", v.Kind, v.StudentRkID) return true } func (a *App) reportViolation(kind, reason string) { a.mu.Lock() studentID := int64(0) mode := a.dashboard.MonitorMode if a.student != nil { studentID = a.student.StudentID } a.mu.Unlock() if studentID <= 0 || !shouldRecordViolation(mode) { return } v := pendingViolation{ Kind: kind, Reason: reason, MonitorMode: mode, ClientAt: time.Now().Format(time.RFC3339), StudentRkID: studentID, } if !a.postViolation(v) { a.enqueuePendingViolation(v) } } // 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 } a.setLocalBrowserActive(false) go func() { guard.SuppressFor(5 * time.Second) runtime.WindowReloadApp(a.ctx) }() w.Write([]byte("ok")) }) mux.HandleFunc("/exam-clear-cache", 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, POST, OPTIONS") return } // Chỉ xóa HTTP disk cache — không đụng cookie / localStorage (giữ phiên đăng nhập trang thi). go a.purgeWebViewDiskCache() w.Write([]byte("ok")) }) mux.HandleFunc("/local-browser", func(w http.ResponseWriter, r *http.Request) { blocked := strings.TrimSpace(r.URL.Query().Get("blocked")) from := strings.TrimSpace(r.URL.Query().Get("from")) w.Header().Set("Content-Type", "text/html; charset=utf-8") notice := "" if blocked == "1" { notice = `
Đã chặn liên kết ngoài mạng. Trình duyệt này chỉ mở localhost / 127.0.0.1.
` if from != "" { notice += `
Đã chặn: ` + html.EscapeString(from) + `
` } } _, _ = fmt.Fprintf(w, localBrowserHTML, notice) }) mux.HandleFunc("/local-browser-go", 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, POST, OPTIONS") return } raw := strings.TrimSpace(r.URL.Query().Get("url")) if raw == "" { http.Error(w, "missing url", http.StatusBadRequest) return } if !strings.Contains(raw, "://") { raw = "http://" + raw } if !isLoopbackURL(raw) { http.Error(w, "only localhost allowed", http.StatusForbidden) return } a.mu.Lock() a.localBrowserActive = true a.mu.Unlock() a.forceLightTheme() go func() { guard.SuppressFor(3 * time.Second) runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", raw)) }() 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 Trang trắng/lỗi: F5 hoặc Xóa cache (giữ đăng nhập)
` const localBrowserHTML = ` Trình duyệt Local — Simple Care
%s

Trình duyệt Local

Chỉ dùng để xem bài làm chạy trên máy bạn (localhost / 127.0.0.1). Mọi link online (GitHub, Google, …) đều bị chặn tại đây.

Mẹo: chạy dự án (npm run dev / Live Server), dán URL vào ô trên rồi bấm Mở. Về trang chính: nút ← hoặc menu Simple Care → Ctrl+H.

` // 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.setLocalBrowserActive(false) 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() a.stopWebcamStream() 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, } } // safeWriteWS writes a message to the WebSocket connection thread-safely func (a *App) safeWriteWS(msg any) error { a.mu.Lock() conn := a.wsConn a.mu.Unlock() if conn == nil { return errors.New("websocket connection is nil") } a.wsWriteMu.Lock() defer a.wsWriteMu.Unlock() return conn.WriteJSON(msg) } // 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.safeWriteWS(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.reportViolation("wifi", fmt.Sprintf("WiFi không được phép: %s (%s)", ssid, bssid)) 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.wsConnecting || a.student == nil { a.mu.Unlock() return } a.wsConnecting = true student := a.student classID := a.dashboard.ClassRkID if classID <= 0 { classID = student.SystemID } backoff := a.wsBackoff if backoff <= 0 { backoff = 500 * time.Millisecond } 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 { a.mu.Lock() a.wsConnecting = false delay := backoff next := delay * 2 if next > 10*time.Second { next = 10 * time.Second } a.wsBackoff = next a.mu.Unlock() log.Printf("[WS] Dial failed: %v — retry in %v", err, delay) time.AfterFunc(delay, func() { if a.isMonitoringActive() && a.CheckLoginStatus() { a.connectWS() } }) return } _ = conn.SetReadDeadline(time.Now().Add(45 * time.Second)) conn.SetPongHandler(func(string) error { return conn.SetReadDeadline(time.Now().Add(45 * time.Second)) }) a.mu.Lock() a.wsConn = conn a.wsConnected = true a.wsConnecting = false a.wsBackoff = 500 * time.Millisecond a.mu.Unlock() log.Println("[WS] Connected to proctor websocket hub.") a.markRunLock() pingDone := make(chan struct{}) go func() { ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() for { select { case <-pingDone: return case <-ticker.C: a.wsWriteMu.Lock() err := conn.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(5*time.Second)) a.wsWriteMu.Unlock() if err != nil { return } // App-level ping (một số proxy không forward WS control frames đúng). _ = a.safeWriteWS(map[string]any{"event": "client:ping", "data": map[string]any{}}) } } }() go func() { defer close(pingDone) defer func() { a.disconnectWS(conn) a.mu.Lock() delay := a.wsBackoff if delay <= 0 { delay = 500 * time.Millisecond } next := delay * 2 if next > 10*time.Second { next = 10 * time.Second } a.wsBackoff = next a.mu.Unlock() log.Printf("[WS] Disconnected — reconnect in %v", delay) time.AfterFunc(delay, func() { if a.isMonitoringActive() && a.CheckLoginStatus() { a.connectWS() } }) }() for { var msg struct { Event string `json:"event"` Data map[string]any `json:"data"` } err := conn.ReadJSON(&msg) if err != nil { log.Printf("[WS] ReadJSON error: %v", err) break } _ = conn.SetReadDeadline(time.Now().Add(45 * time.Second)) if msg.Event == "client:pong" { continue } log.Printf("[WS] Received event: %s", msg.Event) switch msg.Event { case "start_screenshot_stream": var intervalMs int if val, ok := msg.Data["interval"]; ok { if f, ok := val.(float64); ok { intervalMs = int(f) } } a.startScreenshotStream(intervalMs) case "stop_screenshot_stream": a.stopScreenshotStream() case "start_webcam_stream": var intervalMs int if val, ok := msg.Data["interval"]; ok { if f, ok := val.(float64); ok { intervalMs = int(f) } } a.startWebcamStream(intervalMs) 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(expectedConn ...*websocket.Conn) { var shouldStopStreams bool a.mu.Lock() if len(expectedConn) > 0 && expectedConn[0] != nil { if a.wsConn != expectedConn[0] { a.mu.Unlock() return } } if a.wsConn != nil { _ = a.wsConn.Close() a.wsConn = nil } a.wsConnected = false a.wsConnecting = false shouldStopStreams = a.isStreamingSc || a.isStreamingCam a.mu.Unlock() if shouldStopStreams { a.stopScreenshotStream() a.stopWebcamStream() } } func (a *App) startScreenshotStream(intervalMs int) { if intervalMs <= 0 { intervalMs = 3000 } a.mu.Lock() if a.isStreamingSc { if a.scIntervalMs != intervalMs { a.mu.Unlock() a.stopScreenshotStream() a.mu.Lock() } else { a.mu.Unlock() return } } a.isStreamingSc = true a.scIntervalMs = intervalMs a.streamScStop = make(chan struct{}) a.mu.Unlock() go func() { ticker := time.NewTicker(time.Duration(intervalMs) * time.Millisecond) defer ticker.Stop() for { select { case <-ticker.C: frame, err := screen.CaptureScreen() if err != nil { continue } _ = a.safeWriteWS(map[string]any{ "event": "screenshot_stream_frame", "data": map[string]any{ "imageBuffer": frame, }, }) case <-a.streamScStop: return } } }() log.Printf("[WS] Screenshot screen-streaming started with interval %dms.", intervalMs) } func (a *App) stopScreenshotStream() { a.mu.Lock() defer a.mu.Unlock() if !a.isStreamingSc { return } a.isStreamingSc = false a.scIntervalMs = 0 close(a.streamScStop) log.Println("[WS] Screenshot screen-streaming stopped.") } func (a *App) startWebcamStream(intervalMs int) { if intervalMs <= 0 { intervalMs = 3000 } a.mu.Lock() if a.isStreamingCam { if a.camIntervalMs != intervalMs { a.mu.Unlock() a.stopWebcamStream() a.mu.Lock() } else { a.mu.Unlock() return } } a.isStreamingCam = true a.camIntervalMs = intervalMs a.streamCamStop = make(chan struct{}) a.mu.Unlock() if !camera.IsNative { log.Printf("[WS] Platform is non-darwin, delegating webcam stream to frontend events with interval %dms", intervalMs) runtime.EventsEmit(a.ctx, "start_webcam_stream", intervalMs) return } 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(time.Duration(intervalMs) * time.Millisecond) defer ticker.Stop() emptyCount := 0 sentCount := 0 for { select { case <-ticker.C: frame := camera.GetFrame() if frame == "" { emptyCount++ if emptyCount <= 20 || emptyCount%100 == 0 { log.Printf("[WS] Webcam: no frame available (empty count: %d)", emptyCount) } continue } emptyCount = 0 sentCount++ err := a.safeWriteWS(map[string]any{ "event": "webcam_stream_frame", "data": map[string]any{ "imageBuffer": frame, }, }) if sentCount <= 5 { log.Printf("[WS] Webcam frame #%d sent (len=%d, err=%v)", sentCount, len(frame), err) } case <-a.streamCamStop: return } } }() log.Printf("[WS] Webcam native streaming started with interval %dms.", intervalMs) } func (a *App) stopWebcamStream() { a.mu.Lock() defer a.mu.Unlock() if !a.isStreamingCam { return } a.isStreamingCam = false a.camIntervalMs = 0 if a.streamCamStop != nil { close(a.streamCamStop) } if !camera.IsNative { log.Println("[WS] Platform is non-darwin, stopping frontend webcam stream via event") runtime.EventsEmit(a.ctx, "stop_webcam_stream") return } 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() { a.setLocalBrowserActive(false) a.forceLightTheme() guard.SuppressFor(5 * time.Second) runtime.WindowReloadApp(a.ctx) } func (a *App) setLocalBrowserActive(on bool) { a.mu.Lock() a.localBrowserActive = on a.mu.Unlock() } func isLoopbackURL(raw string) bool { raw = strings.TrimSpace(raw) if raw == "" { return false } if !strings.Contains(raw, "://") { raw = "http://" + raw } u, err := url.Parse(raw) if err != nil || u.Host == "" { return false } scheme := strings.ToLower(u.Scheme) if scheme != "http" && scheme != "https" { return false } host := strings.ToLower(u.Hostname()) if host == "localhost" { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } func (a *App) localBrowserGuardLoop() { ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for range ticker.C { a.mu.Lock() active := a.localBrowserActive && a.ctx != nil && !a.monitoringTornDown ctx := a.ctx a.mu.Unlock() if !active { continue } // Nếu hostname không phải loopback → đá về trang local-browser runtime.WindowExecJS(ctx, ` (function(){ try { var h = (location.hostname || '').toLowerCase(); var ok = h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(h); if (!ok) { location.href = 'http://127.0.0.1:34115/local-browser?blocked=1&from=' + encodeURIComponent(location.href); } } catch (e) {} })(); `) } } // OpenGitHub mở github.com trong WebView (về trang chính: Ctrl+H). func (a *App) OpenGitHub() { a.setLocalBrowserActive(false) guard.SuppressFor(5 * time.Second) runtime.WindowExecJS(a.ctx, `window.location.href = 'https://github.com'`) } // OpenGoogleTranslate mở Google Dịch trong WebView. func (a *App) OpenGoogleTranslate() { a.setLocalBrowserActive(false) guard.SuppressFor(5 * time.Second) runtime.WindowExecJS(a.ctx, `window.location.href = 'https://translate.google.com/?sl=auto&tl=vi'`) } // OpenLocalBrowser mở trình duyệt chỉ cho phép localhost (test bài làm local). func (a *App) OpenLocalBrowser() { a.setLocalBrowserActive(true) a.forceLightTheme() guard.SuppressFor(5 * time.Second) runtime.WindowExecJS(a.ctx, `window.location.href = 'http://127.0.0.1:34115/local-browser'`) } // ReloadExamPage — F5: làm mới trang / iframe đang mở (trắc nghiệm). func (a *App) ReloadExamPage() { guard.SuppressFor(3 * time.Second) runtime.WindowExecJS(a.ctx, ` (function(){ try { if (typeof window.refreshExam === 'function') { window.refreshExam(); return; } var f = document.getElementById('exam-frame'); if (f) { try { f.contentWindow.location.reload(); } catch (e) { var s = f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50); } return; } } catch (e) {} location.reload(); })(); `) } // ClearExamBrowserCache — chỉ xóa HTTP disk cache, giữ cookie/localStorage (không văng đăng nhập trang thi). func (a *App) ClearExamBrowserCache() { guard.SuppressFor(8 * time.Second) a.purgeWebViewDiskCache() runtime.WindowExecJS(a.ctx, ` (function(){ try { if (typeof window.reloadFrameSoft === 'function') { window.reloadFrameSoft(); return; } var f = document.getElementById('exam-frame'); if (f) { try { f.contentWindow.location.reload(); } catch (e) { var s = f.getAttribute('src') || f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50); } return; } } catch (e) {} location.reload(); })(); `) } func (a *App) purgeWebViewDiskCache() { if a.webviewDataPath == "" { return } // Chỉ Cache tải tài nguyên — KHÔNG xóa Cookies / Local Storage / Service Worker (giữ phiên đăng nhập). subs := []string{ filepath.Join("EBWebView", "Default", "Cache"), filepath.Join("EBWebView", "Default", "Code Cache"), filepath.Join("EBWebView", "Default", "GPUCache"), } for _, sub := range subs { path := filepath.Join(a.webviewDataPath, sub) if err := os.RemoveAll(path); err != nil { log.Printf("[CACHE] purge %s: %v", path, err) } else { log.Printf("[CACHE] purged %s", path) } } } 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ở") } a.setLocalBrowserActive(false) a.forceLightTheme() // URL local (PDF/resource): khung exam-view. URL ngoài (quiz): mở top-level giữ first-party cookies/session. 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 } 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 } // Mở trực tiếp (không iframe) để cookie/localStorage đăng nhập web quiz là first-party. // F5 / Xóa cache / Về trang chính: menu Simple Care. 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 }) }