diff --git a/apps/m9zCtrlTty/autostart.go b/apps/m9zCtrlTty/autostart.go index 0e9fa62..43dfb99 100644 --- a/apps/m9zCtrlTty/autostart.go +++ b/apps/m9zCtrlTty/autostart.go @@ -28,8 +28,10 @@ func enableAutoStart(appPath string) error { content := autoStartServiceContent(appPath) kioskDebugf("autostart service content:\n%s", content) - cmd := exec.Command("sudo", "bash", "-c", - fmt.Sprintf("echo '%s' > %s", content, serviceFile)) + cmd := exec.Command("sudo", "tee", serviceFile) + cmd.Stdin = strings.NewReader(content) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { kioskErrorf("write autostart service failed service=%s err=%v", serviceFile, err) return err @@ -64,7 +66,8 @@ 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 +Wants=network-online.target +After=network-online.target graphical.target display-manager.service [Service] Type=simple @@ -73,9 +76,10 @@ Restart=on-failure RestartSec=10 User=root WorkingDirectory=%s +Environment=DISPLAY=:0 [Install] -WantedBy=multi-user.target +WantedBy=graphical.target `, appName, appPath, @@ -85,9 +89,22 @@ WantedBy=multi-user.target func autoStartServiceMatches(content string, appPath string) bool { execStart := normalizeSystemdValue(systemdUnitValue(content, "ExecStart")) workingDirectory := normalizeSystemdValue(systemdUnitValue(content, "WorkingDirectory")) + wants := normalizeSystemdValue(systemdUnitValue(content, "Wants")) + after := normalizeSystemdValue(systemdUnitValue(content, "After")) + environment := normalizeSystemdValue(systemdUnitValue(content, "Environment")) + wantedBy := normalizeSystemdValue(systemdUnitValue(content, "WantedBy")) 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) + expectedWants := "network-online.target" + expectedAfter := "network-online.target graphical.target display-manager.service" + expectedEnvironment := "DISPLAY=:0" + expectedWantedBy := "graphical.target" + matches := execStart == appPath && + workingDirectory == expectedWorkdir && + wants == expectedWants && + after == expectedAfter && + environment == expectedEnvironment && + wantedBy == expectedWantedBy + kioskInfof("autostart service fields execStart=%s expectedExec=%s workingDirectory=%s expectedWorkdir=%s wants=%s expectedWants=%s after=%s expectedAfter=%s environment=%s expectedEnvironment=%s wantedBy=%s expectedWantedBy=%s matches=%v", execStart, appPath, workingDirectory, expectedWorkdir, wants, expectedWants, after, expectedAfter, environment, expectedEnvironment, wantedBy, expectedWantedBy, matches) return matches } diff --git a/apps/m9zCtrlTty/autostart_test.go b/apps/m9zCtrlTty/autostart_test.go new file mode 100644 index 0000000..bfcef09 --- /dev/null +++ b/apps/m9zCtrlTty/autostart_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestAutoStartServiceContentWaitsForGraphicalTarget(t *testing.T) { + appPath := filepath.Join("/opt", "tgk-touch", "m9zCtrlTty") + content := autoStartServiceContent(appPath) + + for _, want := range []string{ + "Wants=network-online.target", + "After=network-online.target graphical.target display-manager.service", + "Environment=DISPLAY=:0", + "WantedBy=graphical.target", + } { + if !strings.Contains(content, want) { + t.Fatalf("service content missing %q:\n%s", want, content) + } + } +} + +func TestAutoStartServiceMatchesRejectsOldNetworkOnlyService(t *testing.T) { + appPath := filepath.Join("/opt", "tgk-touch", "m9zCtrlTty") + oldContent := `[Unit] +Description=m9zCtrlTty Background Service +After=network.target + +[Service] +Type=simple +ExecStart=` + appPath + ` +Restart=on-failure +RestartSec=10 +User=root +WorkingDirectory=` + filepath.Dir(appPath) + ` + +[Install] +WantedBy=multi-user.target +` + + if autoStartServiceMatches(oldContent, appPath) { + t.Fatal("old network-only service should be treated as mismatched") + } +} + +func TestAutoStartServiceMatchesGeneratedContent(t *testing.T) { + appPath := filepath.Join("/opt", "tgk-touch", "m9zCtrlTty") + content := autoStartServiceContent(appPath) + + if !autoStartServiceMatches(content, appPath) { + t.Fatalf("generated service content should match:\n%s", content) + } +} diff --git a/apps/m9zCtrlTty/firefox_install.go b/apps/m9zCtrlTty/firefox_install.go index ca8cdb0..01c16dc 100644 --- a/apps/m9zCtrlTty/firefox_install.go +++ b/apps/m9zCtrlTty/firefox_install.go @@ -1,15 +1,51 @@ package main import ( + "archive/tar" + "compress/gzip" "fmt" + "io" "os" "os/exec" + "path/filepath" + "sort" "strings" + "time" "github.com/towgo/towgo/lib/system" g "tgk-touch/internal/global" ) +const firefoxInstallMarkerName = ".firefox-install-ok" + +var forbiddenFirefoxDebPackages = map[string]struct{}{ + "base-files": {}, + "base-passwd": {}, + "bash": {}, + "coreutils": {}, + "dash": {}, + "debconf": {}, + "dpkg": {}, + "gcc-8-base": {}, + "libblkid1": {}, + "libc6": {}, + "libgcc1": {}, + "libmount1": {}, + "libselinux1": {}, + "libsystemd0": {}, + "libudev1": {}, + "login": {}, + "mount": {}, + "passwd": {}, + "perl-base": {}, + "sudo": {}, + "systemd": {}, + "systemd-sysv": {}, + "tar": {}, + "udev": {}, + "util-linux": {}, +} + func isFirefoxUsable() bool { cmd := exec.Command("firefox", "--version") out, err := cmd.CombinedOutput() @@ -27,6 +63,12 @@ func isFirefoxInstalled() bool { } func installFirefox() error { + bundlePath := firefoxBundlePath() + if err := validateFirefoxBundle(bundlePath); err != nil { + kioskErrorf("firefox install refused bundle=%s err=%v", bundlePath, err) + return err + } + var p struct { DebPkgPath string `json:"DebPkgPath"` DebPkgName string `json:"DebPkgName"` @@ -35,40 +77,40 @@ 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) + kioskInfof("firefox install begin programPath=%s bundle=%s debPath=%s debName=%s installShell=%s", system.GetPathOfProgram(), bundlePath, p.DebPkgPath, p.DebPkgName, p.InstallShellPath) command := exec.Command("sudo", "sh", "-c", fmt.Sprintf(` -echo "=== 当前工作目录 ===" +echo "=== current work directory ===" pwd echo "" -echo "=== 卸载旧版 Firefox ===" -dpkg -P firefox || echo "警告:卸载旧版 Firefox 失败(可能未安装)" +echo "=== keep existing Firefox package ===" +echo "skip automatic dpkg purge to avoid touching base system packages" echo "" -echo "=== 清理残留文件 ===" -rm -rf /opt/firefox* 2>/dev/null +echo "=== clean previous offline bundle files ===" +rm -rf /opt/firefox-deb /opt/firefox-deb.tar.gz 2>/dev/null echo "" -echo "=== 开始安装 Firefox ===" -echo "1. 复制安装包到 /opt 目录..." -cp -v %s%s/%s /opt/firefox-deb.tar.gz || { echo "错误:复制安装包失败"; exit 1; } +echo "=== start Firefox install ===" +echo "1. copy offline bundle to /opt..." +cp -v %s /opt/firefox-deb.tar.gz || { echo "error: copy offline bundle failed"; exit 1; } echo "" -echo "2. 解压安装包..." -cd /opt && tar -xzvf firefox-deb.tar.gz || { echo "错误:解压失败"; exit 1; } +echo "2. extract offline bundle..." +cd /opt && tar -xzvf firefox-deb.tar.gz || { echo "error: extract offline bundle failed"; exit 1; } echo "" -echo "3. 安装依赖和主程序..." -cd /opt/firefox-deb && sudo dpkg -i *.deb || { echo "错误:安装 deb 包失败"; exit 1; } +echo "3. install Firefox deb packages..." +cd /opt/firefox-deb && sudo dpkg -i *.deb || { echo "error: install Firefox deb packages failed"; exit 1; } echo "" -echo "4. 验证安装..." -firefox --version || { echo "错误:Firefox 未正确安装"; exit 1; } +echo "4. verify Firefox..." +firefox --version || { echo "error: Firefox verification failed"; exit 1; } echo "" -echo "=== Firefox 安装完成 ===" -`, system.GetPathOfProgram(), p.DebPkgPath, p.DebPkgName)) +echo "=== Firefox install complete ===" +`, shellQuote(bundlePath))) command.Stdout = os.Stdout command.Stderr = os.Stderr @@ -80,3 +122,116 @@ echo "=== Firefox 安装完成 ===" kioskInfof("firefox install command finished successfully") return nil } + +func firefoxInstallMarkerPath() string { + return filepath.Join(system.GetPathOfProgram(), "data", firefoxInstallMarkerName) +} + +func hasFirefoxInstallMarker() bool { + path := firefoxInstallMarkerPath() + info, err := os.Stat(path) + ok := err == nil && !info.IsDir() + kioskInfof("firefox install marker check path=%s ok=%v err=%v", path, ok, err) + return ok +} + +func writeFirefoxInstallMarker(reason string) error { + path := firefoxInstallMarkerPath() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + kioskErrorf("create firefox install marker dir failed path=%s err=%v", filepath.Dir(path), err) + return err + } + + content := fmt.Sprintf("reason=%s\ntime=%s\n", reason, time.Now().Format(time.RFC3339)) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + kioskErrorf("write firefox install marker failed path=%s err=%v", path, err) + return err + } + kioskInfof("firefox install marker written path=%s reason=%s", path, reason) + return nil +} + +func firefoxBundlePath() string { + return filepath.Join(system.GetPathOfProgram(), g.Config().Firefox.DebPkgPath, g.Config().Firefox.DebPkgName) +} + +func validateFirefoxBundle(bundlePath string) error { + packages, err := firefoxBundlePackageNames(bundlePath) + if err != nil { + return fmt.Errorf("read firefox bundle packages: %w", err) + } + if len(packages) == 0 { + return fmt.Errorf("firefox bundle has no deb packages") + } + + blocked := unsafeFirefoxDebPackages(packages) + if len(blocked) > 0 { + return fmt.Errorf("firefox bundle contains blocked base system packages: %s", strings.Join(blocked, ", ")) + } + + kioskInfof("firefox bundle validation passed bundle=%s packages=%d", bundlePath, len(packages)) + return nil +} + +func firefoxBundlePackageNames(bundlePath string) ([]string, error) { + file, err := os.Open(bundlePath) + if err != nil { + return nil, err + } + defer file.Close() + + gzipReader, err := gzip.NewReader(file) + if err != nil { + return nil, err + } + defer gzipReader.Close() + + reader := tar.NewReader(gzipReader) + var packages []string + for { + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if header == nil || header.FileInfo().IsDir() || !strings.HasSuffix(header.Name, ".deb") { + continue + } + name := debPackageName(filepath.Base(header.Name)) + if name != "" { + packages = append(packages, name) + } + } + sort.Strings(packages) + return packages, nil +} + +func debPackageName(fileName string) string { + name := strings.TrimSuffix(fileName, ".deb") + if packageName, _, ok := strings.Cut(name, "_"); ok { + return packageName + } + return name +} + +func unsafeFirefoxDebPackages(packages []string) []string { + seen := make(map[string]struct{}) + for _, name := range packages { + if _, blocked := forbiddenFirefoxDebPackages[name]; blocked { + seen[name] = struct{}{} + } + } + + blocked := make([]string, 0, len(seen)) + for name := range seen { + blocked = append(blocked, name) + } + sort.Strings(blocked) + return blocked +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} diff --git a/apps/m9zCtrlTty/firefox_install_test.go b/apps/m9zCtrlTty/firefox_install_test.go new file mode 100644 index 0000000..adcb2d7 --- /dev/null +++ b/apps/m9zCtrlTty/firefox_install_test.go @@ -0,0 +1,48 @@ +package main + +import "testing" + +func TestDebPackageName(t *testing.T) { + tests := map[string]string{ + "firefox_120.0_arm64.deb": "firefox", + "libgtk-3-0_3.22.30_arm64.deb": "libgtk-3-0", + "firefox.deb": "firefox", + "plain-package": "plain-package", + } + + for input, want := range tests { + if got := debPackageName(input); got != want { + t.Fatalf("debPackageName(%q) = %q, want %q", input, got, want) + } + } +} + +func TestUnsafeFirefoxDebPackages(t *testing.T) { + packages := []string{ + "firefox", + "libgtk-3-0", + "dpkg", + "coreutils", + "libc6", + "tar", + "dpkg", + } + want := []string{"coreutils", "dpkg", "libc6", "tar"} + + got := unsafeFirefoxDebPackages(packages) + if !stringSlicesEqual(got, want) { + t.Fatalf("unsafeFirefoxDebPackages() = %v, want %v", got, want) + } +} + +func stringSlicesEqual(left []string, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} diff --git a/apps/m9zCtrlTty/firefox_kiosk.go b/apps/m9zCtrlTty/firefox_kiosk.go index e46b1e5..9b67410 100644 --- a/apps/m9zCtrlTty/firefox_kiosk.go +++ b/apps/m9zCtrlTty/firefox_kiosk.go @@ -51,6 +51,16 @@ func runClient(port int) { for { os.Setenv("DISPLAY", display) + if !isDisplaySocketReady(display) { + kioskWarnf("display socket not ready display=%s; waiting before firefox launch", display) + time.Sleep(2 * time.Second) + continue + } + if !isFirefoxUsable() { + kioskWarnf("firefox unusable during kiosk loop; waiting before firefox launch") + time.Sleep(10 * time.Second) + continue + } if xauthPath := findXAuthorityPath(currentUser, display); xauthPath != "" { os.Setenv("XAUTHORITY", xauthPath) kioskInfof("xauthority selected path=%s", xauthPath) @@ -92,6 +102,28 @@ func runClient(port int) { } } +func isDisplaySocketReady(display string) bool { + path := displaySocketPath(display) + info, err := os.Stat(path) + ok := err == nil && !info.IsDir() + kioskDebugf("display socket check display=%s path=%s ok=%v err=%v", display, path, ok, err) + return ok +} + +func displaySocketPath(display string) string { + name := strings.TrimSpace(display) + if idx := strings.LastIndex(name, ":"); idx >= 0 { + name = name[idx+1:] + } + if idx := strings.Index(name, "."); idx >= 0 { + name = name[:idx] + } + if name == "" { + name = "0" + } + return filepath.Join("/tmp/.X11-unix", "X"+name) +} + func findXAuthorityPath(currentUser *user.User, display string) string { kioskDebugf("xauthority search begin display=%s", display) candidates := []string{ diff --git a/apps/m9zCtrlTty/install.go b/apps/m9zCtrlTty/install.go index b452ad4..c995d76 100644 --- a/apps/m9zCtrlTty/install.go +++ b/apps/m9zCtrlTty/install.go @@ -8,42 +8,54 @@ import ( func install() error { 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) - return fmt.Errorf("install/repair Firefox failed: %w", err) - } - kioskInfof("firefox install/repair success") - fmt.Println("Firefox install/repair success") - } else { + var firefoxInstallErr error + if isFirefoxUsable() { kioskInfof("firefox is usable") fmt.Println("Firefox is installed and usable") + if err := writeFirefoxInstallMarker("usable"); err != nil { + kioskWarnf("write firefox install marker failed err=%v", err) + } + } else if hasFirefoxInstallMarker() { + kioskWarnf("firefox unusable but install marker exists; skip automatic repair") + fmt.Println("Firefox unusable; skip automatic repair on startup") + } else { + kioskWarnf("firefox unusable and no install marker; starting first-time install") + fmt.Println("Firefox missing or unusable, installing...") + if err := installFirefox(); err != nil { + kioskErrorf("firefox first-time install failed err=%v", err) + fmt.Printf("install Firefox failed: %v\n", err) + firefoxInstallErr = fmt.Errorf("install Firefox failed: %w", err) + } else { + if err := writeFirefoxInstallMarker("installed"); err != nil { + kioskWarnf("write firefox install marker after install failed err=%v", err) + } + kioskInfof("firefox first-time install success") + fmt.Println("Firefox install success") + } } - // 2. 设置当前应用开机自启动 appPath, err := os.Executable() if err != nil { kioskErrorf("read executable path failed err=%v", err) - fmt.Printf("读取应用路径失败: %v\n", err) + fmt.Printf("read executable path failed: %v\n", err) return fmt.Errorf("read executable path failed: %w", err) } 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("正在设置开机自启动...") + fmt.Println("setting autostart...") if err := enableAutoStart(appPath); err != nil { kioskErrorf("autostart setup failed service=%s err=%v", autoStartServiceFile(appPath), err) - fmt.Printf("设置开机自启动失败: %v\n", err) + fmt.Printf("setup autostart failed: %v\n", err) return fmt.Errorf("setup autostart failed: %w", err) } - fmt.Println("开机自启动设置成功") + fmt.Println("autostart setup success") } else { - fmt.Println("已设置开机自启动") + fmt.Println("autostart already configured") + } + if firefoxInstallErr != nil { + return firefoxInstallErr } return nil }