164 lines
4.7 KiB
Go
164 lines
4.7 KiB
Go
package browser
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"chub/internal/domain"
|
|
)
|
|
|
|
var (
|
|
ErrExecutableNotFound = errors.New("browser executable was not found")
|
|
ErrUnsafeArgument = errors.New("browser argument is controlled or unsafe")
|
|
)
|
|
|
|
type Discoverer struct {
|
|
getenv func(string) string
|
|
stat func(string) (fs.FileInfo, error)
|
|
}
|
|
|
|
func NewDiscoverer() *Discoverer { return &Discoverer{getenv: os.Getenv, stat: os.Stat} }
|
|
|
|
func (d *Discoverer) Resolve(ctx context.Context, kind domain.BrowserKind, configured string) (string, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
if d == nil || d.getenv == nil || d.stat == nil || !kind.Valid() {
|
|
return "", ErrExecutableNotFound
|
|
}
|
|
if configured = strings.TrimSpace(configured); configured != "" {
|
|
if path, ok := d.executable(configured); ok {
|
|
return path, nil
|
|
}
|
|
return "", fmt.Errorf("%w: configured path is unavailable", ErrExecutableNotFound)
|
|
}
|
|
seen := make(map[string]struct{})
|
|
for _, candidate := range d.candidates(kind) {
|
|
cleaned := filepath.Clean(candidate)
|
|
key := strings.ToLower(cleaned)
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[key]; exists {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
if path, ok := d.executable(cleaned); ok {
|
|
return path, nil
|
|
}
|
|
}
|
|
return "", ErrExecutableNotFound
|
|
}
|
|
|
|
func (d *Discoverer) candidates(kind domain.BrowserKind) []string {
|
|
var suffixes []string
|
|
switch kind {
|
|
case domain.BrowserChrome:
|
|
suffixes = []string{`Google\Chrome\Application\chrome.exe`}
|
|
case domain.BrowserEdge:
|
|
suffixes = []string{`Microsoft\Edge\Application\msedge.exe`}
|
|
}
|
|
var candidates []string
|
|
for _, root := range []string{"PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"} {
|
|
for _, suffix := range suffixes {
|
|
candidates = append(candidates, filepath.Join(d.getenv(root), suffix))
|
|
}
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
func (d *Discoverer) executable(path string) (string, bool) {
|
|
cleaned := filepath.Clean(strings.TrimSpace(path))
|
|
if cleaned == "." || !filepath.IsAbs(cleaned) {
|
|
return "", false
|
|
}
|
|
info, err := d.stat(cleaned)
|
|
if err != nil || info.IsDir() || !info.Mode().IsRegular() {
|
|
return "", false
|
|
}
|
|
return cleaned, true
|
|
}
|
|
|
|
func BuildArgs(spec domain.LaunchSpec) ([]string, error) {
|
|
normalized, err := spec.Normalize()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
proxy, err := normalizeProxy(normalized.ProxyServer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
args := []string{
|
|
"--user-data-dir=" + normalized.UserDataDir,
|
|
"--no-first-run",
|
|
"--disable-default-apps",
|
|
}
|
|
if normalized.RemoteDebugPort > 0 {
|
|
args = append(args,
|
|
"--remote-debugging-address=127.0.0.1",
|
|
"--remote-debugging-port="+strconv.Itoa(normalized.RemoteDebugPort),
|
|
)
|
|
}
|
|
if normalized.ProfileDirectory != "" {
|
|
args = append(args, "--profile-directory="+normalized.ProfileDirectory)
|
|
}
|
|
if normalized.Headless {
|
|
args = append(args, "--headless=new")
|
|
}
|
|
if proxy != "" {
|
|
args = append(args, "--proxy-server="+proxy)
|
|
}
|
|
for _, extra := range normalized.ExtraArgs {
|
|
if err := validateExtraArg(extra); err != nil {
|
|
return nil, err
|
|
}
|
|
args = append(args, extra)
|
|
}
|
|
if normalized.TargetURL != "" {
|
|
args = append(args, normalized.TargetURL)
|
|
}
|
|
return args, nil
|
|
}
|
|
|
|
func normalizeProxy(value string) (string, error) {
|
|
proxy := strings.TrimSpace(value)
|
|
if proxy == "" {
|
|
return "", nil
|
|
}
|
|
parsed, err := url.Parse(proxy)
|
|
if err != nil || parsed.User != nil || parsed.Hostname() == "" || parsed.Port() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return "", fmt.Errorf("%w: proxy must be scheme://host:port without credentials", domain.ErrInvalidLaunchSpec)
|
|
}
|
|
scheme := strings.ToLower(parsed.Scheme)
|
|
if scheme != "http" && scheme != "https" && scheme != "socks4" && scheme != "socks5" {
|
|
return "", fmt.Errorf("%w: unsupported proxy scheme", domain.ErrInvalidLaunchSpec)
|
|
}
|
|
port, err := strconv.Atoi(parsed.Port())
|
|
if err != nil || port < 1 || port > 65535 {
|
|
return "", fmt.Errorf("%w: proxy port out of range", domain.ErrInvalidLaunchSpec)
|
|
}
|
|
return scheme + "://" + net.JoinHostPort(strings.ToLower(parsed.Hostname()), strconv.Itoa(port)), nil
|
|
}
|
|
|
|
func validateExtraArg(value string) error {
|
|
arg := strings.TrimSpace(value)
|
|
if arg == "" || !strings.HasPrefix(arg, "--") {
|
|
return fmt.Errorf("%w: extra arguments must be non-empty flags", ErrUnsafeArgument)
|
|
}
|
|
name := strings.ToLower(strings.SplitN(arg[2:], "=", 2)[0])
|
|
for _, controlled := range []string{"user-data-dir", "profile-directory", "proxy-server", "remote-debugging-address", "remote-debugging-port"} {
|
|
if name == controlled {
|
|
return fmt.Errorf("%w: --%s is controlled by LaunchSpec", ErrUnsafeArgument, controlled)
|
|
}
|
|
}
|
|
return nil
|
|
}
|