package main import ( "encoding/json" "fmt" "net/http" "os" "os/exec" "os/user" "path/filepath" "strings" "time" ) const firefoxUserPrefs = `// Managed by tgk-touch kiosk startup. Do not edit by hand. user_pref("app.normandy.first_run", false); user_pref("browser.aboutConfig.showWarning", false); user_pref("browser.crashReports.unsubmittedCheck.autoSubmit2", false); user_pref("browser.crashReports.unsubmittedCheck.enabled", false); user_pref("browser.disableResetPrompt", true); user_pref("browser.download.panel.shown", true); user_pref("browser.rights.3.shown", true); user_pref("browser.sessionstore.max_resumed_crashes", 0); user_pref("browser.sessionstore.resume_from_crash", false); user_pref("browser.shell.checkDefaultBrowser", false); user_pref("browser.startup.homepage", "about:blank"); user_pref("browser.startup.page", 0); user_pref("browser.tabs.warnOnClose", false); user_pref("browser.warnOnQuitShortcut", false); user_pref("datareporting.healthreport.uploadEnabled", false); user_pref("datareporting.policy.dataSubmissionEnabled", false); user_pref("signon.autofillForms", false); user_pref("signon.rememberSignons", false); user_pref("toolkit.startup.max_resumed_crashes", 999999); user_pref("toolkit.telemetry.reportingpolicy.firstRun", false); ` func runClient(port int) { display := ":0" kioskInfof("firefox client loop init display=%s port=%d", display, port) currentUser, err := user.Current() if err != nil { kioskErrorf("read current user failed err=%v", err) fmt.Printf("读取当前用户失败: %v\n", err) os.Exit(1) } profileDir := filepath.Join(currentUser.HomeDir, ".tgk-touch-firefox-profile") clientURL := "http://127.0.0.1:" + fmt.Sprintf("%d", port) remoteDebugPort := port + 10000 kioskInfof("firefox client config user=%s home=%s profile=%s url=%s remoteDebugPort=%d", currentUser.Username, currentUser.HomeDir, profileDir, clientURL, remoteDebugPort) for { os.Setenv("DISPLAY", display) if xauthPath := findXAuthorityPath(currentUser, display); xauthPath != "" { os.Setenv("XAUTHORITY", xauthPath) kioskInfof("xauthority selected path=%s", xauthPath) } else { os.Unsetenv("XAUTHORITY") kioskWarnf("xauthority not found; XAUTHORITY unset") } if err := prepareFirefoxKioskProfile(profileDir); err != nil { kioskErrorf("prepare firefox profile failed profile=%s err=%v", profileDir, err) time.Sleep(5 * time.Second) continue } if err := closeExistingFirefox(); err != nil { kioskErrorf("close existing firefox failed err=%v", err) } args := []string{ "--new-instance", "--profile", profileDir, "--remote-debugging-port", fmt.Sprintf("%d", remoteDebugPort), "--kiosk", clientURL, } cmd := exec.Command("firefox", args...) cmd.Env = firefoxKioskEnv() cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr kioskInfof("starting firefox args=%v", args) if err := runFirefoxWithWatchdog(cmd, clientURL, remoteDebugPort); err != nil { kioskErrorf("firefox watchdog returned err=%v", err) if resetErr := resetFirefoxKioskProfile(profileDir); resetErr != nil { kioskErrorf("reset firefox profile after failure failed profile=%s err=%v", profileDir, resetErr) } time.Sleep(5 * time.Second) continue } } } func findXAuthorityPath(currentUser *user.User, display string) string { kioskDebugf("xauthority search begin display=%s", display) candidates := []string{ os.Getenv("XAUTHORITY"), filepath.Join("/var/run/lightdm/root", display), filepath.Join("/run/lightdm/root", display), } if currentUser != nil && currentUser.HomeDir != "" { candidates = append(candidates, filepath.Join(currentUser.HomeDir, ".Xauthority")) } seen := make(map[string]struct{}, len(candidates)) for _, candidate := range candidates { if candidate == "" { continue } if _, ok := seen[candidate]; ok { continue } seen[candidate] = struct{}{} if isUsableXAuthority(candidate) { kioskInfof("xauthority candidate usable path=%s", candidate) return candidate } kioskDebugf("xauthority candidate unusable path=%s", candidate) } for _, pattern := range []string{ "/run/user/*/gdm/Xauthority", "/run/user/*/.Xauthority", "/run/user/*/Xauthority", "/home/*/.Xauthority", "/root/.Xauthority", "/var/lib/lightdm/.Xauthority", "/var/lib/gdm/.Xauthority", "/var/lib/gdm3/.Xauthority", "/tmp/xauth_*", "/tmp/.Xauthority*", } { if match := newestUsableXAuthority(pattern); match != "" { kioskInfof("xauthority glob matched pattern=%s path=%s", pattern, match) return match } kioskDebugf("xauthority glob no usable match pattern=%s", pattern) } kioskWarnf("xauthority search exhausted display=%s", display) return "" } func newestUsableXAuthority(pattern string) string { matches, err := filepath.Glob(pattern) if err != nil { kioskDebugf("xauthority glob invalid pattern=%s err=%v", pattern, err) return "" } kioskDebugf("xauthority glob pattern=%s matches=%d", pattern, len(matches)) var newestPath string var newestModTime time.Time for _, match := range matches { info, err := os.Stat(match) if err != nil || info.IsDir() || info.Size() == 0 { continue } if newestPath == "" || info.ModTime().After(newestModTime) { newestPath = match newestModTime = info.ModTime() } } return newestPath } func isUsableXAuthority(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() && info.Size() > 0 } func closeExistingFirefox() error { names := []string{"firefox", "firefox-esr", "firefox-bin"} kioskInfof("closing existing firefox processes names=%v", names) for _, name := range names { err := exec.Command("pkill", "-TERM", "-x", name).Run() kioskDebugf("pkill TERM name=%s err=%v", name, err) } deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if !isAnyProcessRunning(names) { kioskInfof("existing firefox processes closed after TERM") return nil } kioskDebugf("waiting firefox processes to exit after TERM") time.Sleep(500 * time.Millisecond) } kioskWarnf("firefox still running after TERM; sending KILL") for _, name := range names { err := exec.Command("pkill", "-KILL", "-x", name).Run() kioskDebugf("pkill KILL name=%s err=%v", name, err) } for i := 0; i < 10; i++ { if !isAnyProcessRunning(names) { kioskInfof("existing firefox processes closed after KILL") return nil } kioskDebugf("waiting firefox processes to exit after KILL attempt=%d", i+1) time.Sleep(200 * time.Millisecond) } kioskErrorf("firefox process still running after kill") return fmt.Errorf("firefox process still running after kill") } func isAnyProcessRunning(names []string) bool { for _, name := range names { if exec.Command("pgrep", "-x", name).Run() == nil { kioskDebugf("process running name=%s", name) return true } } return false } func runFirefoxWithWatchdog(cmd *exec.Cmd, clientURL string, remoteDebugPort int) error { kioskInfof("firefox process start begin url=%s remoteDebugPort=%d", clientURL, remoteDebugPort) if err := cmd.Start(); err != nil { kioskErrorf("firefox process start failed err=%v", err) return err } if cmd.Process != nil { kioskInfof("firefox process started pid=%d", cmd.Process.Pid) } done := make(chan error, 1) go func() { done <- cmd.Wait() }() startedAt := time.Now() ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for { select { case err := <-done: kioskWarnf("firefox process exited err=%v", err) return err case <-ticker.C: healthy, err := isFirefoxKioskHealthy(clientURL, remoteDebugPort) if err != nil && time.Since(startedAt) < 30*time.Second { kioskDebugf("firefox health check pending during startup grace elapsed=%s err=%v", time.Since(startedAt).Round(time.Second), err) continue } if err != nil || !healthy { if err == nil { err = fmt.Errorf("firefox is not showing kiosk page in fullscreen") } kioskWarnf("firefox health check failed elapsed=%s healthy=%v err=%v; closing firefox", time.Since(startedAt).Round(time.Second), healthy, err) if closeErr := closeExistingFirefox(); closeErr != nil { kioskErrorf("close firefox after health failure failed err=%v", closeErr) } select { case waitErr := <-done: if waitErr != nil { kioskWarnf("firefox stopped after health failure waitErr=%v", waitErr) return fmt.Errorf("%w; firefox stopped: %v", err, waitErr) } kioskWarnf("firefox stopped after health failure") return err case <-time.After(5 * time.Second): kioskWarnf("firefox did not exit within watchdog close timeout") return err } } kioskDebugf("firefox health check ok elapsed=%s", time.Since(startedAt).Round(time.Second)) } } } func isFirefoxKioskHealthy(clientURL string, remoteDebugPort int) (bool, error) { hasKioskURL, err := firefoxHasKioskURL(clientURL, remoteDebugPort) if err != nil || !hasKioskURL { kioskWarnf("firefox kiosk url check failed hasKioskURL=%v err=%v", hasKioskURL, err) return false, err } fullscreen, err := firefoxHasFullscreenWindow() if err != nil { kioskWarnf("firefox fullscreen check errored err=%v", err) return false, err } if !fullscreen { kioskWarnf("firefox fullscreen check failed") } return fullscreen, nil } type firefoxDebugTarget struct { Type string `json:"type"` URL string `json:"url"` } func firefoxHasKioskURL(clientURL string, remoteDebugPort int) (bool, error) { httpClient := http.Client{Timeout: 2 * time.Second} debugURL := fmt.Sprintf("http://127.0.0.1:%d/json/list", remoteDebugPort) kioskDebugf("firefox remote debug request url=%s", debugURL) resp, err := httpClient.Get(debugURL) if err != nil { kioskDebugf("firefox remote debug request failed url=%s err=%v", debugURL, err) return false, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { kioskWarnf("firefox remote debug bad status url=%s status=%d", debugURL, resp.StatusCode) return false, fmt.Errorf("firefox remote debugging returned status %d", resp.StatusCode) } var targets []firefoxDebugTarget if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil { kioskWarnf("firefox remote debug json decode failed url=%s err=%v", debugURL, err) return false, err } kioskDebugf("firefox remote debug targets=%d expectedURL=%s", len(targets), clientURL) for _, target := range targets { kioskDebugf("firefox target type=%s url=%s", target.Type, target.URL) if target.Type == "page" && isKioskURL(target.URL, clientURL) { kioskDebugf("firefox kiosk url matched targetURL=%s", target.URL) return true, nil } } kioskWarnf("firefox kiosk url not found expectedURL=%s targets=%d", clientURL, len(targets)) return false, nil } func isKioskURL(rawURL string, clientURL string) bool { base := strings.TrimRight(clientURL, "/") url := strings.TrimSpace(rawURL) return url == base || strings.HasPrefix(url, base+"/") || strings.HasPrefix(url, base+"#") || strings.HasPrefix(url, base+"?") } func firefoxHasFullscreenWindow() (bool, error) { if !isCommandAvailable("xdotool") || !isCommandAvailable("xprop") { kioskDebugf("fullscreen check skipped because xdotool/xprop unavailable") return true, nil } windowIDs := make(map[string]struct{}) for _, className := range []string{"firefox", "firefox-esr", "Navigator"} { out, err := exec.Command("xdotool", "search", "--onlyvisible", "--class", className).Output() if err != nil { kioskDebugf("xdotool search failed class=%s err=%v", className, err) continue } for _, id := range strings.Fields(string(out)) { windowIDs[id] = struct{}{} } } if len(windowIDs) == 0 { kioskWarnf("fullscreen check found no visible firefox windows") return false, nil } kioskDebugf("fullscreen check candidate windows=%d", len(windowIDs)) for id := range windowIDs { out, err := exec.Command("xprop", "-id", id, "_NET_WM_STATE").Output() if err != nil { kioskDebugf("xprop failed window=%s err=%v", id, err) continue } if strings.Contains(string(out), "_NET_WM_STATE_FULLSCREEN") { kioskDebugf("fullscreen window matched window=%s", id) return true, nil } } kioskWarnf("no firefox window is fullscreen") return false, nil } func isCommandAvailable(name string) bool { ok := exec.Command("which", name).Run() == nil kioskDebugf("command availability name=%s ok=%v", name, ok) return ok } func firefoxKioskEnv() []string { env := make([]string, 0, len(os.Environ())+4) filteredKeys := make([]string, 0) for _, item := range os.Environ() { key, _, ok := strings.Cut(item, "=") if !ok { continue } if key == "MOZ_SAFE_MODE_RESTART" || key == "MOZ_CRASHREPORTER_RESTART_ARG_0" || strings.HasPrefix(key, "MOZ_CRASHREPORTER_RESTART_ARG_") { filteredKeys = append(filteredKeys, key) continue } env = append(env, item) } env = append(env, "MOZ_DISABLE_SAFE_MODE_KEY=1", "MOZ_CRASHREPORTER_DISABLE=1", "MOZ_CRASHREPORTER_NO_REPORT=1", ) kioskDebugf("firefox env prepared filteredKeys=%v addedKeys=%v total=%d", filteredKeys, []string{"MOZ_DISABLE_SAFE_MODE_KEY", "MOZ_CRASHREPORTER_DISABLE", "MOZ_CRASHREPORTER_NO_REPORT"}, len(env)) return env } func prepareFirefoxKioskProfile(profileDir string) error { kioskInfof("prepare firefox kiosk profile begin profile=%s", profileDir) if err := os.MkdirAll(profileDir, 0755); err != nil { kioskErrorf("create firefox kiosk profile failed profile=%s err=%v", profileDir, err) return fmt.Errorf("create firefox kiosk profile: %w", err) } if err := os.WriteFile(filepath.Join(profileDir, "user.js"), []byte(firefoxUserPrefs), 0644); err != nil { kioskErrorf("write firefox kiosk prefs failed profile=%s err=%v", profileDir, err) return fmt.Errorf("write firefox kiosk prefs: %w", err) } kioskInfof("firefox kiosk prefs written profile=%s", profileDir) if err := cleanFirefoxRecoveryState(profileDir); err != nil { kioskErrorf("clean firefox recovery state failed profile=%s err=%v", profileDir, err) return fmt.Errorf("clean firefox recovery state: %w", err) } kioskInfof("prepare firefox kiosk profile done profile=%s", profileDir) return nil } func resetFirefoxKioskProfile(profileDir string) error { kioskWarnf("reset firefox kiosk profile begin profile=%s", profileDir) if profileDir == "" || filepath.Base(profileDir) != ".tgk-touch-firefox-profile" { kioskErrorf("refuse to reset unexpected firefox profile path=%s", profileDir) return fmt.Errorf("refuse to reset unexpected firefox profile path: %s", profileDir) } if err := os.RemoveAll(profileDir); err != nil { kioskErrorf("remove firefox kiosk profile failed profile=%s err=%v", profileDir, err) return fmt.Errorf("reset firefox kiosk profile: %w", err) } kioskInfof("firefox kiosk profile removed profile=%s", profileDir) return prepareFirefoxKioskProfile(profileDir) } func cleanFirefoxRecoveryState(profileDir string) error { kioskInfof("clean firefox recovery state begin profile=%s", profileDir) for _, name := range []string{ ".parentlock", ".startup-incomplete", "lock", "parent.lock", "sessionCheckpoints.json", "sessionstore.js", "sessionstore.jsonlz4", } { if err := removeIfExists(filepath.Join(profileDir, name)); err != nil { kioskErrorf("remove firefox recovery file failed profile=%s name=%s err=%v", profileDir, name, err) return err } } backupDir := filepath.Join(profileDir, "sessionstore-backups") entries, err := os.ReadDir(backupDir) if err != nil { if os.IsNotExist(err) { kioskDebugf("firefox session backup dir not found path=%s", backupDir) return nil } kioskErrorf("read firefox session backup dir failed path=%s err=%v", backupDir, err) return err } kioskDebugf("firefox session backup entries=%d path=%s", len(entries), backupDir) for _, entry := range entries { if entry.IsDir() { continue } if err := removeIfExists(filepath.Join(backupDir, entry.Name())); err != nil { kioskErrorf("remove firefox session backup failed path=%s name=%s err=%v", backupDir, entry.Name(), err) return err } } kioskInfof("clean firefox recovery state done profile=%s", profileDir) return nil } func removeIfExists(path string) error { err := os.Remove(path) if err == nil || os.IsNotExist(err) { if err == nil { kioskDebugf("removed file path=%s", path) } else { kioskDebugf("remove skipped; file not found path=%s", path) } return nil } kioskErrorf("remove file failed path=%s err=%v", path, err) return err }