This commit is contained in:
490
client/app.go
490
client/app.go
@@ -125,20 +125,25 @@ type App struct {
|
||||
sessionPath string
|
||||
statsPath string
|
||||
webviewDataPath string
|
||||
pendingViolPath string
|
||||
runLockPath string
|
||||
wsConn *websocket.Conn
|
||||
wsConnected bool
|
||||
wifiSSID string
|
||||
wifiBSSID string
|
||||
allowedApps string
|
||||
onlineSecs int
|
||||
offlineSecs int
|
||||
unsyncedOn int
|
||||
unsyncedOff int
|
||||
lastSyncTime time.Time
|
||||
isStreamingSc bool
|
||||
streamScStop chan struct{}
|
||||
isStreamingCam bool
|
||||
streamCamStop chan struct{}
|
||||
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
|
||||
streamScStop chan struct{}
|
||||
isStreamingCam bool
|
||||
streamCamStop chan struct{}
|
||||
statusMsg string
|
||||
expectingLogin bool
|
||||
needsClearPortalStorage bool
|
||||
@@ -152,7 +157,6 @@ type App struct {
|
||||
chatUnread int
|
||||
replyStaffID uint
|
||||
fetchAppsMu sync.Mutex
|
||||
wsWriteMu sync.Mutex
|
||||
}
|
||||
|
||||
type LocalStats struct {
|
||||
@@ -213,11 +217,13 @@ func NewApp() *App {
|
||||
_ = os.MkdirAll(webviewDir, 0755)
|
||||
|
||||
return &App{
|
||||
sessionPath: filepath.Join(appDir, "student_session.json"),
|
||||
statsPath: filepath.Join(appDir, "student_stats.json"),
|
||||
webviewDataPath: webviewDir,
|
||||
lastSyncTime: time.Now(),
|
||||
expectingLogin: false,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +232,10 @@ func (a *App) startup(ctx context.Context) {
|
||||
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()
|
||||
@@ -268,10 +278,35 @@ func (a *App) startup(ctx context.Context) {
|
||||
go a.authStorageScanner()
|
||||
}
|
||||
|
||||
func (a *App) handleGuardViolation(reason string) {
|
||||
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 mode == "" || mode == "not_configured" {
|
||||
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()
|
||||
@@ -287,6 +322,7 @@ func (a *App) tearDownBeforeQuit() {
|
||||
a.stopWebcamStream()
|
||||
a.disconnectWS()
|
||||
guard.Stop()
|
||||
a.clearRunLock()
|
||||
}
|
||||
|
||||
func (a *App) isMonitoringActive() bool {
|
||||
@@ -315,6 +351,162 @@ func (a *App) showQuitDialog(title, message string) {
|
||||
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"`
|
||||
}
|
||||
|
||||
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 || mode == "" || mode == "not_configured" {
|
||||
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 {
|
||||
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 {
|
||||
return true
|
||||
}
|
||||
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 {
|
||||
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()
|
||||
@@ -392,6 +584,16 @@ func (a *App) startLocalServer() {
|
||||
}()
|
||||
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ỉ purge disk — JS trên trang exam-view tự clear storage + reload iframe.
|
||||
go a.purgeWebViewDiskCache()
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
go func() {
|
||||
_ = server.ListenAndServe()
|
||||
@@ -408,26 +610,95 @@ const examViewHTML = `<!DOCTYPE html>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:Segoe UI,system-ui,sans-serif;background:#1e293b;color:#f8fafc;height:100vh;display:flex;flex-direction:column}
|
||||
.bar{display:flex;align-items:center;gap:12px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0}
|
||||
.bar button{background:#7c3aed;color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:14px;cursor:pointer;font-weight:600}
|
||||
.bar button:hover{background:#6d28d9}
|
||||
.bar span{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0}
|
||||
.bar-title{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:auto;min-width:0}
|
||||
.bar button{border:none;border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap}
|
||||
.btn-back{background:#7c3aed;color:#fff}
|
||||
.btn-back:hover{background:#6d28d9}
|
||||
.btn-refresh{background:#0ea5e9;color:#fff}
|
||||
.btn-refresh:hover{background:#0284c7}
|
||||
.btn-cache{background:#f59e0b;color:#111}
|
||||
.btn-cache:hover{background:#d97706}
|
||||
.btn-cache:disabled,.btn-refresh:disabled{opacity:.65;cursor:wait}
|
||||
.hint{font-size:11px;color:#94a3b8;flex-shrink:0}
|
||||
.frame-wrap{flex:1;min-height:0;background:#fff}
|
||||
iframe{width:100%%;height:100%%;border:0;display:block}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bar">
|
||||
<button type="button" onclick="goBack()">← Quay lại</button>
|
||||
<span>%s</span>
|
||||
<button type="button" class="btn-back" onclick="goBack()">← Quay lại</button>
|
||||
<span class="bar-title">%s</span>
|
||||
<span class="hint">Nếu trang lỗi / trắng: F5 hoặc Xóa cache</span>
|
||||
<button type="button" class="btn-refresh" id="btn-refresh" onclick="refreshExam()">⟳ F5 Làm mới</button>
|
||||
<button type="button" class="btn-cache" id="btn-cache" onclick="clearExamCache()">🗑 Xóa cache</button>
|
||||
</div>
|
||||
<div class="frame-wrap">
|
||||
<iframe src="%s" title="exam-content"></iframe>
|
||||
<iframe id="exam-frame" src="%s" title="exam-content"></iframe>
|
||||
</div>
|
||||
<script>
|
||||
function goBack(){
|
||||
fetch('http://127.0.0.1:34115/exam-close').catch(function(){});
|
||||
}
|
||||
function refreshExam(){
|
||||
var f = document.getElementById('exam-frame');
|
||||
if (!f) return;
|
||||
var btn = document.getElementById('btn-refresh');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
f.contentWindow.location.reload();
|
||||
} catch (e) {
|
||||
var src = f.src;
|
||||
f.src = 'about:blank';
|
||||
setTimeout(function(){ f.src = src; }, 50);
|
||||
}
|
||||
setTimeout(function(){ if (btn) btn.disabled = false; }, 800);
|
||||
}
|
||||
async function clearExamCache(){
|
||||
var btn = document.getElementById('btn-cache');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
if (window.caches) {
|
||||
var keys = await caches.keys();
|
||||
await Promise.all(keys.map(function(k){ return caches.delete(k); }));
|
||||
}
|
||||
} catch (e) {}
|
||||
try { localStorage.clear(); } catch (e) {}
|
||||
try { sessionStorage.clear(); } catch (e) {}
|
||||
try {
|
||||
var f = document.getElementById('exam-frame');
|
||||
if (f && f.contentWindow) {
|
||||
try { f.contentWindow.localStorage.clear(); } catch (e) {}
|
||||
try { f.contentWindow.sessionStorage.clear(); } catch (e) {}
|
||||
try {
|
||||
if (f.contentWindow.caches) {
|
||||
var ikeys = await f.contentWindow.caches.keys();
|
||||
await Promise.all(ikeys.map(function(k){ return f.contentWindow.caches.delete(k); }));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
await fetch('http://127.0.0.1:34115/exam-clear-cache');
|
||||
} catch (e) {}
|
||||
var f = document.getElementById('exam-frame');
|
||||
if (f) {
|
||||
try {
|
||||
var u = new URL(f.src, window.location.href);
|
||||
u.searchParams.set('_sc_cb', String(Date.now()));
|
||||
f.src = u.toString();
|
||||
} catch (e) {
|
||||
refreshExam();
|
||||
}
|
||||
}
|
||||
setTimeout(function(){ if (btn) btn.disabled = false; }, 1200);
|
||||
}
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key === 'F5') {
|
||||
e.preventDefault();
|
||||
refreshExam();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -1017,6 +1288,7 @@ func (a *App) rejectUnauthorizedWifi(ssid, bssid string) {
|
||||
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),
|
||||
@@ -1132,40 +1404,101 @@ func (a *App) connectWS() {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.wsConnected || a.student == nil {
|
||||
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)
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
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 {
|
||||
@@ -1177,6 +1510,10 @@ func (a *App) connectWS() {
|
||||
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 {
|
||||
@@ -1238,6 +1575,7 @@ func (a *App) disconnectWS(expectedConn ...*websocket.Conn) {
|
||||
a.wsConn = nil
|
||||
}
|
||||
a.wsConnected = false
|
||||
a.wsConnecting = false
|
||||
shouldStopStreams = a.isStreamingSc || a.isStreamingCam
|
||||
a.mu.Unlock()
|
||||
|
||||
@@ -1523,6 +1861,83 @@ func (a *App) ReturnToDashboard() {
|
||||
runtime.WindowReloadApp(a.ctx)
|
||||
}
|
||||
|
||||
// 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 — xóa cache WebView (storage + disk cache) rồi làm mới trang thi.
|
||||
func (a *App) ClearExamBrowserCache() {
|
||||
guard.SuppressFor(8 * time.Second)
|
||||
a.purgeWebViewDiskCache()
|
||||
runtime.WindowExecJS(a.ctx, `
|
||||
(async function(){
|
||||
try {
|
||||
if (window.caches) {
|
||||
var keys = await caches.keys();
|
||||
await Promise.all(keys.map(function(k){ return caches.delete(k); }));
|
||||
}
|
||||
} catch (e) {}
|
||||
try { localStorage.clear(); } catch (e) {}
|
||||
try { sessionStorage.clear(); } catch (e) {}
|
||||
try {
|
||||
var f = document.getElementById('exam-frame');
|
||||
if (f) {
|
||||
try { f.contentWindow.localStorage.clear(); } catch (e) {}
|
||||
try { f.contentWindow.sessionStorage.clear(); } catch (e) {}
|
||||
try {
|
||||
var u = new URL(f.src, window.location.href);
|
||||
u.searchParams.set('_sc_cb', String(Date.now()));
|
||||
f.src = u.toString();
|
||||
return;
|
||||
} catch (e) {
|
||||
try { f.contentWindow.location.reload(); } catch (e2) {
|
||||
var s = 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
|
||||
}
|
||||
subs := []string{
|
||||
filepath.Join("EBWebView", "Default", "Cache"),
|
||||
filepath.Join("EBWebView", "Default", "Code Cache"),
|
||||
filepath.Join("EBWebView", "Default", "GPUCache"),
|
||||
filepath.Join("EBWebView", "Default", "Service Worker", "CacheStorage"),
|
||||
filepath.Join("EBWebView", "Default", "Service Worker", "ScriptCache"),
|
||||
}
|
||||
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 == "" {
|
||||
@@ -1533,11 +1948,17 @@ func isLocalExamURL(raw string) bool {
|
||||
}
|
||||
|
||||
func (a *App) openExamWebView(targetURL, title string) error {
|
||||
return a.openExamWebViewOpts(targetURL, title, false)
|
||||
}
|
||||
|
||||
// openExamWebViewOpts mở nội dung trong WebView.
|
||||
// forceWrap=true luôn dùng khung exam-view (có nút F5 / xóa cache) — dùng cho trắc nghiệm.
|
||||
func (a *App) openExamWebViewOpts(targetURL, title string, forceWrap bool) error {
|
||||
targetURL = strings.TrimSpace(targetURL)
|
||||
if targetURL == "" {
|
||||
return errors.New("không có nội dung để mở")
|
||||
}
|
||||
if isLocalExamURL(targetURL) {
|
||||
if forceWrap || isLocalExamURL(targetURL) {
|
||||
wrapper := fmt.Sprintf(
|
||||
"http://127.0.0.1:34115/exam-view?url=%s&title=%s",
|
||||
url.QueryEscape(targetURL),
|
||||
@@ -1547,7 +1968,7 @@ func (a *App) openExamWebView(targetURL, title string) error {
|
||||
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper))
|
||||
return nil
|
||||
}
|
||||
// Trang thi bên ngoài: mở trực tiếp trong WebView (first-party cookies → giữ phiên đăng nhập).
|
||||
// Trang thi bên ngoài (đề/tài nguyên): mở trực tiếp để giữ first-party cookies.
|
||||
guard.SuppressFor(5 * time.Second)
|
||||
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL))
|
||||
return nil
|
||||
@@ -1639,7 +2060,8 @@ func (a *App) OpenExamQuiz() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.openExamWebView(viewURL, "Làm trắc nghiệm")
|
||||
// Luôn mở trong khung có nút F5 / Xóa cache để SV tự xử lý khi trang lỗi.
|
||||
return a.openExamWebViewOpts(viewURL, "Làm trắc nghiệm", true)
|
||||
}
|
||||
|
||||
func (a *App) OpenExamResource(fileID uint) error {
|
||||
|
||||
@@ -413,7 +413,7 @@ function renderExamPanel() {
|
||||
<div class="card-title-row">
|
||||
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
|
||||
</div>
|
||||
<p class="exam-panel-desc">Đề PDF và tài nguyên xem ngay trong app. Link trắc nghiệm mở trang thi — xong thì menu <strong>Simple Care → Về trang chính</strong> (Ctrl+H).</p>
|
||||
<p class="exam-panel-desc">Đề PDF và tài nguyên xem trong app. Trắc nghiệm có nút <strong>F5</strong> / <strong>Xóa cache</strong> trên thanh công cụ. Xong: menu <strong>Simple Care → Về trang chính</strong> (Ctrl+H).</p>
|
||||
<div class="exam-panel-actions">
|
||||
${paperBtn}
|
||||
${quizBtn}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
var (
|
||||
guardOnce sync.Once
|
||||
guardViolation func(string)
|
||||
guardViolation func(kind, reason string)
|
||||
guardStop chan struct{}
|
||||
suppressMu sync.Mutex
|
||||
suppressViolationsUntil time.Time
|
||||
@@ -36,8 +36,8 @@ func violationsSuppressed() bool {
|
||||
return time.Now().Before(suppressViolationsUntil)
|
||||
}
|
||||
|
||||
// Start giám sát môi trường macOS — vi phạm thì gọi onViolation (đa màn hình).
|
||||
func Start(onViolation func(reason string)) {
|
||||
// Start giám sát môi trường macOS — vi phạm thì gọi onViolation(kind, reason).
|
||||
func Start(onViolation func(kind, reason string)) {
|
||||
guardOnce.Do(func() {
|
||||
if onViolation == nil {
|
||||
return
|
||||
@@ -46,7 +46,7 @@ func Start(onViolation func(reason string)) {
|
||||
guardStop = make(chan struct{})
|
||||
|
||||
if screenshot.NumActiveDisplays() > 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.")
|
||||
onViolation("multi_monitor", "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
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func checkEnvironment() {
|
||||
return
|
||||
}
|
||||
if n := screenshot.NumActiveDisplays(); 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ụ.")
|
||||
guardViolation("multi_monitor", "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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
var (
|
||||
guardOnce sync.Once
|
||||
guardViolation func(string)
|
||||
guardViolation func(kind, reason string)
|
||||
guardStop chan struct{}
|
||||
suppressMu sync.Mutex
|
||||
suppressViolationsUntil time.Time
|
||||
@@ -37,7 +37,7 @@ func violationsSuppressed() bool {
|
||||
}
|
||||
|
||||
// Start monitors the Linux desktop environment (multi-display check)
|
||||
func Start(onViolation func(reason string)) {
|
||||
func Start(onViolation func(kind, reason string)) {
|
||||
guardOnce.Do(func() {
|
||||
if onViolation == nil {
|
||||
return
|
||||
@@ -46,7 +46,7 @@ func Start(onViolation func(reason string)) {
|
||||
guardStop = make(chan struct{})
|
||||
|
||||
if screenshot.NumActiveDisplays() > 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.")
|
||||
onViolation("multi_monitor", "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
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func checkEnvironment() {
|
||||
return
|
||||
}
|
||||
if n := screenshot.NumActiveDisplays(); 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ụ.")
|
||||
guardViolation("multi_monitor", "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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ package guard
|
||||
|
||||
import "time"
|
||||
|
||||
func Start(onViolation func(reason string)) {}
|
||||
func Start(onViolation func(kind, reason string)) {}
|
||||
func Stop() {}
|
||||
func SuppressFor(_ time.Duration) {}
|
||||
|
||||
@@ -72,15 +72,15 @@ type msg struct {
|
||||
}
|
||||
|
||||
var (
|
||||
guardOnce sync.Once
|
||||
guardViolation func(string)
|
||||
guardStop chan struct{}
|
||||
guardBaselineUser string
|
||||
guardBaselineSession uint32
|
||||
desktopHook uintptr
|
||||
guardClassAtom uint16
|
||||
suppressMu sync.Mutex
|
||||
suppressViolationsUntil time.Time
|
||||
guardOnce sync.Once
|
||||
guardViolation func(kind, reason string)
|
||||
guardStop chan struct{}
|
||||
guardBaselineUser string
|
||||
guardBaselineSession uint32
|
||||
desktopHook uintptr
|
||||
guardClassAtom uint16
|
||||
suppressMu sync.Mutex
|
||||
suppressViolationsUntil time.Time
|
||||
)
|
||||
|
||||
// SuppressFor tạm không thoát app khi WebView/Explorer chuyển màn hình nội bộ.
|
||||
@@ -102,8 +102,8 @@ func violationsSuppressed() bool {
|
||||
return time.Now().Before(suppressViolationsUntil)
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
// Start giám sát môi trường Windows — vi phạm thì gọi onViolation(kind, reason).
|
||||
func Start(onViolation func(kind, reason string)) {
|
||||
guardOnce.Do(func() {
|
||||
if onViolation == nil {
|
||||
return
|
||||
@@ -114,7 +114,7 @@ func Start(onViolation func(reason string)) {
|
||||
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.")
|
||||
onViolation("multi_monitor", "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
|
||||
}
|
||||
|
||||
@@ -147,27 +147,27 @@ func checkEnvironment() {
|
||||
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ụ.")
|
||||
guardViolation("multi_monitor", "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.")
|
||||
guardViolation("user_switch", "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.")
|
||||
guardViolation("session_change", "Phiên đăng nhập Windows đã thay đổi. Ứng dụng sẽ thoát.")
|
||||
}
|
||||
}
|
||||
|
||||
func triggerViolation(reason string) {
|
||||
func triggerViolation(kind, reason string) {
|
||||
if violationsSuppressed() {
|
||||
log.Printf("[GUARD] suppressed: %s", reason)
|
||||
return
|
||||
}
|
||||
if guardViolation != nil {
|
||||
guardViolation(reason)
|
||||
guardViolation(kind, reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
|
||||
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.")
|
||||
triggerViolation("session_change", "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)
|
||||
@@ -271,7 +271,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
|
||||
|
||||
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.")
|
||||
triggerViolation("virtual_desktop", "Không được chuyển Desktop ảo (Win+Tab). Ứng dụng sẽ thoát.")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"log"
|
||||
"os"
|
||||
@@ -46,6 +47,12 @@ func main() {
|
||||
fileMenu.AddText("Về trang chính", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) {
|
||||
app.ReturnToDashboard()
|
||||
})
|
||||
fileMenu.AddText("Làm mới trang (F5)", keys.Key("f5"), func(_ *menu.CallbackData) {
|
||||
app.ReloadExamPage()
|
||||
})
|
||||
fileMenu.AddText("Xóa cache trình duyệt", keys.CmdOrCtrl("Delete"), func(_ *menu.CallbackData) {
|
||||
app.ClearExamBrowserCache()
|
||||
})
|
||||
|
||||
// Create application with options
|
||||
err := wails.Run(&options.App{
|
||||
@@ -58,6 +65,9 @@ func main() {
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||
OnStartup: app.startup,
|
||||
OnBeforeClose: func(ctx context.Context) (prevent bool) {
|
||||
return app.HandleBeforeClose()
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
WebviewUserDataPath: app.webviewDataPath,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user