add window guard
This commit is contained in:
176
client/app.go
176
client/app.go
@@ -19,6 +19,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"client/internal/blocker"
|
"client/internal/blocker"
|
||||||
|
"client/internal/guard"
|
||||||
"client/internal/screen"
|
"client/internal/screen"
|
||||||
"client/internal/winapi"
|
"client/internal/winapi"
|
||||||
|
|
||||||
@@ -100,7 +101,9 @@ type App struct {
|
|||||||
dashboard StudentDashboardSnapshot
|
dashboard StudentDashboardSnapshot
|
||||||
wifiEnforce bool
|
wifiEnforce bool
|
||||||
acceptedWifi map[string]bool
|
acceptedWifi map[string]bool
|
||||||
wifiRejected bool
|
wifiRejected bool
|
||||||
|
quitDialogShown bool
|
||||||
|
monitoringTornDown bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalStats struct {
|
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
|
// Khởi chạy HTTP server nhận callback login thành công
|
||||||
a.startLocalServer()
|
a.startLocalServer()
|
||||||
|
|
||||||
@@ -183,6 +189,53 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
go a.authStorageScanner()
|
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
|
// startLocalServer khởi chạy server lắng nghe callback nhận thông tin sinh viên từ webview
|
||||||
func (a *App) startLocalServer() {
|
func (a *App) startLocalServer() {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -224,6 +277,7 @@ func (a *App) startLocalServer() {
|
|||||||
go a.fetchAllowedApps(st.SystemID)
|
go a.fetchAllowedApps(st.SystemID)
|
||||||
go a.fetchStudentStatus(st.StudentID)
|
go a.fetchStudentStatus(st.StudentID)
|
||||||
go a.fetchWifiPolicy()
|
go a.fetchWifiPolicy()
|
||||||
|
go a.refreshNetworkStatus()
|
||||||
|
|
||||||
// Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets
|
// Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets
|
||||||
runtime.WindowReloadApp(a.ctx)
|
runtime.WindowReloadApp(a.ctx)
|
||||||
@@ -264,6 +318,7 @@ func (a *App) loadSession() {
|
|||||||
go a.fetchAllowedApps(s.SystemID)
|
go a.fetchAllowedApps(s.SystemID)
|
||||||
go a.fetchStudentStatus(s.StudentID)
|
go a.fetchStudentStatus(s.StudentID)
|
||||||
go a.fetchWifiPolicy()
|
go a.fetchWifiPolicy()
|
||||||
|
go a.refreshNetworkStatus()
|
||||||
go a.syncProfileToServer(&s)
|
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
|
// SendWebcamFrame truyền webcam frame từ JS frontend lên máy chủ qua WS
|
||||||
func (a *App) SendWebcamFrame(frameBase64 string) {
|
func (a *App) SendWebcamFrame(frameBase64 string) {
|
||||||
|
if !a.isMonitoringActive() {
|
||||||
|
return
|
||||||
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
conn := a.wsConn
|
conn := a.wsConn
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
@@ -480,50 +538,69 @@ func (a *App) monitorLoop() {
|
|||||||
ticker := time.NewTicker(10 * time.Second)
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
a.runMonitorTick()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
<-ticker.C
|
<-ticker.C
|
||||||
|
a.runMonitorTick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !a.CheckLoginStatus() {
|
func (a *App) runMonitorTick() {
|
||||||
continue
|
if !a.isMonitoringActive() || !a.CheckLoginStatus() {
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
wifi := winapi.GetWifiConnection()
|
wifi := winapi.GetWifiConnection()
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.wifiSSID = wifi.SSID
|
a.wifiSSID = wifi.SSID
|
||||||
a.wifiBSSID = wifi.BSSID
|
a.wifiBSSID = wifi.BSSID
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
backendOnline := a.pingBackend()
|
backendOnline := a.pingBackend()
|
||||||
|
|
||||||
if backendOnline && wifi.SSID != "" && wifi.BSSID != "" {
|
if backendOnline && wifi.SSID != "" && wifi.BSSID != "" {
|
||||||
go a.reportWifi(wifi.SSID, wifi.BSSID)
|
go a.reportWifi(wifi.SSID, wifi.BSSID)
|
||||||
a.fetchWifiPolicy()
|
a.fetchWifiPolicy()
|
||||||
if !a.isWifiAllowed(wifi.SSID, wifi.BSSID) {
|
if !a.isWifiAllowed(wifi.SSID, wifi.BSSID) {
|
||||||
a.rejectUnauthorizedWifi(wifi.SSID, wifi.BSSID)
|
a.rejectUnauthorizedWifi(wifi.SSID, wifi.BSSID)
|
||||||
continue
|
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func (a *App) pingBackend() bool {
|
||||||
@@ -537,6 +614,9 @@ func (a *App) pingBackend() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) syncLogsToServer() {
|
func (a *App) syncLogsToServer() {
|
||||||
|
if !a.isMonitoringActive() {
|
||||||
|
return
|
||||||
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
student := a.student
|
student := a.student
|
||||||
unsyncedOn := a.unsyncedOn
|
unsyncedOn := a.unsyncedOn
|
||||||
@@ -580,6 +660,9 @@ func (a *App) syncLogsToServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) reportBlockedApp(procName string, title string) {
|
func (a *App) reportBlockedApp(procName string, title string) {
|
||||||
|
if !a.isMonitoringActive() {
|
||||||
|
return
|
||||||
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
student := a.student
|
student := a.student
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
@@ -620,6 +703,9 @@ func (a *App) reportBlockedApp(procName string, title string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) reportWifi(ssid, bssid string) {
|
func (a *App) reportWifi(ssid, bssid string) {
|
||||||
|
if !a.isMonitoringActive() {
|
||||||
|
return
|
||||||
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
student := a.student
|
student := a.student
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
@@ -709,15 +795,10 @@ func (a *App) rejectUnauthorizedWifi(ssid, bssid string) {
|
|||||||
a.wifiRejected = true
|
a.wifiRejected = true
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
blocker.Instance.Stop()
|
a.showQuitDialog(
|
||||||
a.disconnectWS()
|
"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),
|
||||||
_, _ = 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) fetchAllowedApps(classId int64) {
|
func (a *App) fetchAllowedApps(classId int64) {
|
||||||
@@ -807,6 +888,9 @@ func (a *App) fetchStudentStatus(studentID int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) connectWS() {
|
func (a *App) connectWS() {
|
||||||
|
if !a.isMonitoringActive() {
|
||||||
|
return
|
||||||
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
if a.wsConnected || a.student == nil {
|
if a.wsConnected || a.student == nil {
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 70 KiB |
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<link rel="icon" type="image/jpeg" href="/src/assets/logo.jpeg"/>
|
||||||
<title>Simple Care — Rikkei Education</title>
|
<title>Simple Care — Rikkei Education</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ body {
|
|||||||
margin-bottom: 1.5rem;
|
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 {
|
.login-logo {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
@@ -413,24 +427,61 @@ body {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.65rem;
|
margin-bottom: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title-sub {
|
.card-title-sub {
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-display-summary {
|
.clock-display-summary {
|
||||||
margin-bottom: 0.75rem;
|
display: flex;
|
||||||
padding-bottom: 0.75rem;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
padding-bottom: 0.45rem;
|
||||||
border-bottom: 1px dashed var(--border-color);
|
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 {
|
.shifts-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: min(32vh, 220px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shifts-table {
|
.shifts-table {
|
||||||
@@ -571,10 +622,11 @@ body {
|
|||||||
.clocks-card {
|
.clocks-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.65rem;
|
gap: 0.4rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
|
|||||||
BIN
client/frontend/src/assets/logo.jpeg
Normal file
BIN
client/frontend/src/assets/logo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -1,5 +1,8 @@
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
import logoUrl from './assets/logo.jpeg';
|
||||||
|
|
||||||
|
const brandLogoHtml = `<img src="${logoUrl}" alt="Simple Care" class="brand-logo-img" />`;
|
||||||
|
|
||||||
// Trạng thái cục bộ
|
// Trạng thái cục bộ
|
||||||
let loggedIn = false;
|
let loggedIn = false;
|
||||||
@@ -68,7 +71,7 @@ function renderLoginPrompt() {
|
|||||||
<div class="login-prompt-container">
|
<div class="login-prompt-container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="login-brand-row">
|
<div class="login-brand-row">
|
||||||
<div class="login-logo">RE</div>
|
${brandLogoHtml}
|
||||||
<div class="login-brand-text">
|
<div class="login-brand-text">
|
||||||
<div class="login-brand-name">Simple Care</div>
|
<div class="login-brand-name">Simple Care</div>
|
||||||
<div class="login-brand-sub">Rikkei Education</div>
|
<div class="login-brand-sub">Rikkei Education</div>
|
||||||
@@ -157,7 +160,7 @@ function renderDashboard() {
|
|||||||
<div class="dashboard-wrapper">
|
<div class="dashboard-wrapper">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-logo">RE</div>
|
${brandLogoHtml}
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<span class="brand-name">Simple Care</span>
|
<span class="brand-name">Simple Care</span>
|
||||||
<span class="brand-sub">Rikkei Education</span>
|
<span class="brand-sub">Rikkei Education</span>
|
||||||
@@ -228,14 +231,14 @@ function renderDashboard() {
|
|||||||
<div class="card-title-sub" id="session-date">${stats.sessionDate || ''}</div>
|
<div class="card-title-sub" id="session-date">${stats.sessionDate || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="clock-display clock-display-summary">
|
<div class="clock-display clock-display-summary">
|
||||||
<div class="clock-item">
|
<span class="clock-chip clock-chip--online">
|
||||||
<div class="clock-label">Tổng trực tuyến</div>
|
<em>Trực tuyến</em>
|
||||||
<div class="clock-value online" id="clock-online">00:00:00</div>
|
<strong id="clock-online">00:00:00</strong>
|
||||||
</div>
|
</span>
|
||||||
<div class="clock-item">
|
<span class="clock-chip clock-chip--offline">
|
||||||
<div class="clock-label">Tổng ngoại tuyến</div>
|
<em>Ngoại tuyến</em>
|
||||||
<div class="clock-value offline" id="clock-offline">00:00:00</div>
|
<strong id="clock-offline">00:00:00</strong>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shifts-wrap" id="shifts-wrap">
|
<div class="shifts-wrap" id="shifts-wrap">
|
||||||
${renderShiftsTable(stats.shifts)}
|
${renderShiftsTable(stats.shifts)}
|
||||||
@@ -265,7 +268,7 @@ function formatDuration(totalSeconds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startStatsTicker() {
|
function startStatsTicker() {
|
||||||
setInterval(async () => {
|
const pullStats = async () => {
|
||||||
if (!loggedIn) return;
|
if (!loggedIn) return;
|
||||||
try {
|
try {
|
||||||
const freshStats = await window.go.main.App.GetStats();
|
const freshStats = await window.go.main.App.GetStats();
|
||||||
@@ -330,7 +333,9 @@ function startStatsTicker() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching stats:', err);
|
console.error('Error fetching stats:', err);
|
||||||
}
|
}
|
||||||
}, 1000);
|
};
|
||||||
|
pullStats();
|
||||||
|
setInterval(pullStats, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startWebcam() {
|
async function startWebcam() {
|
||||||
|
|||||||
6
client/internal/guard/guard_other.go
Normal file
6
client/internal/guard/guard_other.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package guard
|
||||||
|
|
||||||
|
func Start(onViolation func(reason string)) {}
|
||||||
|
func Stop() {}
|
||||||
252
client/internal/guard/guard_windows.go
Normal file
252
client/internal/guard/guard_windows.go
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package guard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
smCMonitors = 80
|
||||||
|
eventSystemDesktopSwitch = 0x0020
|
||||||
|
wineventOutofcontext = 0x0000
|
||||||
|
wmWtsSessionChange = 0x02B1
|
||||||
|
|
||||||
|
wtsConsoleDisconnect = 0x2
|
||||||
|
wtsRemoteDisconnect = 0x3
|
||||||
|
wtsSessionLogoff = 0x6
|
||||||
|
wtsSessionLock = 0x7
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = syscall.NewLazyDLL("user32.dll")
|
||||||
|
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
wtsapi32 = syscall.NewLazyDLL("wtsapi32.dll")
|
||||||
|
|
||||||
|
procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
|
||||||
|
procSetWinEventHook = user32.NewProc("SetWinEventHook")
|
||||||
|
procUnhookWinEvent = user32.NewProc("UnhookWinEvent")
|
||||||
|
procGetMessageW = user32.NewProc("GetMessageW")
|
||||||
|
procTranslateMessage = user32.NewProc("TranslateMessage")
|
||||||
|
procDispatchMessageW = user32.NewProc("DispatchMessageW")
|
||||||
|
procCreateWindowExW = user32.NewProc("CreateWindowExW")
|
||||||
|
procDefWindowProcW = user32.NewProc("DefWindowProcW")
|
||||||
|
procRegisterClassExW = user32.NewProc("RegisterClassExW")
|
||||||
|
procDestroyWindow = user32.NewProc("DestroyWindow")
|
||||||
|
procGetCurrentProcessId = kernel32.NewProc("GetCurrentProcessId")
|
||||||
|
procProcessIdToSessionId = kernel32.NewProc("ProcessIdToSessionId")
|
||||||
|
procWTSRegisterSessionNotification = wtsapi32.NewProc("WTSRegisterSessionNotification")
|
||||||
|
procWTSUnRegisterSessionNotification = wtsapi32.NewProc("WTSUnRegisterSessionNotification")
|
||||||
|
)
|
||||||
|
|
||||||
|
type wndclassEx struct {
|
||||||
|
Size uint32
|
||||||
|
Style uint32
|
||||||
|
WndProc uintptr
|
||||||
|
ClsExtra int32
|
||||||
|
WndExtra int32
|
||||||
|
Instance syscall.Handle
|
||||||
|
Icon syscall.Handle
|
||||||
|
Cursor syscall.Handle
|
||||||
|
Background syscall.Handle
|
||||||
|
MenuName *uint16
|
||||||
|
ClassName *uint16
|
||||||
|
IconSm syscall.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
type point struct {
|
||||||
|
X, Y int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type msg struct {
|
||||||
|
Hwnd syscall.Handle
|
||||||
|
Message uint32
|
||||||
|
WParam uintptr
|
||||||
|
LParam uintptr
|
||||||
|
Time uint32
|
||||||
|
Pt point
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
guardOnce sync.Once
|
||||||
|
guardViolation func(string)
|
||||||
|
guardStop chan struct{}
|
||||||
|
guardBaselineUser string
|
||||||
|
guardBaselineSession uint32
|
||||||
|
desktopHook uintptr
|
||||||
|
guardClassAtom uint16
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start giám sát môi trường Windows — vi phạm thì gọi onViolation (đổi user, đa màn hình, đổi desktop ảo).
|
||||||
|
func Start(onViolation func(reason string)) {
|
||||||
|
guardOnce.Do(func() {
|
||||||
|
if onViolation == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guardViolation = onViolation
|
||||||
|
guardStop = make(chan struct{})
|
||||||
|
guardBaselineUser = currentUsername()
|
||||||
|
guardBaselineSession = currentSessionID()
|
||||||
|
|
||||||
|
if monitorCount() > 1 {
|
||||||
|
onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go pollLoop()
|
||||||
|
go runMessageWindow()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Stop() {
|
||||||
|
if guardStop != nil {
|
||||||
|
close(guardStop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pollLoop() {
|
||||||
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-guardStop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
checkEnvironment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkEnvironment() {
|
||||||
|
if guardViolation == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n := monitorCount(); n > 1 {
|
||||||
|
guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := currentUsername()
|
||||||
|
if user != "" && guardBaselineUser != "" && user != guardBaselineUser {
|
||||||
|
guardViolation("Phát hiện đổi tài khoản Windows. Ứng dụng sẽ thoát.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sid := currentSessionID()
|
||||||
|
if sid != 0 && guardBaselineSession != 0 && sid != guardBaselineSession {
|
||||||
|
guardViolation("Phiên đăng nhập Windows đã thay đổi. Ứng dụng sẽ thoát.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerViolation(reason string) {
|
||||||
|
if guardViolation != nil {
|
||||||
|
guardViolation(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitorCount() int {
|
||||||
|
n, _, _ := procGetSystemMetrics.Call(smCMonitors)
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentSessionID() uint32 {
|
||||||
|
pid, _, _ := procGetCurrentProcessId.Call()
|
||||||
|
var sid uint32
|
||||||
|
procProcessIdToSessionId.Call(pid, uintptr(unsafe.Pointer(&sid)))
|
||||||
|
return sid
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentUsername() string {
|
||||||
|
if u, ok := syscall.Getenv("USERNAME"); ok {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMessageWindow() {
|
||||||
|
className, _ := syscall.UTF16PtrFromString("SimpleCareGuardWnd")
|
||||||
|
hInstance := syscall.Handle(0)
|
||||||
|
|
||||||
|
wndProc := syscall.NewCallback(guardWndProc)
|
||||||
|
wc := wndclassEx{
|
||||||
|
Size: uint32(unsafe.Sizeof(wndclassEx{})),
|
||||||
|
WndProc: wndProc,
|
||||||
|
Instance: hInstance,
|
||||||
|
ClassName: className,
|
||||||
|
}
|
||||||
|
atom, _, _ := procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc)))
|
||||||
|
if atom == 0 {
|
||||||
|
log.Println("[GUARD] RegisterClassEx failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guardClassAtom = uint16(atom)
|
||||||
|
|
||||||
|
title, _ := syscall.UTF16PtrFromString("SimpleCareGuard")
|
||||||
|
hwnd, _, _ := procCreateWindowExW.Call(
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(className)),
|
||||||
|
uintptr(unsafe.Pointer(title)),
|
||||||
|
0,
|
||||||
|
0, 0, 0, 0,
|
||||||
|
0,
|
||||||
|
0, uintptr(hInstance), 0,
|
||||||
|
)
|
||||||
|
if hwnd == 0 {
|
||||||
|
log.Println("[GUARD] CreateWindowEx failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer procDestroyWindow.Call(hwnd)
|
||||||
|
|
||||||
|
procWTSRegisterSessionNotification.Call(hwnd, 0)
|
||||||
|
|
||||||
|
desktopHook, _, _ = procSetWinEventHook.Call(
|
||||||
|
eventSystemDesktopSwitch,
|
||||||
|
eventSystemDesktopSwitch,
|
||||||
|
0,
|
||||||
|
syscall.NewCallback(desktopSwitchCallback),
|
||||||
|
0, 0,
|
||||||
|
wineventOutofcontext,
|
||||||
|
)
|
||||||
|
defer func() {
|
||||||
|
if desktopHook != 0 {
|
||||||
|
procUnhookWinEvent.Call(desktopHook)
|
||||||
|
}
|
||||||
|
procWTSUnRegisterSessionNotification.Call(hwnd)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var m msg
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-guardStop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
ret, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
|
||||||
|
if ret == 0 || ret == ^uintptr(0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
procTranslateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
|
||||||
|
switch uint32(msg) {
|
||||||
|
case wmWtsSessionChange:
|
||||||
|
switch uint32(wParam) {
|
||||||
|
case wtsSessionLock, wtsSessionLogoff, wtsConsoleDisconnect, wtsRemoteDisconnect:
|
||||||
|
triggerViolation("Phiên Windows bị khóa, đăng xuất hoặc chuyển người dùng. Ứng dụng sẽ thoát.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r, _, _ := procDefWindowProcW.Call(hwnd, msg, wParam, lParam)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func desktopSwitchCallback(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime uintptr) uintptr {
|
||||||
|
if event == eventSystemDesktopSwitch {
|
||||||
|
triggerViolation("Không được chuyển Desktop ảo (Win+Tab). Ứng dụng sẽ thoát.")
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="vi">
|
<html lang="vi">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/jpeg" href="/logo.jpeg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Simple Care — Rikkei Education</title>
|
<title>Simple Care — Rikkei Education</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
BIN
management/public/logo.jpeg
Normal file
BIN
management/public/logo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -69,7 +69,7 @@ function App() {
|
|||||||
<>
|
<>
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<div className="brand-logo">RE</div>
|
<img src="/logo.jpeg" alt="Simple Care" className="brand-logo-img" />
|
||||||
<div className="brand-text">
|
<div className="brand-text">
|
||||||
<span className="brand-name">Simple Care</span>
|
<span className="brand-name">Simple Care</span>
|
||||||
<span className="brand-sub">Rikkei Education</span>
|
<span className="brand-sub">Rikkei Education</span>
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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.1);
|
||||||
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
width: 42px;
|
width: 42px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
|
|||||||
Reference in New Issue
Block a user