diff --git a/apps/m9zCtrlTty/m9zCtrlTty.go b/apps/m9zCtrlTty/m9zCtrlTty.go index a7ae422..8bde6be 100644 --- a/apps/m9zCtrlTty/m9zCtrlTty.go +++ b/apps/m9zCtrlTty/m9zCtrlTty.go @@ -1,13 +1,14 @@ package main import ( + "encoding/json" "fmt" "github.com/gogf/gf/v2/errors/gerror" - "github.com/towgo/towgo/lib/jsonrpc" "github.com/towgo/towgo/lib/system" "github.com/towgo/towgo/lib/www" "go.uber.org/zap" "log" + "net/http" "os" "os/exec" "os/user" @@ -16,7 +17,6 @@ import ( "tgk-touch/internal/core" g "tgk-touch/internal/global" "tgk-touch/internal/initialize" - licenseterminal "tgk-touch/internal/module/license_terminal" "tgk-touch/internal/module/maincontrollerClient" "time" @@ -28,6 +28,47 @@ 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() { @@ -45,9 +86,10 @@ func main() { } func appInit() { initialize.Init() - initLicense() + kioskInfof("app initialized") + //initLicense() -} +} /* func initLicense() { licenseterminal.SetProductNumber(productNumber) licenseterminal.AutoFristActive() @@ -68,11 +110,13 @@ func initLicense() { log.Println("产品:" + productNumber + "许可证续存") jsonrpc.MethodUnlockAll() }) -} +}*/ func start() { + kioskInfof("service start begin") var err error var serialPortAddress string = g.Config().Tty.SerialPortAddress if serialPortAddress == "" { + kioskErrorf("tty serialPortAddress is empty") panic(gerror.Wrap(err, "串口地址未配置 tty.serialPortAddress")) } var baudRateint int = g.Config().Tty.BaudRate @@ -80,14 +124,18 @@ func start() { zap.S().Info("波特率未配置 tty.baudRate , 使用 默认 9600") baudRateint = 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) panic(gerror.Wrap(err, "串口启动失败")) } + kioskInfof("serial port started serial=%s baud=%d", serialPortAddress, baudRateint) 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, ) @@ -110,7 +158,9 @@ func start() { 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) go runClient(g.Config().Server.Port) core.RunServer() } @@ -119,28 +169,37 @@ func getMessageInterval(rpcConn towgo.JsonRpcConnection) { rpcConn.WriteResult(g.Config().MessageInterval) } func install() { - // 1. 检查并安装Firefox - if !isFirefoxInstalled() { - fmt.Println("Firefox未安装,正在安装...") + 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 { - fmt.Printf("安装Firefox失败: %v\n", err) + kioskErrorf("firefox install/repair failed err=%v", err) + fmt.Printf("install/repair Firefox failed: %v\n", err) os.Exit(1) } - fmt.Println("Firefox安装成功") + kioskInfof("firefox install/repair success") + fmt.Println("Firefox install/repair success") } else { - fmt.Println("Firefox已安装") + 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) } @@ -153,36 +212,58 @@ func install() { 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) } - xauthPath := filepath.Join(currentUser.HomeDir, ".Xauthority") + 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) - os.Setenv("XAUTHORITY", xauthPath) - // 关键:这组参数 = 完全关闭密码管理器 + 禁止保存/填充/弹窗 + 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{ - "--kiosk", - "--noerrdialogs", - "--no-first-run", "--new-instance", - // 👇 下面这 3 个就是禁密码弹窗的核心 - "--disable-features=PasswordManager", - "--pref=signon.rememberSignons=false", - "--pref=signon.autofillForms=false", - "http://127.0.0.1:" + fmt.Sprintf("%d", port), + "--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 := cmd.Run(); err != nil { - g.Log().Error(err.Error()) + 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 } @@ -190,10 +271,423 @@ func runClient(port int) { } } +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") - return cmd.Run() == nil + 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 @@ -206,6 +700,7 @@ func installFirefox() error { 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 "=== 当前工作目录 ===" @@ -243,20 +738,72 @@ echo "=== Firefox 安装完成 ===" command.Stdout = os.Stdout command.Stderr = os.Stderr - return command.Run() + 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 { - desktopFile := fmt.Sprintf("/etc/systemd/system/%s.service", strings.TrimSuffix(filepath.Base(appPath), filepath.Ext(appPath))) + serviceFile := autoStartServiceFile(appPath) + kioskInfof("autostart check begin service=%s expectedExec=%s expectedWorkdir=%s", serviceFile, appPath, filepath.Dir(appPath)) - _, err := os.Stat(desktopFile) - return !os.IsNotExist(err) + 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 { - appName := strings.TrimSuffix(filepath.Base(appPath), filepath.Ext(appPath)) - serviceFile := fmt.Sprintf("/etc/systemd/system/%s.service", appName) + serviceFile := autoStartServiceFile(appPath) + kioskInfof("autostart setup begin service=%s exec=%s workdir=%s", serviceFile, appPath, filepath.Dir(appPath)) // 创建systemd服务文件内容 - content := fmt.Sprintf(`[Unit] + 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 @@ -274,21 +821,33 @@ WantedBy=multi-user.target appName, appPath, filepath.Dir(appPath)) +} - // 写入服务文件 - cmd := exec.Command("sudo", "bash", "-c", - fmt.Sprintf("echo '%s' > %s", content, serviceFile)) - if err := cmd.Run(); err != nil { - return err - } +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) - // 重新加载systemd配置 - cmd = exec.Command("sudo", "systemctl", "daemon-reload") - if err := cmd.Run(); err != nil { - return err + 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 "" +} - // 启用服务 - cmd = exec.Command("sudo", "systemctl", "enable", appName+".service") - return cmd.Run() +func normalizeSystemdValue(value string) string { + return strings.Trim(strings.TrimSpace(value), `"'`) }