diff --git a/README.md b/README.md index 76974b5..61de9cc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,17 @@ # streamed-tui TUI Application for launching streamed.pk feeds + +## Bundled Puppeteer dependencies + +The extractor relies on `puppeteer-extra`, `puppeteer-extra-plugin-stealth`, and `puppeteer`. These Node.js packages are +bundled into the final binary via `internal/assets/node_modules.tar.gz`. To refresh the archive (for example after updating +dependency versions), run: + +``` +scripts/build_node_modules.sh +``` + +The script installs the dependencies into a temporary directory and regenerates the tarball so the Go binary can extract +them at runtime without requiring `npm install` on the target system. When the binary starts it will automatically unpack the +archive into the user's cache directory (or `$TMPDIR` fallback) and point Puppeteer at that cached `node_modules` tree, so the +program can run as a single self-contained executable even when no dependencies exist alongside it. diff --git a/internal/assets/node_modules.tar.gz b/internal/assets/node_modules.tar.gz new file mode 100644 index 0000000..2f627c3 Binary files /dev/null and b/internal/assets/node_modules.tar.gz differ diff --git a/internal/dependencies.go b/internal/dependencies.go new file mode 100644 index 0000000..3bccdc3 --- /dev/null +++ b/internal/dependencies.go @@ -0,0 +1,104 @@ +package internal + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + _ "embed" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +//go:embed assets/node_modules.tar.gz +var embeddedNodeModules []byte + +// ensureEmbeddedNodeModules extracts the bundled Node.js dependencies into a +// deterministic cache directory derived from the archive hash and returns the +// path that contains the resulting node_modules directory. +func ensureEmbeddedNodeModules() (string, error) { + if len(embeddedNodeModules) == 0 { + return "", errors.New("no embedded node modules archive available") + } + + sum := sha256.Sum256(embeddedNodeModules) + hashPrefix := hex.EncodeToString(sum[:8]) + + cacheRoot, err := os.UserCacheDir() + if err != nil { + cacheRoot = os.TempDir() + } + baseDir := filepath.Join(cacheRoot, "streamed-tui", "node_modules", hashPrefix) + + marker := filepath.Join(baseDir, ".complete") + if _, err := os.Stat(marker); err == nil { + return baseDir, nil + } + + if err := os.RemoveAll(baseDir); err != nil { + return "", fmt.Errorf("failed to clear embedded node cache: %w", err) + } + if err := os.MkdirAll(baseDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create embedded node cache: %w", err) + } + + if err := untarGzip(bytes.NewReader(embeddedNodeModules), baseDir); err != nil { + return "", fmt.Errorf("failed to extract embedded node modules: %w", err) + } + + if err := os.WriteFile(marker, []byte(time.Now().Format(time.RFC3339)), 0o644); err != nil { + return "", fmt.Errorf("failed to mark embedded node modules ready: %w", err) + } + + return baseDir, nil +} + +func untarGzip(r io.Reader, dest string) error { + gz, err := gzip.NewReader(r) + if err != nil { + return err + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return err + } + + target := filepath.Join(dest, hdr.Name) + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, os.FileMode(hdr.Mode)); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + default: + // Ignore unsupported entries to keep extraction simple. + } + } + return nil +} diff --git a/internal/extractor.go b/internal/extractor.go index 32f3f46..45104b3 100644 --- a/internal/extractor.go +++ b/internal/extractor.go @@ -71,7 +71,11 @@ func findNodeModuleBase() (string, error) { } } - return "", errors.New("puppeteer-extra not found; install dependencies with npm in the project directory") + if extracted, err := ensureEmbeddedNodeModules(); err == nil { + return extracted, nil + } + + return "", errors.New("puppeteer-extra not found; install dependencies with npm in the project directory or rebuild the embedded archive") } func (l *logBuffer) Write(p []byte) (int, error) { @@ -134,7 +138,11 @@ func ensurePuppeteerAvailable(baseDir string) error { check.Env = append(os.Environ(), fmt.Sprintf("STREAMED_TUI_NODE_BASE=%s", baseDir)) if err := check.Run(); err != nil { - return fmt.Errorf("puppeteer-extra or stealth plugin missing in %s. Run `npm install puppeteer-extra puppeteer-extra-plugin-stealth puppeteer` there: %w", baseDir, err) + if embedded, embErr := ensureEmbeddedNodeModules(); embErr == nil && embedded != baseDir { + return ensurePuppeteerAvailable(embedded) + } + + return fmt.Errorf("puppeteer-extra or stealth plugin missing in %s. Run `npm install puppeteer-extra puppeteer-extra-plugin-stealth puppeteer` there or rebuild the embedded archive with scripts/build_node_modules.sh: %w", baseDir, err) } return nil diff --git a/scripts/build_node_modules.sh b/scripts/build_node_modules.sh new file mode 100755 index 0000000..ca376a3 --- /dev/null +++ b/scripts/build_node_modules.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ASSETS_DIR="$ROOT_DIR/internal/assets" +ARCHIVE="$ASSETS_DIR/node_modules.tar.gz" + +if ! command -v npm >/dev/null 2>&1; then + echo "npm is required to bundle node_modules" >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +pushd "$TMP_DIR" >/dev/null +npm install puppeteer-extra puppeteer-extra-plugin-stealth puppeteer +mkdir -p "$ASSETS_DIR" +tar -czf "$ARCHIVE" node_modules +popd >/dev/null + +echo "Bundled node_modules into $ARCHIVE"