diff --git a/client/app.go b/client/app.go index 00b7589..49e6d82 100644 --- a/client/app.go +++ b/client/app.go @@ -19,6 +19,7 @@ import ( "time" "client/internal/blocker" + "client/internal/guard" "client/internal/screen" "client/internal/winapi" @@ -100,7 +101,9 @@ type App struct { dashboard StudentDashboardSnapshot wifiEnforce bool acceptedWifi map[string]bool - wifiRejected bool + wifiRejected bool + quitDialogShown bool + monitoringTornDown bool } type LocalStats struct { @@ -173,6 +176,9 @@ func (a *App) startup(ctx context.Context) { }) } + // 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() @@ -183,6 +189,53 @@ func (a *App) startup(ctx context.Context) { go a.authStorageScanner() } +func (a *App) handleGuardViolation(reason string) { + a.showQuitDialog("Vi phạm giám sát", reason) +} + +// tearDownBeforeQuit ngắt mọi kênh giám sát ngay — trước khi hiện dialog (tránh treo OK để duy trì kết nối). +func (a *App) tearDownBeforeQuit() { + a.mu.Lock() + if a.monitoringTornDown { + a.mu.Unlock() + return + } + a.monitoringTornDown = true + a.mu.Unlock() + + blocker.Instance.Stop() + a.stopScreenshotStream() + runtime.EventsEmit(a.ctx, "stop_webcam_stream") + a.disconnectWS() + guard.Stop() +} + +func (a *App) isMonitoringActive() bool { + a.mu.Lock() + defer a.mu.Unlock() + return !a.monitoringTornDown +} + +// showQuitDialog — ngắt giám sát trước, hiện cảnh báo, sinh viên bấm OK rồi app mới thoát. +func (a *App) showQuitDialog(title, message string) { + a.mu.Lock() + if a.quitDialogShown { + a.mu.Unlock() + return + } + a.quitDialogShown = true + a.mu.Unlock() + + a.tearDownBeforeQuit() + + _, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{ + Type: runtime.WarningDialog, + Title: title, + Message: message + "\n\nNhấn OK để đóng ứng dụng.", + }) + runtime.Quit(a.ctx) +} + // startLocalServer khởi chạy server lắng nghe callback nhận thông tin sinh viên từ webview func (a *App) startLocalServer() { mux := http.NewServeMux() @@ -224,6 +277,7 @@ func (a *App) startLocalServer() { go a.fetchAllowedApps(st.SystemID) go a.fetchStudentStatus(st.StudentID) go a.fetchWifiPolicy() + go a.refreshNetworkStatus() // Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets runtime.WindowReloadApp(a.ctx) @@ -264,6 +318,7 @@ func (a *App) loadSession() { go a.fetchAllowedApps(s.SystemID) go a.fetchStudentStatus(s.StudentID) go a.fetchWifiPolicy() + go a.refreshNetworkStatus() go a.syncProfileToServer(&s) } } @@ -433,6 +488,9 @@ func (a *App) GetStats() map[string]any { // 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() @@ -480,50 +538,69 @@ func (a *App) monitorLoop() { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() + a.runMonitorTick() + for { <-ticker.C + a.runMonitorTick() + } +} - if !a.CheckLoginStatus() { - continue - } +func (a *App) runMonitorTick() { + if !a.isMonitoringActive() || !a.CheckLoginStatus() { + return + } - wifi := winapi.GetWifiConnection() - a.mu.Lock() - a.wifiSSID = wifi.SSID - a.wifiBSSID = wifi.BSSID - a.mu.Unlock() + wifi := winapi.GetWifiConnection() + a.mu.Lock() + a.wifiSSID = wifi.SSID + a.wifiBSSID = wifi.BSSID + a.mu.Unlock() - backendOnline := a.pingBackend() + 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) - continue - } - } - - 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() + if backendOnline && wifi.SSID != "" && wifi.BSSID != "" { + go a.reportWifi(wifi.SSID, wifi.BSSID) + a.fetchWifiPolicy() + if !a.isWifiAllowed(wifi.SSID, wifi.BSSID) { + a.rejectUnauthorizedWifi(wifi.SSID, wifi.BSSID) + return } } + + a.mu.Lock() + a.backendOnline = backendOnline + if backendOnline { + a.onlineSecs += 10 + a.unsyncedOn += 10 + } else { + a.offlineSecs += 10 + a.unsyncedOff += 10 + } + a.mu.Unlock() + + a.saveStats() + + if backendOnline { + a.connectWS() + a.syncLogsToServer() + } else { + a.disconnectWS() + } +} + +// refreshNetworkStatus — đọc WiFi + ping server ngay (không cộng thời gian online/offline). +func (a *App) refreshNetworkStatus() { + if !a.isMonitoringActive() || !a.CheckLoginStatus() { + return + } + wifi := winapi.GetWifiConnection() + backendOnline := a.pingBackend() + a.mu.Lock() + a.wifiSSID = wifi.SSID + a.wifiBSSID = wifi.BSSID + a.backendOnline = backendOnline + a.mu.Unlock() } func (a *App) pingBackend() bool { @@ -537,6 +614,9 @@ func (a *App) pingBackend() bool { } func (a *App) syncLogsToServer() { + if !a.isMonitoringActive() { + return + } a.mu.Lock() student := a.student unsyncedOn := a.unsyncedOn @@ -580,6 +660,9 @@ func (a *App) syncLogsToServer() { } func (a *App) reportBlockedApp(procName string, title string) { + if !a.isMonitoringActive() { + return + } a.mu.Lock() student := a.student a.mu.Unlock() @@ -620,6 +703,9 @@ func (a *App) reportBlockedApp(procName string, title string) { } func (a *App) reportWifi(ssid, bssid string) { + if !a.isMonitoringActive() { + return + } a.mu.Lock() student := a.student a.mu.Unlock() @@ -709,15 +795,10 @@ func (a *App) rejectUnauthorizedWifi(ssid, bssid string) { a.wifiRejected = true a.mu.Unlock() - blocker.Instance.Stop() - a.disconnectWS() - - _, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{ - Type: runtime.WarningDialog, - Title: "WiFi không được phép", - Message: 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), - }) - runtime.Quit(a.ctx) + 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) { @@ -807,6 +888,9 @@ func (a *App) fetchStudentStatus(studentID int64) { } func (a *App) connectWS() { + if !a.isMonitoringActive() { + return + } a.mu.Lock() if a.wsConnected || a.student == nil { a.mu.Unlock() diff --git a/client/build/appicon.png b/client/build/appicon.png index 63617fe..026b8f0 100644 Binary files a/client/build/appicon.png and b/client/build/appicon.png differ diff --git a/client/frontend/index.html b/client/frontend/index.html index 3addd1f..fa9205e 100644 --- a/client/frontend/index.html +++ b/client/frontend/index.html @@ -3,6 +3,7 @@ + Simple Care — Rikkei Education diff --git a/client/frontend/src/app.css b/client/frontend/src/app.css index 04a7db6..40cf21d 100644 --- a/client/frontend/src/app.css +++ b/client/frontend/src/app.css @@ -93,6 +93,20 @@ body { margin-bottom: 1.5rem; } +.brand-logo-img { + width: 42px; + height: 42px; + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); +} + +.login-brand-row .brand-logo-img { + width: 48px; + height: 48px; +} + .login-logo { width: 48px; height: 48px; @@ -413,24 +427,61 @@ body { justify-content: space-between; align-items: baseline; gap: 0.5rem; - margin-bottom: 0.65rem; + margin-bottom: 0.4rem; + flex-shrink: 0; } .card-title-sub { - font-size: 0.75rem; + font-size: 0.72rem; color: var(--text-muted); font-family: monospace; } .clock-display-summary { - margin-bottom: 0.75rem; - padding-bottom: 0.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-bottom: 0.45rem; + padding-bottom: 0.45rem; border-bottom: 1px dashed var(--border-color); + flex: 0 0 auto; +} + +.clock-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.5rem; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-subtle); + font-size: 0.72rem; +} + +.clock-chip em { + font-style: normal; + color: var(--text-muted); + font-weight: 600; +} + +.clock-chip strong { + font-family: 'Share Tech Mono', monospace; + font-size: 0.78rem; + font-weight: 700; +} + +.clock-chip--online strong { + color: var(--online-color); +} + +.clock-chip--offline strong { + color: var(--offline-color); } .shifts-wrap { + flex: 1; + min-height: 0; overflow: auto; - max-height: min(32vh, 220px); } .shifts-table { @@ -571,10 +622,11 @@ body { .clocks-card { display: flex; flex-direction: column; - gap: 0.65rem; + gap: 0.4rem; flex: 1; min-height: 0; overflow: hidden; + padding: 0.75rem 0.85rem; } .card-title { diff --git a/client/frontend/src/assets/logo.jpeg b/client/frontend/src/assets/logo.jpeg new file mode 100644 index 0000000..026b8f0 Binary files /dev/null and b/client/frontend/src/assets/logo.jpeg differ diff --git a/client/frontend/src/main.js b/client/frontend/src/main.js index 256a713..f77ead9 100644 --- a/client/frontend/src/main.js +++ b/client/frontend/src/main.js @@ -1,5 +1,8 @@ import './style.css'; import './app.css'; +import logoUrl from './assets/logo.jpeg'; + +const brandLogoHtml = `Simple Care`; // Trạng thái cục bộ let loggedIn = false; @@ -68,7 +71,7 @@ function renderLoginPrompt() {