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": {}, } type firefoxBundleInstallPlan struct { TotalPackageCount int HasFirefoxPackage bool BaseSystemPackages []string } 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 { if path, err := exec.LookPath("firefox"); err == nil { kioskDebugf("firefox install path check ok=true path=%s source=PATH", path) return true } for _, path := range []string{ "/usr/bin/firefox", "/usr/local/bin/firefox", "/snap/bin/firefox", } { if isExecutableFile(path) { kioskDebugf("firefox install path check ok=true path=%s source=fallback", path) return true } } kioskDebugf("firefox install path check ok=false") return false } func isExecutableFile(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() && info.Mode()&0111 != 0 } func installFirefox() error { bundlePath := firefoxBundlePath() plan, err := validateFirefoxBundle(bundlePath) if err != nil { kioskErrorf("firefox install refused bundle=%s err=%v", bundlePath, err) return err } baseSystemPackages := strings.Join(plan.BaseSystemPackages, " ") 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 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 "=== current work directory ===" pwd echo "" echo "=== keep existing Firefox package ===" echo "skip automatic dpkg purge to avoid touching base system packages" echo "" echo "=== clean previous offline bundle files ===" rm -rf /opt/firefox-deb /opt/firefox-installable /opt/firefox-deb.tar.gz 2>/dev/null echo "" 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. extract offline bundle..." cd /opt && tar -xzvf firefox-deb.tar.gz || { echo "error: extract offline bundle failed"; exit 1; } echo "" echo "3. filter Firefox deb packages..." base_system_packages=%s mkdir -p /opt/firefox-installable selected_count=0 skipped_installed_count=0 selected_missing_base_count=0 selected_regular_count=0 firefox_selected=0 select_deb() { cp -v "$deb" /opt/firefox-installable/ selected_count=$((selected_count + 1)) } package_name_from_deb() { pkg=$(dpkg-deb -f "$1" Package 2>/dev/null || true) if [ -n "$pkg" ]; then echo "$pkg" return 0 fi file=$(basename "$1") echo "$file" | cut -d '_' -f 1 | sed 's/[.]deb$//' } for deb in /opt/firefox-deb/*.deb; do [ -e "$deb" ] || continue file=$(basename "$deb") pkg=$(package_name_from_deb "$deb") if [ -z "$pkg" ]; then echo "skip package with empty name: $file" continue fi echo "package candidate: $file package=$pkg" status=$(dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null || true) installed=0 if [ "$status" = "install ok installed" ]; then installed=1 fi if [ "$pkg" = "firefox" ]; then select_deb firefox_selected=1 continue fi case " $base_system_packages " in *" $pkg "*) if [ "$installed" -eq 1 ]; then echo "skip installed base system package: $file" skipped_installed_count=$((skipped_installed_count + 1)) continue fi echo "select missing base system package: $file" select_deb selected_missing_base_count=$((selected_missing_base_count + 1)) continue ;; esac if [ "$installed" -eq 1 ]; then echo "skip installed package: $file" skipped_installed_count=$((skipped_installed_count + 1)) continue fi select_deb selected_regular_count=$((selected_regular_count + 1)) done echo "selected packages: $selected_count, selected missing base: $selected_missing_base_count, selected regular: $selected_regular_count, skipped installed: $skipped_installed_count" if [ "$selected_count" -eq 0 ]; then echo "error: no packages selected for install" exit 1 fi if [ "$firefox_selected" -ne 1 ]; then echo "error: Firefox deb package was not selected" exit 1 fi echo "" echo "4. install selected Firefox packages..." cd /opt/firefox-installable && sudo dpkg -i ./*.deb || { echo "error: install selected Firefox deb packages failed"; exit 1; } echo "" echo "5. verify Firefox..." firefox --version || { echo "error: Firefox verification failed"; exit 1; } echo "" echo "=== Firefox install complete ===" `, shellQuote(bundlePath), shellQuote(baseSystemPackages))) 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 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) (firefoxBundleInstallPlan, error) { packages, err := firefoxBundlePackageNames(bundlePath) if err != nil { return firefoxBundleInstallPlan{}, fmt.Errorf("read firefox bundle packages: %w", err) } if len(packages) == 0 { return firefoxBundleInstallPlan{}, fmt.Errorf("firefox bundle has no deb packages") } plan := firefoxBundleInstallPlanFromPackages(packages) if !plan.HasFirefoxPackage { return firefoxBundleInstallPlan{}, fmt.Errorf("firefox bundle has no firefox deb package") } if len(plan.BaseSystemPackages) > 0 { kioskWarnf("firefox bundle contains base system packages; installed ones will be skipped and missing ones may be installed packages=%s", strings.Join(plan.BaseSystemPackages, ", ")) } kioskInfof("firefox bundle validation passed bundle=%s totalPackages=%d baseSystemPackages=%d hasFirefox=%v", bundlePath, plan.TotalPackageCount, len(plan.BaseSystemPackages), plan.HasFirefoxPackage) return plan, nil } func firefoxBundleInstallPlanFromPackages(packages []string) firefoxBundleInstallPlan { plan := firefoxBundleInstallPlan{ TotalPackageCount: len(packages), BaseSystemPackages: unsafeFirefoxDebPackages(packages), } for _, name := range packages { if name == "firefox" { plan.HasFirefoxPackage = true } } return plan } 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, "'", "'\\''") + "'" }