diff --git a/client/build.sh b/client/build.sh index d2e89bf..ba28ada 100755 --- a/client/build.sh +++ b/client/build.sh @@ -5,7 +5,7 @@ set -e cd "$(dirname "$0")" echo "=============================================" -echo " BUILDING SECURE WAILS CLIENT FOR MACOS " +echo " BUILDING SECURE WAILS CLIENT " echo "=============================================" # Setup Go path for tools like garble and wails @@ -46,10 +46,21 @@ else fi BUILD_FLAGS+=(-ldflags "-s -w") -echo "[*] Building macOS Apple Silicon (arm64)..." -"$WAILS_CMD" build -platform darwin/arm64 "${BUILD_FLAGS[@]}" +# Detect host OS +HOST_OS=$(go env GOOS) -echo "[*] Building macOS Intel (amd64)..." -"$WAILS_CMD" build -platform darwin/amd64 "${BUILD_FLAGS[@]}" +if [ "$HOST_OS" = "darwin" ]; then + echo "[*] Building macOS Apple Silicon (arm64)..." + "$WAILS_CMD" build -platform darwin/arm64 "${BUILD_FLAGS[@]}" -echo "[+] macOS arm64 and amd64 builds completed successfully!" + echo "[*] Building macOS Intel (amd64)..." + "$WAILS_CMD" build -platform darwin/amd64 "${BUILD_FLAGS[@]}" + echo "[+] macOS arm64 and amd64 builds completed successfully!" +elif [ "$HOST_OS" = "linux" ]; then + echo "[*] Building Linux Intel/AMD64 (amd64)..." + "$WAILS_CMD" build -platform linux/amd64 -tags webkit2_41 "${BUILD_FLAGS[@]}" + echo "[+] Linux amd64 build completed successfully!" +else + echo "[!] Unsupported host OS: $HOST_OS. Please build manually using 'wails build'." + exit 1 +fi diff --git a/client/build/trim.txt b/client/build/trim.txt new file mode 100644 index 0000000..63746f1 --- /dev/null +++ b/client/build/trim.txt @@ -0,0 +1 @@ +1782971860 \ No newline at end of file diff --git a/client/internal/blocker/blocker_linux.go b/client/internal/blocker/blocker_linux.go new file mode 100644 index 0000000..f026d6a --- /dev/null +++ b/client/internal/blocker/blocker_linux.go @@ -0,0 +1,214 @@ +//go:build linux + +package blocker + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" +) + +var systemAllowed = map[string]bool{ + "gnome-shell": true, + "mutter": true, + "xterm": true, + "gnome-terminal": true, + "konsole": true, + "ptyxis": true, // Ubuntu terminal + "bash": true, + "zsh": true, + "sh": true, + "wails": true, + "code": true, + "cursor": true, + "windsurf": true, + "goland": true, + "idea": true, + "client": true, + "simple_care_v1.0": true, + "xwayland": true, + "mutter-x11-fram": true, + "ibus-x11": true, + "ibus-daemon": true, + "gsd-": true, // prefix + "ibus-": true, // prefix + "xdg-": true, // prefix + "at-spi": true, // prefix + "evolution-": true, // prefix + "goa-daemon": true, + "gjs": true, + "snapd-": true, // prefix + "systemd": true, + "webkit": true, // WebKit subprocesses + "glycin": true, // GNOME image helper + "zenity": true, // Dialog utility + "rustdesk": true, // Remote support safety + "rustdesk-bin": true, + "anydesk": true, + "teamviewer": true, + "remmina": true, +} + +func isSystemAllowed(name string) bool { + name = strings.ToLower(name) + if systemAllowed[name] { + return true + } + for pattern := range systemAllowed { + if strings.HasPrefix(name, pattern) && pattern != name { + return true + } + } + return false +} + +func getPPID(pid uint32) uint32 { + statusBytes, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) + if err != nil { + return 0 + } + lines := strings.Split(string(statusBytes), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "PPid:") { + parts := strings.Fields(line) + if len(parts) >= 2 { + if ppid, err := strconv.ParseUint(parts[1], 10, 32); err == nil { + return uint32(ppid) + } + } + } + } + return 0 +} + +func isDescendantOf(pid, targetPid uint32) bool { + curr := pid + for i := 0; i < 10; i++ { // limits lookup to 10 ancestor levels + ppid := getPPID(curr) + if ppid == 0 { + return false + } + if ppid == targetPid { + return true + } + curr = ppid + } + return false +} + +type ProcessInfo struct { + PID uint32 + Name string +} + +func getVisibleProcesses() (map[uint32]ProcessInfo, error) { + files, err := os.ReadDir("/proc") + if err != nil { + return nil, err + } + + procs := make(map[uint32]ProcessInfo) + for _, f := range files { + if !f.IsDir() { + continue + } + pid, err := strconv.ParseUint(f.Name(), 10, 32) + if err != nil { + continue + } + + mapsPath := fmt.Sprintf("/proc/%d/maps", pid) + mapsBytes, err := os.ReadFile(mapsPath) + if err != nil { + // Skip processes we don't own (permission denied) + continue + } + + mapsStr := string(mapsBytes) + isGUI := strings.Contains(mapsStr, "libgtk") || + strings.Contains(mapsStr, "libQt") || + strings.Contains(mapsStr, "libX11") || + strings.Contains(mapsStr, "libwayland-client") + + if !isGUI { + continue + } + + // Read process name from /proc/PID/comm + commBytes, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) + if err != nil { + continue + } + procName := strings.TrimSpace(string(commBytes)) + + if procName != "" { + procs[uint32(pid)] = ProcessInfo{ + PID: uint32(pid), + Name: procName, + } + } + } + + return procs, nil +} + +func (b *Blocker) checkAndKill() { + b.mu.Lock() + keywords := make([]string, len(b.allowedKeywords)) + copy(keywords, b.allowedKeywords) + b.mu.Unlock() + + if len(keywords) == 0 { + return + } + + currentExec := "" + if execPath, err := os.Executable(); err == nil { + currentExec = strings.ToLower(filepath.Base(execPath)) + } + + procs, err := getVisibleProcesses() + if err != nil { + log.Printf("[BLOCKER] Failed to get visible processes: %v", err) + return + } + + myPid := uint32(os.Getpid()) + for pid, info := range procs { + pNameLower := strings.ToLower(info.Name) + + // 1. Always allow our app, our sub-processes, system/critical developer tools, or agent helpers + isOurSubprocess := pid == myPid || isDescendantOf(pid, myPid) + isAntigravity := strings.Contains(pNameLower, "antigravity") + if isOurSubprocess || (currentExec != "" && pNameLower == currentExec) || isSystemAllowed(pNameLower) || isAntigravity { + continue + } + + // 2. Check if the process name contains any allowed keywords + allowed := false + for _, kw := range keywords { + if matchesAllowedKeyword(kw, pNameLower, pNameLower) { + allowed = true + break + } + } + + // 3. If not allowed, kill the application + if !allowed { + if b.OnBlocked != nil { + b.OnBlocked(info.Name, info.Name) + } + log.Printf("[BLOCKER] KILLED unauthorized application: %s (PID: %d)", info.Name, pid) + proc, err := os.FindProcess(int(pid)) + if err == nil { + errKill := proc.Kill() + if errKill == nil && b.OnKill != nil { + b.OnKill(info.Name, info.Name) + } + } + } + } +} diff --git a/client/internal/blocker/blocker_other.go b/client/internal/blocker/blocker_other.go index 8a28577..7d7fd0e 100644 --- a/client/internal/blocker/blocker_other.go +++ b/client/internal/blocker/blocker_other.go @@ -1,4 +1,4 @@ -//go:build !windows && !darwin +//go:build !windows && !darwin && !linux package blocker diff --git a/client/internal/guard/guard_linux.go b/client/internal/guard/guard_linux.go new file mode 100644 index 0000000..4eda870 --- /dev/null +++ b/client/internal/guard/guard_linux.go @@ -0,0 +1,90 @@ +//go:build linux + +package guard + +import ( + "sync" + "time" + + "github.com/kbinani/screenshot" +) + +var ( + guardOnce sync.Once + guardViolation func(string) + guardStop chan struct{} + suppressMu sync.Mutex + suppressViolationsUntil time.Time +) + +// SuppressFor temporarily suspends environment violation checks (e.g. when transition screen) +func SuppressFor(d time.Duration) { + if d <= 0 { + return + } + suppressMu.Lock() + next := time.Now().Add(d) + if next.After(suppressViolationsUntil) { + suppressViolationsUntil = next + } + suppressMu.Unlock() +} + +func violationsSuppressed() bool { + suppressMu.Lock() + defer suppressMu.Unlock() + return time.Now().Before(suppressViolationsUntil) +} + +// Start monitors the Linux desktop environment (multi-display check) +func Start(onViolation func(reason string)) { + guardOnce.Do(func() { + if onViolation == nil { + return + } + guardViolation = onViolation + 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.") + return + } + + go pollLoop() + }) +} + +// Stop stops the environment guard +func Stop() { + if guardStop != nil { + select { + case <-guardStop: + // already closed + default: + 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 || violationsSuppressed() { + 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ụ.") + return + } +} diff --git a/client/internal/guard/guard_other.go b/client/internal/guard/guard_other.go index e5fce7b..74d99a6 100644 --- a/client/internal/guard/guard_other.go +++ b/client/internal/guard/guard_other.go @@ -1,4 +1,4 @@ -//go:build !windows && !darwin +//go:build !windows && !darwin && !linux package guard diff --git a/client/internal/winapi/window_linux.go b/client/internal/winapi/window_linux.go new file mode 100644 index 0000000..22422e4 --- /dev/null +++ b/client/internal/winapi/window_linux.go @@ -0,0 +1,40 @@ +//go:build linux + +package winapi + +import ( + "log" + "os/exec" +) + +// ActivateAppWindow attempts to focus the application window. +// On Linux standard X11/Wayland desktop, focus is managed by the WM. +func ActivateAppWindow(titleHint string) { + log.Printf("[WINDOW] ActivateAppWindow requested for: %s", titleHint) +} + +// PlayNotifySound plays a system notification sound using canberra-gtk-play, falling back to pw-play or aplay. +func PlayNotifySound() { + log.Println("[WINDOW] Playing notification sound...") + go func() { + // Try canberra-gtk-play first + cmd := exec.Command("canberra-gtk-play", "-i", "bell") + if err := cmd.Run(); err != nil { + // Fallback to pw-play (Pipewire) + cmd = exec.Command("pw-play", "/usr/share/sounds/freedesktop/stereo/bell.oga") + if err := cmd.Run(); err != nil { + // Fallback to aplay (ALSA) + _ = exec.Command("aplay", "/usr/share/sounds/alsa/Front_Center.wav").Run() + } + } + }() +} + +// ShowWarningMessageBox displays an asynchronous GUI warning dialog using zenity. +func ShowWarningMessageBox(title, message string) { + log.Printf("[WINDOW] Warning Message Box: %s - %s", title, message) + go func() { + cmd := exec.Command("zenity", "--warning", "--title="+title, "--text="+message, "--no-wrap") + _ = cmd.Run() + }() +} diff --git a/client/internal/winapi/window_other.go b/client/internal/winapi/window_other.go index f751ad3..41d8927 100644 --- a/client/internal/winapi/window_other.go +++ b/client/internal/winapi/window_other.go @@ -1,4 +1,4 @@ -//go:build !windows && !darwin +//go:build !windows && !darwin && !linux package winapi