diff --git a/apps/m9zCtrlTty/autostart.go b/apps/m9zCtrlTty/autostart.go new file mode 100644 index 0000000..0e9fa62 --- /dev/null +++ b/apps/m9zCtrlTty/autostart.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func isAutoStartEnabled(appPath string) bool { + serviceFile := autoStartServiceFile(appPath) + kioskInfof("autostart check begin service=%s expectedExec=%s expectedWorkdir=%s", serviceFile, appPath, filepath.Dir(appPath)) + + content, err := os.ReadFile(serviceFile) + if err != nil { + kioskWarnf("autostart service read failed service=%s err=%v", serviceFile, err) + return false + } + matches := autoStartServiceMatches(string(content), appPath) + kioskInfof("autostart service match result service=%s matches=%v", serviceFile, matches) + return matches +} + +func enableAutoStart(appPath string) error { + serviceFile := autoStartServiceFile(appPath) + kioskInfof("autostart setup begin service=%s exec=%s workdir=%s", serviceFile, appPath, filepath.Dir(appPath)) + + content := autoStartServiceContent(appPath) + kioskDebugf("autostart service content:\n%s", content) + cmd := exec.Command("sudo", "bash", "-c", + fmt.Sprintf("echo '%s' > %s", content, serviceFile)) + if err := cmd.Run(); err != nil { + kioskErrorf("write autostart service failed service=%s err=%v", serviceFile, err) + return err + } + kioskInfof("write autostart service success service=%s", serviceFile) + + cmd = exec.Command("sudo", "systemctl", "daemon-reload") + if err := cmd.Run(); err != nil { + kioskErrorf("systemctl daemon-reload failed err=%v", err) + return err + } + kioskInfof("systemctl daemon-reload success") + + cmd = exec.Command("sudo", "systemctl", "enable", autoStartServiceName(appPath)) + if err := cmd.Run(); err != nil { + kioskErrorf("systemctl enable failed service=%s err=%v", autoStartServiceName(appPath), err) + return err + } + kioskInfof("systemctl enable success service=%s", autoStartServiceName(appPath)) + return nil +} + +func autoStartServiceName(appPath string) string { + return strings.TrimSuffix(filepath.Base(appPath), filepath.Ext(appPath)) + ".service" +} + +func autoStartServiceFile(appPath string) string { + return filepath.Join("/etc/systemd/system", autoStartServiceName(appPath)) +} + +func autoStartServiceContent(appPath string) string { + appName := strings.TrimSuffix(filepath.Base(appPath), filepath.Ext(appPath)) + return fmt.Sprintf(`[Unit] +Description=%s Background Service +After=network.target + +[Service] +Type=simple +ExecStart=%s +Restart=on-failure +RestartSec=10 +User=root +WorkingDirectory=%s + +[Install] +WantedBy=multi-user.target +`, + appName, + appPath, + filepath.Dir(appPath)) +} + +func autoStartServiceMatches(content string, appPath string) bool { + execStart := normalizeSystemdValue(systemdUnitValue(content, "ExecStart")) + workingDirectory := normalizeSystemdValue(systemdUnitValue(content, "WorkingDirectory")) + expectedWorkdir := filepath.Dir(appPath) + matches := execStart == appPath && workingDirectory == expectedWorkdir + kioskInfof("autostart service fields execStart=%s expectedExec=%s workingDirectory=%s expectedWorkdir=%s matches=%v", execStart, appPath, workingDirectory, expectedWorkdir, matches) + + return matches +} + +func systemdUnitValue(content string, key string) string { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + name, value, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(name) != key { + continue + } + return strings.TrimSpace(value) + } + return "" +} + +func normalizeSystemdValue(value string) string { + return strings.Trim(strings.TrimSpace(value), `"'`) +} diff --git a/apps/m9zCtrlTty/firefox_install.go b/apps/m9zCtrlTty/firefox_install.go new file mode 100644 index 0000000..ca8cdb0 --- /dev/null +++ b/apps/m9zCtrlTty/firefox_install.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/towgo/towgo/lib/system" + g "tgk-touch/internal/global" +) + +func isFirefoxUsable() bool { + cmd := exec.Command("firefox", "--version") + out, err := cmd.CombinedOutput() + ok := err == nil + kioskInfof("firefox usability check ok=%v output=%q err=%v", ok, strings.TrimSpace(string(out)), err) + return ok +} + +func isFirefoxInstalled() bool { + cmd := exec.Command("which", "firefox") + out, err := cmd.CombinedOutput() + ok := err == nil + kioskDebugf("firefox install path check ok=%v output=%q err=%v", ok, strings.TrimSpace(string(out)), err) + return ok +} + +func installFirefox() error { + var p struct { + DebPkgPath string `json:"DebPkgPath"` + DebPkgName string `json:"DebPkgName"` + InstallShellPath string `json:"InstallShellPath"` + } + p.DebPkgPath = g.Config().Firefox.DebPkgPath + p.DebPkgName = g.Config().Firefox.DebPkgName + p.InstallShellPath = g.Config().Firefox.InstallShellPath + kioskInfof("firefox install begin programPath=%s debPath=%s debName=%s installShell=%s", system.GetPathOfProgram(), p.DebPkgPath, p.DebPkgName, p.InstallShellPath) + + command := exec.Command("sudo", "sh", "-c", fmt.Sprintf(` +echo "=== 当前工作目录 ===" +pwd +echo "" + +echo "=== 卸载旧版 Firefox ===" +dpkg -P firefox || echo "警告:卸载旧版 Firefox 失败(可能未安装)" +echo "" + +echo "=== 清理残留文件 ===" +rm -rf /opt/firefox* 2>/dev/null +echo "" + +echo "=== 开始安装 Firefox ===" +echo "1. 复制安装包到 /opt 目录..." +cp -v %s%s/%s /opt/firefox-deb.tar.gz || { echo "错误:复制安装包失败"; exit 1; } +echo "" + +echo "2. 解压安装包..." +cd /opt && tar -xzvf firefox-deb.tar.gz || { echo "错误:解压失败"; exit 1; } +echo "" + +echo "3. 安装依赖和主程序..." +cd /opt/firefox-deb && sudo dpkg -i *.deb || { echo "错误:安装 deb 包失败"; exit 1; } +echo "" + +echo "4. 验证安装..." +firefox --version || { echo "错误:Firefox 未正确安装"; exit 1; } +echo "" + +echo "=== Firefox 安装完成 ===" +`, system.GetPathOfProgram(), p.DebPkgPath, p.DebPkgName)) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + + err := command.Run() + if err != nil { + kioskErrorf("firefox install command failed err=%v", err) + return err + } + kioskInfof("firefox install command finished successfully") + return nil +} diff --git a/apps/m9zCtrlTty/firefox_kiosk.go b/apps/m9zCtrlTty/firefox_kiosk.go new file mode 100644 index 0000000..e46b1e5 --- /dev/null +++ b/apps/m9zCtrlTty/firefox_kiosk.go @@ -0,0 +1,494 @@ +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 +} diff --git a/apps/m9zCtrlTty/install.go b/apps/m9zCtrlTty/install.go new file mode 100644 index 0000000..9e0c1d6 --- /dev/null +++ b/apps/m9zCtrlTty/install.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +func install() { + kioskInfof("checking firefox usability") + // 1. 检查并修复 Firefox + if !isFirefoxUsable() { + kioskWarnf("firefox unusable; starting install/repair") + fmt.Println("Firefox missing or unusable, installing/repairing...") + if err := installFirefox(); err != nil { + kioskErrorf("firefox install/repair failed err=%v", err) + fmt.Printf("install/repair Firefox failed: %v\n", err) + os.Exit(1) + } + kioskInfof("firefox install/repair success") + fmt.Println("Firefox install/repair success") + } else { + kioskInfof("firefox is usable") + fmt.Println("Firefox is installed and usable") + } + + // 2. 设置当前应用开机自启动 + appPath, err := os.Executable() + if err != nil { + kioskErrorf("read executable path failed err=%v", err) + fmt.Printf("读取应用路径失败: %v\n", err) + os.Exit(1) + } + + kioskInfof("current executable path=%s workdir=%s", appPath, filepath.Dir(appPath)) + if !isAutoStartEnabled(appPath) { + kioskWarnf("autostart service missing or mismatched; reinstalling service=%s", autoStartServiceFile(appPath)) + fmt.Println("正在设置开机自启动...") + if err := enableAutoStart(appPath); err != nil { + kioskErrorf("autostart setup failed service=%s err=%v", autoStartServiceFile(appPath), err) + fmt.Printf("设置开机自启动失败: %v\n", err) + os.Exit(1) + } + fmt.Println("开机自启动设置成功") + } else { + fmt.Println("已设置开机自启动") + } +} diff --git a/apps/m9zCtrlTty/kiosk_log.go b/apps/m9zCtrlTty/kiosk_log.go new file mode 100644 index 0000000..9a3eb0d --- /dev/null +++ b/apps/m9zCtrlTty/kiosk_log.go @@ -0,0 +1,21 @@ +package main + +import g "tgk-touch/internal/global" + +const kioskLogPrefix = "[touch-kiosk]" + +func kioskInfof(format string, args ...interface{}) { + g.Log().Infof(kioskLogPrefix+" "+format, args...) +} + +func kioskDebugf(format string, args ...interface{}) { + g.Log().Debugf(kioskLogPrefix+" "+format, args...) +} + +func kioskWarnf(format string, args ...interface{}) { + g.Log().Warnf(kioskLogPrefix+" "+format, args...) +} + +func kioskErrorf(format string, args ...interface{}) { + g.Log().Errorf(kioskLogPrefix+" "+format, args...) +} diff --git a/apps/m9zCtrlTty/m9zCtrlTty.go b/apps/m9zCtrlTty/m9zCtrlTty.go index 8bde6be..30b3b1b 100644 --- a/apps/m9zCtrlTty/m9zCtrlTty.go +++ b/apps/m9zCtrlTty/m9zCtrlTty.go @@ -1,74 +1,22 @@ package main import ( - "encoding/json" "fmt" + "log" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/towgo/towgo/errors/tcode" "github.com/towgo/towgo/lib/system" "github.com/towgo/towgo/lib/www" + "github.com/towgo/towgo/towgo" "go.uber.org/zap" - "log" - "net/http" - "os" - "os/exec" - "os/user" - "path/filepath" - "strings" + "tgk-touch/internal/core" g "tgk-touch/internal/global" "tgk-touch/internal/initialize" "tgk-touch/internal/module/maincontrollerClient" - "time" - - "github.com/towgo/towgo/errors/tcode" - "github.com/towgo/towgo/towgo" -) - -var ( - productNumber string = "LampServer" ) -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); -` - -const kioskLogPrefix = "[touch-kiosk]" - -func kioskInfof(format string, args ...interface{}) { - g.Log().Infof(kioskLogPrefix+" "+format, args...) -} - -func kioskDebugf(format string, args ...interface{}) { - g.Log().Debugf(kioskLogPrefix+" "+format, args...) -} - -func kioskWarnf(format string, args ...interface{}) { - g.Log().Warnf(kioskLogPrefix+" "+format, args...) -} - -func kioskErrorf(format string, args ...interface{}) { - g.Log().Errorf(kioskLogPrefix+" "+format, args...) -} - func main() { log.SetFlags(log.Lshortfile | log.LstdFlags) defer func() { @@ -82,82 +30,60 @@ func main() { }() appInit() start() - } + func appInit() { initialize.Init() kioskInfof("app initialized") - //initLicense() +} -} /* -func initLicense() { - licenseterminal.SetProductNumber(productNumber) - licenseterminal.AutoFristActive() - licenseterminal.RegExpirationCall(productNumber, func() { - log.Println("产品:" + productNumber + "许可证到期") - list := []string{ - "/account/login", - "/license/activecode/get", - "/license/activecode/online/request", - "/license/getActiveCredential", - "/license/active", - "/license/list", - "/license/accesscode/get", - } - jsonrpc.MethodLockAll(list...) - }) - licenseterminal.RegReNewCall(productNumber, func() { - log.Println("产品:" + productNumber + "许可证续存") - jsonrpc.MethodUnlockAll() - }) -}*/ func start() { kioskInfof("service start begin") - var err error - var serialPortAddress string = g.Config().Tty.SerialPortAddress + serialPortAddress := g.Config().Tty.SerialPortAddress if serialPortAddress == "" { kioskErrorf("tty serialPortAddress is empty") - panic(gerror.Wrap(err, "串口地址未配置 tty.serialPortAddress")) + panic(gerror.New("tty.serialPortAddress is empty")) } - var baudRateint int = g.Config().Tty.BaudRate - if baudRateint == 0 { - zap.S().Info("波特率未配置 tty.baudRate , 使用 默认 9600") - baudRateint = 9600 + + baudRate := g.Config().Tty.BaudRate + if baudRate == 0 { + zap.S().Info("tty.baudRate 未配置,使用默认 9600") + baudRate = 9600 } - kioskInfof("tty config loaded serial=%s baud=%d", serialPortAddress, baudRateint) - towgo.SetFunc("/getMessageInterval", getMessageInterval) - err = maincontrollerClient.UseSerialPort(serialPortAddress, baudRateint) - if err != nil { - kioskErrorf("serial port start failed serial=%s baud=%d err=%v", serialPortAddress, baudRateint, err) + kioskInfof("tty config loaded serial=%s baud=%d", serialPortAddress, baudRate) + + towgo.SetFunc("/getMessageInterval", func(rpcConn towgo.JsonRpcConnection) { + rpcConn.WriteResult(g.Config().MessageInterval) + }) + if err := maincontrollerClient.UseSerialPort(serialPortAddress, baudRate); err != nil { + kioskErrorf("serial port start failed serial=%s baud=%d err=%v", serialPortAddress, baudRate, err) panic(gerror.Wrap(err, "串口启动失败")) } - kioskInfof("serial port started serial=%s baud=%d", serialPortAddress, baudRateint) + kioskInfof("serial port started serial=%s baud=%d", serialPortAddress, baudRate) + webServer := www.WebServer{} webServer.Wwwroot = system.GetPathOfProgram() + "wwwroot" webServer.Index = []string{"index.html"} kioskInfof("web server configured wwwroot=%s index=%v", webServer.Wwwroot, webServer.Index) - g.HttpServerMux().HandleFunc("/", - webServer.WebServerHandller, - ) - g.HttpServerMux().HandleFunc("/jsonrpc/websocket", - towgo.DefaultWebSocketServer.WebsocketServiceHandller.ServeHTTP, - ) - g.HttpServerMux().HandleFunc("/jsonrpc", - towgo.HttpHandller) + + g.HttpServerMux().HandleFunc("/", webServer.WebServerHandller) + g.HttpServerMux().HandleFunc("/jsonrpc/websocket", towgo.DefaultWebSocketServer.WebsocketServiceHandller.ServeHTTP) + g.HttpServerMux().HandleFunc("/jsonrpc", towgo.HttpHandller) towgo.DefaultExec = func(rpcConn towgo.JsonRpcConnection) { if exception := recover(); exception != nil { - var msg string + msg := "unknown panic" if v, ok := exception.(error); ok && gerror.HasStack(v) { g.Log().Error("towgo jsonrpc exception \n", v) msg = v.Error() } else { g.Log().Error("towgo jsonrpc recover exception \n", gerror.NewCodef(tcode.CodeInternalPanic, "%+v", exception)) - msg = v.Error() + msg = fmt.Sprintf("%+v", exception) } rpcConn.WriteError(500, rpcConn.GetRpcRequest().Method+":"+msg) } } + kioskInfof("install checks begin") install() kioskInfof("install checks done; starting firefox client goroutine port=%d", g.Config().Server.Port) @@ -165,689 +91,4 @@ func start() { core.RunServer() } -func getMessageInterval(rpcConn towgo.JsonRpcConnection) { - rpcConn.WriteResult(g.Config().MessageInterval) -} -func install() { - kioskInfof("checking firefox usability") - // 1. 检查并修复 Firefox - if !isFirefoxUsable() { - kioskWarnf("firefox unusable; starting install/repair") - fmt.Println("Firefox missing or unusable, installing/repairing...") - if err := installFirefox(); err != nil { - kioskErrorf("firefox install/repair failed err=%v", err) - fmt.Printf("install/repair Firefox failed: %v\n", err) - os.Exit(1) - } - kioskInfof("firefox install/repair success") - fmt.Println("Firefox install/repair success") - } else { - kioskInfof("firefox is usable") - fmt.Println("Firefox is installed and usable") - } - - // 2. 设置当前应用开机自启动 - appPath, err := os.Executable() - if err != nil { - kioskErrorf("read executable path failed err=%v", err) - fmt.Printf("读取应用路径失败: %v\n", err) - os.Exit(1) - } - - kioskInfof("current executable path=%s workdir=%s", appPath, filepath.Dir(appPath)) - if !isAutoStartEnabled(appPath) { - kioskWarnf("autostart service missing or mismatched; reinstalling service=%s", autoStartServiceFile(appPath)) - fmt.Println("正在设置开机自启动...") - if err := enableAutoStart(appPath); err != nil { - kioskErrorf("autostart setup failed service=%s err=%v", autoStartServiceFile(appPath), err) - fmt.Printf("设置开机自启动失败: %v\n", err) - os.Exit(1) - } - fmt.Println("开机自启动设置成功") - } else { - fmt.Println("已设置开机自启动") - } -} - -func runClient(port int) { - // 配置显示器和Xauthority路径 - 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 - } - // 使用独立 profile,避免断电后默认 profile 的崩溃恢复状态影响 kiosk 启动。 - 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, - } - // 启动Firefox - 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 -} - -// 准备 Firefox kiosk profile。 -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 -} - -// 检查Firefox是否安装 -func isFirefoxUsable() bool { - cmd := exec.Command("firefox", "--version") - out, err := cmd.CombinedOutput() - ok := err == nil - kioskInfof("firefox usability check ok=%v output=%q err=%v", ok, strings.TrimSpace(string(out)), err) - return ok -} - -func isFirefoxInstalled() bool { - cmd := exec.Command("which", "firefox") - out, err := cmd.CombinedOutput() - ok := err == nil - kioskDebugf("firefox install path check ok=%v output=%q err=%v", ok, strings.TrimSpace(string(out)), err) - return ok -} - -// 安装Firefox -func installFirefox() error { - var p struct { - DebPkgPath string `json:"DebPkgPath"` - DebPkgName string `json:"DebPkgName"` - InstallShellPath string `json:"InstallShellPath"` - } - p.DebPkgPath = g.Config().Firefox.DebPkgPath - p.DebPkgName = g.Config().Firefox.DebPkgName - p.InstallShellPath = g.Config().Firefox.InstallShellPath - kioskInfof("firefox install begin programPath=%s debPath=%s debName=%s installShell=%s", system.GetPathOfProgram(), p.DebPkgPath, p.DebPkgName, p.InstallShellPath) - - command := exec.Command("sudo", "sh", "-c", fmt.Sprintf(` -echo "=== 当前工作目录 ===" -pwd -echo "" - -echo "=== 卸载旧版 Firefox ===" -dpkg -P firefox || echo "警告:卸载旧版 Firefox 失败(可能未安装)" -echo "" - -echo "=== 清理残留文件 ===" -rm -rf /opt/firefox* 2>/dev/null -echo "" - -echo "=== 开始安装 Firefox ===" -echo "1. 复制安装包到 /opt 目录..." -cp -v %s%s/%s /opt/firefox-deb.tar.gz || { echo "错误:复制安装包失败"; exit 1; } -echo "" - -echo "2. 解压安装包..." -cd /opt && tar -xzvf firefox-deb.tar.gz || { echo "错误:解压失败"; exit 1; } -echo "" - -echo "3. 安装依赖和主程序..." -cd /opt/firefox-deb && sudo dpkg -i *.deb || { echo "错误:安装 deb 包失败"; } -echo "" - -echo "4. 验证安装..." -firefox --version || { echo "错误:Firefox 未正确安装"; exit 1; } -echo "" - -echo "=== Firefox 安装完成 ===" -`, system.GetPathOfProgram(), p.DebPkgPath, p.DebPkgName)) - // 将命令的 stdout/stderr 直接绑定到当前终端 - command.Stdout = os.Stdout - command.Stderr = os.Stderr - - err := command.Run() - if err != nil { - kioskErrorf("firefox install command failed err=%v", err) - return err - } - kioskInfof("firefox install command finished successfully") - return nil -} -func isAutoStartEnabled(appPath string) bool { - serviceFile := autoStartServiceFile(appPath) - kioskInfof("autostart check begin service=%s expectedExec=%s expectedWorkdir=%s", serviceFile, appPath, filepath.Dir(appPath)) - - content, err := os.ReadFile(serviceFile) - if err != nil { - kioskWarnf("autostart service read failed service=%s err=%v", serviceFile, err) - return false - } - matches := autoStartServiceMatches(string(content), appPath) - kioskInfof("autostart service match result service=%s matches=%v", serviceFile, matches) - return matches -} -func enableAutoStart(appPath string) error { - serviceFile := autoStartServiceFile(appPath) - kioskInfof("autostart setup begin service=%s exec=%s workdir=%s", serviceFile, appPath, filepath.Dir(appPath)) - - // 创建systemd服务文件内容 - content := autoStartServiceContent(appPath) - kioskDebugf("autostart service content:\n%s", content) - // 写入服务文件 - cmd := exec.Command("sudo", "bash", "-c", - fmt.Sprintf("echo '%s' > %s", content, serviceFile)) - if err := cmd.Run(); err != nil { - kioskErrorf("write autostart service failed service=%s err=%v", serviceFile, err) - return err - } - kioskInfof("write autostart service success service=%s", serviceFile) - - // 重新加载systemd配置 - cmd = exec.Command("sudo", "systemctl", "daemon-reload") - if err := cmd.Run(); err != nil { - kioskErrorf("systemctl daemon-reload failed err=%v", err) - return err - } - kioskInfof("systemctl daemon-reload success") - - // 启用服务 - cmd = exec.Command("sudo", "systemctl", "enable", autoStartServiceName(appPath)) - if err := cmd.Run(); err != nil { - kioskErrorf("systemctl enable failed service=%s err=%v", autoStartServiceName(appPath), err) - return err - } - kioskInfof("systemctl enable success service=%s", autoStartServiceName(appPath)) - return nil -} - -func autoStartServiceName(appPath string) string { - return strings.TrimSuffix(filepath.Base(appPath), filepath.Ext(appPath)) + ".service" -} - -func autoStartServiceFile(appPath string) string { - return filepath.Join("/etc/systemd/system", autoStartServiceName(appPath)) -} - -func autoStartServiceContent(appPath string) string { - appName := strings.TrimSuffix(filepath.Base(appPath), filepath.Ext(appPath)) - return fmt.Sprintf(`[Unit] -Description=%s Background Service -After=network.target - -[Service] -Type=simple -ExecStart=%s -Restart=on-failure -RestartSec=10 -User=root -WorkingDirectory=%s - -[Install] -WantedBy=multi-user.target -`, - appName, - appPath, - filepath.Dir(appPath)) -} - -func autoStartServiceMatches(content string, appPath string) bool { - execStart := normalizeSystemdValue(systemdUnitValue(content, "ExecStart")) - workingDirectory := normalizeSystemdValue(systemdUnitValue(content, "WorkingDirectory")) - expectedWorkdir := filepath.Dir(appPath) - matches := execStart == appPath && workingDirectory == expectedWorkdir - kioskInfof("autostart service fields execStart=%s expectedExec=%s workingDirectory=%s expectedWorkdir=%s matches=%v", execStart, appPath, workingDirectory, expectedWorkdir, matches) - - return matches -} - -func systemdUnitValue(content string, key string) string { - for _, line := range strings.Split(content, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { - continue - } - name, value, ok := strings.Cut(line, "=") - if !ok || strings.TrimSpace(name) != key { - continue - } - return strings.TrimSpace(value) - } - return "" -} - -func normalizeSystemdValue(value string) string { - return strings.Trim(strings.TrimSpace(value), `"'`) -} +// TODO Build Cmd CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o m9zCtrlTty-arm64 .