Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Requires:
rodney start # Launch headless Chrome
rodney start --show # Launch with visible browser window
rodney start --insecure # Launch with TLS errors ignored (-k shorthand)
rodney start --viewport 375x812 --mobile --scale 2 # Start with mobile viewport
rodney connect host:9222 # Connect to existing Chrome on remote debug port
rodney status # Show browser info and active page
rodney stop # Shut down Chrome
Expand Down Expand Up @@ -116,10 +117,22 @@ rodney sleep 2.5 # Sleep for N seconds
```bash
rodney screenshot # Save as screenshot.png
rodney screenshot page.png # Save to specific file
rodney screenshot -w 1280 -h 720 out.png # Set viewport width/height
rodney screenshot -w 1280 -h 720 out.png # Override viewport width/height
rodney screenshot-el ".chart" chart.png # Screenshot specific element
```

When a viewport has been set via `rodney viewport`, screenshots use that viewport by default. Pass `-w`/`-h` to override.

### Viewport / mobile emulation

```bash
rodney viewport 375 812 # iPhone-sized viewport
rodney viewport 375 812 --mobile # With mobile emulation (viewport meta, etc.)
rodney viewport 375 812 --mobile --scale 3 # Retina-class device pixel ratio
rodney viewport 1280 720 # Desktop viewport
rodney viewport --reset # Reset to browser default
```

### Manage tabs

