From b4c3155564b894a5038f4c2f824602e0f2b9930a Mon Sep 17 00:00:00 2001 From: PhuocNTB Date: Mon, 13 Jul 2026 10:04:56 +0700 Subject: [PATCH] fix ui --- client/app.go | 214 ++++++++++++++ client/frontend/src/app.css | 271 ++++++++++++------ client/frontend/src/main.js | 21 ++ client/frontend/src/style.css | 12 +- client/frontend/wailsjs/go/main/App.d.ts | 6 + client/frontend/wailsjs/go/main/App.js | 30 +- .../internal/singleinstance/singleinstance.go | 11 + .../singleinstance/singleinstance_unix.go | 49 ++++ .../singleinstance/singleinstance_windows.go | 47 +++ client/internal/winapi/window_darwin.go | 17 +- client/main.go | 6 + 11 files changed, 587 insertions(+), 97 deletions(-) create mode 100644 client/internal/singleinstance/singleinstance.go create mode 100644 client/internal/singleinstance/singleinstance_unix.go create mode 100644 client/internal/singleinstance/singleinstance_windows.go diff --git a/client/app.go b/client/app.go index 8666904..cd960f8 100644 --- a/client/app.go +++ b/client/app.go @@ -16,6 +16,7 @@ import ( "io" "log" "mime/multipart" + "net" "net/http" "net/url" "os" @@ -154,6 +155,7 @@ type App struct { 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 @@ -276,6 +278,9 @@ func (a *App) startup(ctx context.Context) { // 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) { @@ -588,6 +593,7 @@ func (a *App) startLocalServer() { w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") return } + a.setLocalBrowserActive(false) go func() { guard.SuppressFor(5 * time.Second) runtime.WindowReloadApp(a.ctx) @@ -604,6 +610,46 @@ func (a *App) startLocalServer() { 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() + 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() @@ -690,6 +736,94 @@ document.addEventListener('keydown', function(e){ ` +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() @@ -808,6 +942,7 @@ func (a *App) GetStudentInfo() map[string]any { // 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() @@ -1844,10 +1979,88 @@ func (a *App) examStudentContext() (int64, *StudentExamSnapshot, error) { // ReturnToDashboard quay lại giao diện chính của app (giữ cookie WebView2). func (a *App) ReturnToDashboard() { + a.setLocalBrowserActive(false) 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) + 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) @@ -1928,6 +2141,7 @@ func (a *App) openExamWebView(targetURL, title string) error { if targetURL == "" { return errors.New("không có nội dung để mở") } + a.setLocalBrowserActive(false) // 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( diff --git a/client/frontend/src/app.css b/client/frontend/src/app.css index edbfe3b..08bb0e3 100644 --- a/client/frontend/src/app.css +++ b/client/frontend/src/app.css @@ -18,10 +18,12 @@ --offline-light: #fdeaea; --shadow-sm: 0 1px 3px rgba(26, 35, 50, 0.05); --shadow-md: 0 4px 16px rgba(26, 35, 50, 0.07); - --radius-sm: 8px; - --radius-md: 12px; - --radius-lg: 16px; + --radius-sm: 7px; + --radius-md: 10px; + --radius-lg: 12px; --transition: 0.2s ease; + --page-pad: 0.75rem 0.9rem; + --card-pad: 0.95rem 1rem; } * { @@ -32,7 +34,8 @@ html { height: 100%; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; } body { @@ -40,18 +43,22 @@ body { background-color: var(--bg-main); color: var(--text-primary); font-family: 'Be Vietnam Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - overflow: hidden; - height: 100%; + font-size: 14px; + overflow-x: hidden; + overflow-y: auto; + min-height: 100%; + height: auto; width: 100%; -webkit-font-smoothing: antialiased; } #app { width: 100%; - height: 100%; - max-height: 100vh; - display: flex; - overflow: hidden; + min-height: 100%; + height: auto; + max-height: none; + display: block; + overflow: visible; } /* ── Cards ── */ @@ -60,7 +67,7 @@ body { border: 1px solid var(--border-color); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); - padding: 1.75rem; + padding: var(--card-pad); transition: var(--transition); } @@ -70,10 +77,10 @@ body { align-items: center; justify-content: center; width: 100%; - height: 100%; - padding: 1.5rem; + min-height: 100%; + padding: 1rem; background: var(--bg-main); - overflow: hidden; + overflow: visible; } .login-prompt-container .card { @@ -94,8 +101,8 @@ body { } .brand-logo-img { - width: 42px; - height: 42px; + width: 34px; + height: 34px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; @@ -103,8 +110,8 @@ body { } .login-brand-row .brand-logo-img { - width: 48px; - height: 48px; + width: 40px; + height: 40px; } .login-logo { @@ -159,11 +166,13 @@ body { display: flex; flex-direction: column; width: 100%; - height: 100%; - max-height: 100vh; - padding: 1.25rem 1.5rem; + min-height: 100%; + height: auto; + max-height: none; + padding: var(--page-pad); + padding-bottom: 1.25rem; box-sizing: border-box; - overflow: hidden; + overflow: visible; animation: fadeIn 0.35s ease-out; } @@ -171,10 +180,14 @@ body { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; - padding-bottom: 0.85rem; + margin-bottom: 0.65rem; + padding-bottom: 0.55rem; border-bottom: 1px solid var(--border-color); flex-shrink: 0; + position: sticky; + top: 0; + z-index: 20; + background: var(--bg-main); } .brand { @@ -184,15 +197,15 @@ body { } .brand-logo { - width: 38px; - height: 38px; + width: 32px; + height: 32px; background: var(--accent); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; font-weight: 800; - font-size: 0.85rem; + font-size: 0.75rem; color: white; flex-shrink: 0; } @@ -205,25 +218,27 @@ body { .brand-name { font-weight: 800; - font-size: 1.05rem; + font-size: 0.95rem; letter-spacing: -0.3px; color: var(--text-primary); line-height: 1.2; } .brand-sub { - font-size: 0.7rem; + font-size: 0.62rem; font-weight: 500; color: var(--accent); } .main-grid { display: grid; - grid-template-columns: minmax(220px, 260px) 1fr; - gap: 1rem; - flex: 1; + grid-template-columns: minmax(180px, 220px) 1fr; + gap: 0.75rem; + flex: none; min-height: 0; - overflow: hidden; + height: auto; + overflow: visible; + align-items: start; } /* ── Profile Card ── */ @@ -232,25 +247,25 @@ body { flex-direction: column; align-items: center; text-align: center; - height: 100%; + height: auto; min-height: 0; - overflow-y: auto; + overflow: visible; justify-content: flex-start; - padding-top: 1rem; + padding-top: 0.75rem; } .avatar-container { position: relative; - margin-bottom: 1.25rem; + margin-bottom: 0.75rem; } .avatar { - width: 84px; - height: 84px; + width: 68px; + height: 68px; border-radius: 50%; object-fit: cover; - border: 3px solid var(--accent-light); - padding: 3px; + border: 2px solid var(--accent-light); + padding: 2px; background: var(--bg-subtle); } @@ -276,7 +291,7 @@ body { .student-name { margin: 0; - font-size: 1.2rem; + font-size: 1.05rem; font-weight: 700; color: var(--text-primary); letter-spacing: -0.3px; @@ -285,12 +300,12 @@ body { .student-code { background: var(--accent-light); color: var(--accent); - padding: 3px 10px; + padding: 2px 8px; border-radius: 6px; - font-size: 0.8rem; + font-size: 0.72rem; font-weight: 700; - margin-top: 0.4rem; - margin-bottom: 0.5rem; + margin-top: 0.3rem; + margin-bottom: 0.35rem; border: 1px solid rgba(187, 33, 38, 0.15); letter-spacing: 0.3px; display: inline-block; @@ -299,22 +314,22 @@ body { .student-email { margin: 0; color: var(--text-muted); - font-size: 0.82rem; + font-size: 0.75rem; } .divider { width: 100%; height: 1px; background: var(--border-color); - margin: 1.25rem 0; + margin: 0.75rem 0; } .profile-info-row { display: flex; justify-content: space-between; width: 100%; - font-size: 0.82rem; - margin-bottom: 0.6rem; + font-size: 0.75rem; + margin-bottom: 0.4rem; } .profile-info-row span { @@ -330,17 +345,17 @@ body { .right-column { display: flex; flex-direction: column; - gap: 0.85rem; - overflow: hidden; - height: 100%; + gap: 0.65rem; + overflow: visible; + height: auto; min-height: 0; } .status-banner { - padding: 0.75rem 1rem; + padding: 0.55rem 0.75rem; display: flex; align-items: flex-start; - gap: 0.75rem; + gap: 0.55rem; border-left: 3px solid var(--accent); flex-shrink: 0; } @@ -393,13 +408,13 @@ body { .exam-panel { border: 1px solid #c4b5fd; background: linear-gradient(135deg, #faf5ff 0%, #fff 100%); - padding: 1rem 1.15rem; - margin-bottom: 0.75rem; + padding: 0.75rem 0.9rem; + margin-bottom: 0; } .exam-panel-desc { - margin: 0 0 0.85rem; - font-size: 0.85rem; + margin: 0 0 0.65rem; + font-size: 0.78rem; color: var(--text-secondary); } @@ -410,6 +425,38 @@ body { align-items: center; } +.tools-panel { + margin-bottom: 0; + padding: 0.75rem 0.9rem; + border: 1px solid #f5c2c4; + background: linear-gradient(135deg, #fff8f8 0%, #fff 100%); +} + +.tools-panel-desc { + margin: 0 0 0.65rem; + font-size: 0.78rem; + color: var(--text-secondary); +} + +.tools-panel-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.btn-tool { + background: #fff; + color: var(--accent); + border: 1px solid var(--accent); + padding: 0.4rem 0.75rem; + font-size: 0.78rem; +} + +.btn-tool:hover { + background: var(--accent-light); +} + .exam-wait, .exam-done { font-size: 0.82rem; color: var(--text-muted); @@ -608,7 +655,7 @@ body { .stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 0.65rem; + gap: 0.5rem; } .stats-grid--two { @@ -957,9 +1004,10 @@ body { } .shifts-wrap { - flex: 1; + flex: none; min-height: 0; - overflow: auto; + max-height: none; + overflow: visible; } .shifts-table { @@ -1028,14 +1076,14 @@ body { } .status-banner-icon { - width: 36px; - height: 36px; + width: 30px; + height: 30px; background: var(--accent-light); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; - font-size: 1.1rem; + font-size: 0.95rem; flex-shrink: 0; } @@ -1048,7 +1096,7 @@ body { } .status-banner-value { - font-size: 0.9rem; + font-size: 0.82rem; font-weight: 600; color: var(--text-primary); } @@ -1056,19 +1104,19 @@ body { .stat-card { display: flex; align-items: center; - gap: 0.85rem; - padding: 0.9rem 1rem; + gap: 0.65rem; + padding: 0.65rem 0.75rem; } .stat-icon-wrap { - width: 40px; - height: 40px; + width: 34px; + height: 34px; background: var(--bg-subtle); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; - font-size: 1.2rem; + font-size: 1rem; flex-shrink: 0; } @@ -1088,7 +1136,7 @@ body { } .stat-value { - font-size: 0.95rem; + font-size: 0.85rem; font-weight: 700; color: var(--text-primary); white-space: nowrap; @@ -1100,11 +1148,11 @@ body { .clocks-card { display: flex; flex-direction: column; - gap: 0.3rem; - flex: 1; + gap: 0.25rem; + flex: none; min-height: 0; - overflow: hidden; - padding: 0.5rem 0.65rem; + overflow: visible; + padding: 0.45rem 0.55rem; } .card-title { @@ -1161,11 +1209,11 @@ body { /* ── Buttons ── */ .btn { - padding: 0.65rem 1.15rem; + padding: 0.45rem 0.85rem; border-radius: var(--radius-sm); border: none; font-weight: 600; - font-size: 0.875rem; + font-size: 0.78rem; font-family: 'Be Vietnam Pro', sans-serif; cursor: pointer; transition: var(--transition); @@ -1187,8 +1235,8 @@ body { background: var(--bg-subtle); color: var(--text-secondary); border: 1px solid var(--border-color); - padding: 0.5rem 0.9rem; - font-size: 0.82rem; + padding: 0.35rem 0.7rem; + font-size: 0.72rem; } .btn-logout:hover { @@ -1197,6 +1245,69 @@ body { border-color: rgba(214, 48, 49, 0.25); } +/* ── Compact / small screens ── */ +@media (max-width: 860px) { + .main-grid { + grid-template-columns: 1fr; + } + + .profile-card { + flex-direction: row; + flex-wrap: wrap; + text-align: left; + align-items: center; + gap: 0.65rem 0.85rem; + padding-top: 0.55rem; + } + + .avatar-container { + margin-bottom: 0; + } + + .profile-card .student-name, + .profile-card .student-code, + .profile-card .student-email { + text-align: left; + } + + .profile-card .divider { + display: none; + } + + .profile-info-row { + width: auto; + flex: 1 1 100%; + margin-bottom: 0.2rem; + } +} + +@media (max-width: 560px) { + :root { + --page-pad: 0.55rem 0.6rem; + --card-pad: 0.7rem 0.75rem; + } + + .tools-panel-actions, + .exam-panel-actions { + flex-direction: column; + align-items: stretch; + } + + .tools-panel-actions .btn, + .exam-panel-actions .btn { + width: 100%; + } + + .clock-inline { + margin-left: 0; + width: 100%; + } + + .card-title-row--shifts { + flex-wrap: wrap; + } +} + /* ── Animations ── */ @keyframes floatIn { from { opacity: 0; transform: translateY(12px); } diff --git a/client/frontend/src/main.js b/client/frontend/src/main.js index d23ee85..48338d7 100644 --- a/client/frontend/src/main.js +++ b/client/frontend/src/main.js @@ -544,6 +544,18 @@ function renderDashboard() {
${renderExamPanel()}
+
+
+
Công cụ học tập
+
+

GitHub & Google Dịch mở trong app. Trình duyệt Local chỉ chạy link localhost để test bài làm.

+
+ + + +
+
+
📡
@@ -592,6 +604,15 @@ function renderDashboard() { await window.go.main.App.Logout(); } }); + document.getElementById('btn-open-github')?.addEventListener('click', () => { + window.go.main.App.OpenGitHub(); + }); + document.getElementById('btn-open-translate')?.addEventListener('click', () => { + window.go.main.App.OpenGoogleTranslate(); + }); + document.getElementById('btn-open-local-browser')?.addEventListener('click', () => { + window.go.main.App.OpenLocalBrowser(); + }); wireExamPanel(); } diff --git a/client/frontend/src/style.css b/client/frontend/src/style.css index 3c0f7ba..848b99a 100644 --- a/client/frontend/src/style.css +++ b/client/frontend/src/style.css @@ -1,14 +1,18 @@ html { height: 100%; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; } body { margin: 0; - height: 100%; - overflow: hidden; + min-height: 100%; + height: auto; + overflow-x: hidden; + overflow-y: auto; } #app { - height: 100%; + min-height: 100%; + height: auto; } diff --git a/client/frontend/wailsjs/go/main/App.d.ts b/client/frontend/wailsjs/go/main/App.d.ts index fb1d0a8..6bead82 100755 --- a/client/frontend/wailsjs/go/main/App.d.ts +++ b/client/frontend/wailsjs/go/main/App.d.ts @@ -39,6 +39,12 @@ export function OpenExamQuiz():Promise; export function OpenExamResource(arg1:number):Promise; +export function OpenGitHub():Promise; + +export function OpenGoogleTranslate():Promise; + +export function OpenLocalBrowser():Promise; + export function ReloadExamPage():Promise; export function ReturnToDashboard():Promise; diff --git a/client/frontend/wailsjs/go/main/App.js b/client/frontend/wailsjs/go/main/App.js index 5df3982..34ca717 100755 --- a/client/frontend/wailsjs/go/main/App.js +++ b/client/frontend/wailsjs/go/main/App.js @@ -78,26 +78,38 @@ export function OpenExamResource(arg1) { return ObfuscatedCall(18, [arg1]); } -export function ReloadExamPage() { +export function OpenGitHub() { return ObfuscatedCall(19, []); } -export function ReturnToDashboard() { +export function OpenGoogleTranslate() { return ObfuscatedCall(20, []); } -export function SendChatMessage(arg1) { - return ObfuscatedCall(21, [arg1]); +export function OpenLocalBrowser() { + return ObfuscatedCall(21, []); } -export function SendWebcamFrame(arg1) { - return ObfuscatedCall(22, [arg1]); +export function ReloadExamPage() { + return ObfuscatedCall(22, []); } -export function SubmitExamWork() { +export function ReturnToDashboard() { return ObfuscatedCall(23, []); } -export function UnlockChatAudio() { - return ObfuscatedCall(24, []); +export function SendChatMessage(arg1) { + return ObfuscatedCall(24, [arg1]); +} + +export function SendWebcamFrame(arg1) { + return ObfuscatedCall(25, [arg1]); +} + +export function SubmitExamWork() { + return ObfuscatedCall(26, []); +} + +export function UnlockChatAudio() { + return ObfuscatedCall(27, []); } diff --git a/client/internal/singleinstance/singleinstance.go b/client/internal/singleinstance/singleinstance.go new file mode 100644 index 0000000..9dbd9f8 --- /dev/null +++ b/client/internal/singleinstance/singleinstance.go @@ -0,0 +1,11 @@ +package singleinstance + +import "errors" + +// ErrAlreadyRunning — đã có một tiến trình Simple Care đang chạy. +var ErrAlreadyRunning = errors.New("simple care already running") + +const ( + mutexName = "Local\\SimpleCare_SingleInstance" + windowTitle = "Simple Care" +) diff --git a/client/internal/singleinstance/singleinstance_unix.go b/client/internal/singleinstance/singleinstance_unix.go new file mode 100644 index 0000000..0f8c188 --- /dev/null +++ b/client/internal/singleinstance/singleinstance_unix.go @@ -0,0 +1,49 @@ +//go:build unix + +package singleinstance + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "client/internal/winapi" + + "golang.org/x/sys/unix" +) + +// Giữ file lock mở suốt đời process. +var lockFile *os.File + +// Acquire — flock non-blocking trên file trong config dir. +func Acquire() error { + configDir, err := os.UserConfigDir() + if err != nil { + configDir = os.TempDir() + } + dir := filepath.Join(configDir, "SimpleCare") + _ = os.MkdirAll(dir, 0755) + path := filepath.Join(dir, "instance.lock") + + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return err + } + if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + _ = f.Close() + winapi.ActivateAppWindow(windowTitle) + winapi.ShowWarningMessageBox( + "Simple Care", + "Ứng dụng Simple Care đang chạy.\n\nChỉ được mở một cửa sổ.", + ) + // Cho dialog async kịp hiện trước khi process thoát. + time.Sleep(1500 * time.Millisecond) + fmt.Fprintln(os.Stderr, "Simple Care is already running") + return ErrAlreadyRunning + } + _, _ = f.WriteString(fmt.Sprintf("%d\n", os.Getpid())) + _ = f.Sync() + lockFile = f + return nil +} diff --git a/client/internal/singleinstance/singleinstance_windows.go b/client/internal/singleinstance/singleinstance_windows.go new file mode 100644 index 0000000..7e76ecc --- /dev/null +++ b/client/internal/singleinstance/singleinstance_windows.go @@ -0,0 +1,47 @@ +//go:build windows + +package singleinstance + +import ( + "syscall" + "unsafe" + + "client/internal/winapi" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procCreateMutexW = kernel32.NewProc("CreateMutexW") + user32 = syscall.NewLazyDLL("user32.dll") + procMessageBoxW = user32.NewProc("MessageBoxW") + + // Giữ handle mutex suốt đời process — đóng = nhả lock. + mutexHandle uintptr +) + +const errorAlreadyExists = 183 + +// Acquire — chỉ cho phép 1 instance. Instance thứ 2: đưa cửa sổ cũ lên rồi báo lỗi. +func Acquire() error { + namePtr, err := syscall.UTF16PtrFromString(mutexName) + if err != nil { + return err + } + r1, _, lastErr := procCreateMutexW.Call(0, 0, uintptr(unsafe.Pointer(namePtr))) + if r1 == 0 { + return lastErr + } + mutexHandle = r1 + if errno, ok := lastErr.(syscall.Errno); ok && errno == errorAlreadyExists { + winapi.ActivateAppWindow(windowTitle) + showAlreadyRunningDialog() + return ErrAlreadyRunning + } + return nil +} + +func showAlreadyRunningDialog() { + title, _ := syscall.UTF16PtrFromString("Simple Care") + msg, _ := syscall.UTF16PtrFromString("Ứng dụng Simple Care đang chạy.\n\nChỉ được mở một cửa sổ. Cửa sổ hiện có đã được đưa lên phía trước.") + _, _, _ = procMessageBoxW.Call(0, uintptr(unsafe.Pointer(msg)), uintptr(unsafe.Pointer(title)), 0x00000040) // MB_ICONINFORMATION +} diff --git a/client/internal/winapi/window_darwin.go b/client/internal/winapi/window_darwin.go index f7c11e2..a54d22c 100644 --- a/client/internal/winapi/window_darwin.go +++ b/client/internal/winapi/window_darwin.go @@ -4,14 +4,23 @@ package winapi import ( "fmt" - "os" "os/exec" ) -// ActivateAppWindow — đưa cửa sổ app lên trước +// ActivateAppWindow — đưa cửa sổ Simple Care đang chạy lên trước (theo tên process / title). func ActivateAppWindow(titleHint string) { - pid := os.Getpid() - script := fmt.Sprintf(`tell application "System Events" to set frontmost of first process whose unix id is %d to true`, pid) + hint := titleHint + if hint == "" { + hint = "Simple Care" + } + script := fmt.Sprintf(` +tell application "System Events" + set candidates to every process whose name contains %q or name contains "simple_care" or name contains "SimpleCare" + if (count of candidates) > 0 then + set frontmost of item 1 of candidates to true + end if +end tell +`, hint) cmd := exec.Command("osascript", "-e", script) _ = cmd.Run() } diff --git a/client/main.go b/client/main.go index b5fecd1..827332a 100644 --- a/client/main.go +++ b/client/main.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" + "client/internal/singleinstance" + "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/menu" "github.com/wailsapp/wails/v2/pkg/menu/keys" @@ -34,6 +36,10 @@ func initLogging() *os.File { } func main() { + if err := singleinstance.Acquire(); err != nil { + os.Exit(0) + } + lf := initLogging() if lf != nil { defer lf.Close()