You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

854 lines
28 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

package main
import (
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/towgo/towgo/lib/system"
"github.com/towgo/towgo/lib/www"
"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() {
if exception := recover(); exception != nil {
if v, ok := exception.(error); ok && gerror.HasStack(v) {
zap.S().Errorf("err %+v \n", v)
} else {
zap.S().Errorf("recover exception %+v\n", gerror.NewCodef(tcode.CodeInternalPanic, "%+v", exception))
}
}
}()
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
if serialPortAddress == "" {
kioskErrorf("tty serialPortAddress is empty")
panic(gerror.Wrap(err, "串口地址未配置 tty.serialPortAddress"))
}
var baudRateint int = g.Config().Tty.BaudRate
if baudRateint == 0 {
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,
)
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
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()
}
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()
}
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), `"'`)
}