```bash
Expand Down Expand Up @@ -403,7 +416,7 @@ The tool uses the [rod](https://github.com/go-rod/rod) Go library which communic

| Command | Arguments | Description |
|---|---|---|
| `start` | `[--show] [--insecure\|-k]` | Launch Chrome (headless by default, `--show` for visible) |
| `start` | `[--show] [--insecure\|-k] [--viewport WxH] [--mobile] [--scale N]` | Launch Chrome (headless by default, `--show` for visible) |
| `connect` | `<host:port>` | Connect to existing Chrome on remote debug port |
| `stop` | | Shut down Chrome |
| `status` | | Show browser status |
Expand Down Expand Up @@ -435,6 +448,8 @@ The tool uses the [rod](https://github.com/go-rod/rod) Go library which communic
| `sleep` | `<seconds>` | Sleep N seconds |
| `screenshot` | `[-w N] [-h N] [file]` | Page screenshot (optional viewport size) |
| `screenshot-el` | `<selector> [file]` | Element screenshot |
| `viewport` | `<width> <height> [--scale N] [--mobile]` | Set browser viewport size |
| `viewport` | `--reset` | Reset viewport to browser default |
| `pages` | | List tabs |
| `page` | `<index>` | Switch tab |
| `newpage` | `[url]` | Open new tab |
Expand Down
7 changes: 6 additions & 1 deletion help.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
rodney - Chrome automation from the command line

Browser lifecycle:
rodney start [--show] [--insecure | -k] Launch Chrome (headless by default, --show for visible)
rodney start [--show] [--insecure | -k] [--viewport WxH] [--mobile] [--scale N]
Launch Chrome (headless by default, --show for visible)
rodney connect <host:port> Connect to existing Chrome on remote debug port
rodney stop Shut down Chrome
rodney status Show browser status
Expand Down Expand Up @@ -44,6 +45,10 @@ Screenshots:
rodney screenshot [-w N] [-h N] [file] Take page screenshot
rodney screenshot-el <sel> [f] Screenshot an element

Viewport:
rodney viewport <w> <h> [--scale N] [--mobile] Set viewport size
rodney viewport --reset Reset to browser default

Tabs:
rodney pages List all pages/tabs
rodney page <index> Switch to page by index
Expand Down
236 changes: 207 additions & 29 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ type State struct {
DataDir string `json:"data_dir"`
ProxyPID int `json:"proxy_pid,omitempty"` // PID of auth proxy helper
ProxyPort int `json:"proxy_port,omitempty"` // local port of auth proxy

// Viewport overrides (set by "rodney viewport", re-applied on each connection)
ViewportWidth int `json:"viewport_width,omitempty"`
ViewportHeight int `json:"viewport_height,omitempty"`
ViewportScale float64 `json:"viewport_scale,omitempty"`
ViewportMobile bool `json:"viewport_mobile,omitempty"`
}

func stateDir() string {
Expand Down Expand Up @@ -253,6 +259,8 @@ func main() {
cmdScreenshot(args)
case "screenshot-el":
cmdScreenshotEl(args)
case "viewport":
cmdViewport(args)
case "pages":
cmdPages(args)
case "page":
Expand Down Expand Up @@ -313,22 +321,100 @@ func withPage() (*State, *rod.Browser, *rod.Page) {
}
// Apply default timeout so element queries don't hang forever
page = page.Timeout(defaultTimeout)

// Re-apply viewport override if set via "rodney viewport"
if s.ViewportWidth > 0 && s.ViewportHeight > 0 {
scale := s.ViewportScale
if scale == 0 {
scale = 1
}
if err := (proto.EmulationSetDeviceMetricsOverride{
Width: s.ViewportWidth,
Height: s.ViewportHeight,
DeviceScaleFactor: scale,
Mobile: s.ViewportMobile,
}.Call(page)); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to re-apply viewport: %v\n", err)
}
}

return s, browser, page
}

// formatViewportDesc returns a human-readable description of viewport settings.
func formatViewportDesc(prefix string, w, h int, mobile bool, scale float64) string {
desc := fmt.Sprintf("%s %dx%d", prefix, w, h)
var extras []string
if mobile {
extras = append(extras, "mobile")
}
if scale != 0 && scale != 1 {
extras = append(extras, fmt.Sprintf("scale %g", scale))
}
if len(extras) > 0 {
desc += " (" + strings.Join(extras, ", ") + ")"
}
return desc
}

// --- Commands ---

func cmdStart(args []string) {
ignoreCertErrors := false
headless := true
vpWidth, vpHeight := 0, 0
vpScale := 0.0
vpMobile := false

for i := 0; i < len(args); i++ {
switch args[i] {
case "--insecure", "-k":
ignoreCertErrors = true
case "--show":
headless = false
case "--mobile":
vpMobile = true
case "--scale":
i++
if i >= len(args) {
fatal("missing value for --scale")
}
v, err := strconv.ParseFloat(args[i], 64)
if err != nil {
fatal("invalid scale: %v", err)
}
vpScale = v
case "--viewport":
i++
if i >= len(args) {
fatal("missing value for --viewport (expected WxH, e.g. 375x812)")
}
parts := strings.SplitN(args[i], "x", 2)
if len(parts) != 2 {
fatal("invalid viewport format: %q (expected WxH, e.g. 375x812)", args[i])
}
w, err := strconv.Atoi(parts[0])
if err != nil {
fatal("invalid viewport width: %v", err)
}
h, err := strconv.Atoi(parts[1])
if err != nil {
fatal("invalid viewport height: %v", err)
}
vpWidth, vpHeight = w, h
default:
fatal("unknown flag: %s\nusage: rodney start [--insecure]", args[i])
fatal("unknown flag: %s\nusage: rodney start [--show] [--insecure|-k] [--viewport WxH] [--mobile] [--scale N]", args[i])
}
}

if (vpMobile || vpScale != 0) && vpWidth == 0 {
fatal("--mobile and --scale require --viewport")
}

if vpWidth > 0 && vpScale == 0 {
vpScale = 1
}

// Check if already running
if s, err := loadState(); err == nil {
// Try connecting
Expand All @@ -339,14 +425,6 @@ func cmdStart(args []string) {
}
}

// Parse flags
headless := true
for _, arg := range args {
if arg == "--show" {
headless = false
}
}

dataDir := filepath.Join(stateDir(), "chrome-data")
os.MkdirAll(dataDir, 0755)

Expand Down Expand Up @@ -411,12 +489,16 @@ func cmdStart(args []string) {
pid := l.PID()

state := &State{
DebugURL: debugURL,
ChromePID: pid,
ActivePage: 0,
DataDir: dataDir,
ProxyPID: proxyPID,
ProxyPort: proxyPort,
DebugURL: debugURL,
ChromePID: pid,
ActivePage: 0,
DataDir: dataDir,
ProxyPID: proxyPID,
ProxyPort: proxyPort,
ViewportWidth: vpWidth,
ViewportHeight: vpHeight,
ViewportScale: vpScale,
ViewportMobile: vpMobile,
}

if err := saveState(state); err != nil {
Expand All @@ -425,6 +507,9 @@ func cmdStart(args []string) {

fmt.Printf("Chrome started (PID %d)\n", pid)
fmt.Printf("Debug URL: %s\n", debugURL)
if vpWidth > 0 && vpHeight > 0 {
fmt.Println(formatViewportDesc("Viewport:", vpWidth, vpHeight, vpMobile, vpScale))
}
}

func cmdConnect(args []string) {
Expand Down Expand Up @@ -1101,11 +1186,96 @@ func nextAvailableFile(base, ext string) string {
}
}

func cmdViewport(args []string) {
if len(args) < 1 {
fatal("usage: rodney viewport <width> <height> [--scale N] [--mobile]\n rodney viewport --reset")
}

// Handle --reset: clear viewport override and restore browser defaults
if args[0] == "--reset" {
s, _, page := withPage()

if err := (proto.EmulationClearDeviceMetricsOverride{}.Call(page)); err != nil {
fatal("failed to clear viewport override: %v", err)
}

s.ViewportWidth = 0
s.ViewportHeight = 0
s.ViewportScale = 0
s.ViewportMobile = false
if err := saveState(s); err != nil {
fatal("failed to save state: %v", err)
}

fmt.Println("Viewport reset to browser default")
return
}

if len(args) < 2 {
fatal("usage: rodney viewport <width> <height> [--scale N] [--mobile]\n rodney viewport --reset")
}

w, err := strconv.Atoi(args[0])
if err != nil {
fatal("invalid width: %v", err)
}
h, err := strconv.Atoi(args[1])
if err != nil {
fatal("invalid height: %v", err)
}

scale := 1.0
mobile := false

for i := 2; i < len(args); i++ {
switch args[i] {
case "--scale":
i++
if i >= len(args) {
fatal("missing value for --scale")
}
v, err := strconv.ParseFloat(args[i], 64)
if err != nil {
fatal("invalid scale: %v", err)
}
scale = v
case "--mobile":
mobile = true
default:
fatal("unknown flag: %s", args[i])
}
}

s, _, page := withPage()

err = proto.EmulationSetDeviceMetricsOverride{
Width: w,
Height: h,
DeviceScaleFactor: scale,
Mobile: mobile,
}.Call(page)
if err != nil {
fatal("failed to set viewport: %v", err)
}

// Persist viewport settings so they are re-applied on each subsequent command
s.ViewportWidth = w
s.ViewportHeight = h
s.ViewportScale = scale
s.ViewportMobile = mobile
if err := saveState(s); err != nil {
fatal("failed to save state: %v", err)
}

fmt.Println(formatViewportDesc("Viewport set to", w, h, mobile, scale))
}

func cmdScreenshot(args []string) {
var file string
width := 1280
width := 0
height := 0
fullPage := true
sizeExplicit := false

// Parse flags and positional args
var positional []string
Expand All @@ -1121,6 +1291,7 @@ func cmdScreenshot(args []string) {
fatal("invalid width: %v", err)
}
width = v
sizeExplicit = true
case "-h", "--height":
i++
if i >= len(args) {
Expand All @@ -1132,6 +1303,7 @@ func cmdScreenshot(args []string) {
}
height = v
fullPage = false
sizeExplicit = true
default:
positional = append(positional, args[i])
}
Expand All @@ -1143,20 +1315,26 @@ func cmdScreenshot(args []string) {
file = nextAvailableFile("screenshot", ".png")
}

_, _, page := withPage()
s, _, page := withPage()

// Set viewport size
viewportHeight := height
if viewportHeight == 0 {
viewportHeight = 720
}
err := proto.EmulationSetDeviceMetricsOverride{
Width: width,
Height: viewportHeight,
DeviceScaleFactor: 1,
}.Call(page)
if err != nil {
fatal("failed to set viewport: %v", err)
// Only override viewport if -w/-h were explicitly passed, or if no
// viewport has been set via "rodney viewport"
if sizeExplicit || s.ViewportWidth == 0 {
if width == 0 {
width = 1280
}
viewportHeight := height
if viewportHeight == 0 {
viewportHeight = 720
}
err := proto.EmulationSetDeviceMetricsOverride{
Width: width,
Height: viewportHeight,
DeviceScaleFactor: 1,
}.Call(page)
if err != nil {
fatal("failed to set viewport: %v", err)
}
}

data, err := page.Screenshot(fullPage, nil)
Expand Down
Loading