Skip to content

Commit 09b6ba1

Browse files
authored
add viewport switches on browser create (#27)
https://screen.studio/share/SCOuFPyE
1 parent ed27333 commit 09b6ba1

File tree

4 files changed

+218
-10
lines changed

4 files changed

+218
-10
lines changed

cmd/browsers.go

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"os"
1313
"path/filepath"
1414
"regexp"
15+
"strconv"
1516
"strings"
1617

1718
"github.com/onkernel/cli/pkg/util"
@@ -30,7 +31,7 @@ type BrowsersService interface {
3031
New(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (res *kernel.BrowserNewResponse, err error)
3132
Delete(ctx context.Context, body kernel.BrowserDeleteParams, opts ...option.RequestOption) (err error)
3233
DeleteByID(ctx context.Context, id string, opts ...option.RequestOption) (err error)
33-
UploadExtensions(ctx context.Context, id string, body kernel.BrowserUploadExtensionsParams, opts ...option.RequestOption) (err error)
34+
LoadExtensions(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) (err error)
3435
}
3536

3637
// BrowserReplaysService defines the subset we use for browser replays.
@@ -81,6 +82,53 @@ type BoolFlag struct {
8182
// Regular expression to validate CUID2 identifiers (24 lowercase alphanumeric characters).
8283
var cuidRegex = regexp.MustCompile(`^[a-z0-9]{24}$`)
8384

85+
// getAvailableViewports returns the list of supported viewport configurations.
86+
func getAvailableViewports() []string {
87+
return []string{
88+
"2560x1440@10",
89+
"1920x1080@25",
90+
"1920x1200@25",
91+
"1440x900@25",
92+
"1024x768@60",
93+
}
94+
}
95+
96+
// parseViewport parses a viewport string (e.g., "1920x1080@25") and returns width, height, and refresh rate.
97+
// Returns error if the format is invalid.
98+
func parseViewport(viewport string) (width, height, refreshRate int64, err error) {
99+
parts := strings.Split(viewport, "@")
100+
var dimStr string
101+
if len(parts) == 1 {
102+
dimStr = parts[0]
103+
refreshRate = 0
104+
} else if len(parts) == 2 {
105+
dimStr = parts[0]
106+
rr, parseErr := strconv.ParseInt(parts[1], 10, 64)
107+
if parseErr != nil {
108+
return 0, 0, 0, fmt.Errorf("invalid refresh rate: %v", parseErr)
109+
}
110+
refreshRate = rr
111+
} else {
112+
return 0, 0, 0, fmt.Errorf("invalid viewport format")
113+
}
114+
115+
dims := strings.Split(dimStr, "x")
116+
if len(dims) != 2 {
117+
return 0, 0, 0, fmt.Errorf("invalid viewport format, expected WIDTHxHEIGHT[@RATE]")
118+
}
119+
120+
w, err := strconv.ParseInt(dims[0], 10, 64)
121+
if err != nil {
122+
return 0, 0, 0, fmt.Errorf("invalid width: %v", err)
123+
}
124+
h, err := strconv.ParseInt(dims[1], 10, 64)
125+
if err != nil {
126+
return 0, 0, 0, fmt.Errorf("invalid height: %v", err)
127+
}
128+
129+
return w, h, refreshRate, nil
130+
}
131+
84132
// Inputs for each command
85133
type BrowsersCreateInput struct {
86134
PersistenceID string
@@ -92,6 +140,7 @@ type BrowsersCreateInput struct {
92140
ProfileSaveChanges BoolFlag
93141
ProxyID string
94142
Extensions []string
143+
Viewport string
95144
}
96145

97146
type BrowsersDeleteInput struct {
@@ -230,6 +279,22 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error {
230279
}
231280
}
232281

282+
// Add viewport if specified
283+
if in.Viewport != "" {
284+
width, height, refreshRate, err := parseViewport(in.Viewport)
285+
if err != nil {
286+
pterm.Error.Printf("Invalid viewport format: %v\n", err)
287+
return nil
288+
}
289+
params.Viewport = kernel.BrowserNewParamsViewport{
290+
Width: width,
291+
Height: height,
292+
}
293+
if refreshRate > 0 {
294+
params.Viewport.RefreshRate = kernel.Opt(refreshRate)
295+
}
296+
}
297+
233298
browser, err := b.browsers.New(ctx, params)
234299
if err != nil {
235300
return util.CleanedUpSdkError{Err: err}
@@ -1239,7 +1304,7 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions
12391304
return nil
12401305
}
12411306

1242-
var extensions []kernel.BrowserUploadExtensionsParamsExtension
1307+
var extensions []kernel.BrowserLoadExtensionsParamsExtension
12431308
var tempZipFiles []string
12441309
var openFiles []*os.File
12451310

@@ -1280,14 +1345,14 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions
12801345
}
12811346
openFiles = append(openFiles, zipFile)
12821347

1283-
extensions = append(extensions, kernel.BrowserUploadExtensionsParamsExtension{
1348+
extensions = append(extensions, kernel.BrowserLoadExtensionsParamsExtension{
12841349
Name: extName,
12851350
ZipFile: zipFile,
12861351
})
12871352
}
12881353

12891354
pterm.Info.Printf("Uploading %d extension(s) to browser %s...\n", len(extensions), br.SessionID)
1290-
if err := b.browsers.UploadExtensions(ctx, br.SessionID, kernel.BrowserUploadExtensionsParams{
1355+
if err := b.browsers.LoadExtensions(ctx, br.SessionID, kernel.BrowserLoadExtensionsParams{
12911356
Extensions: extensions,
12921357
}); err != nil {
12931358
return util.CleanedUpSdkError{Err: err}
@@ -1482,6 +1547,8 @@ func init() {
14821547
browsersCreateCmd.Flags().Bool("save-changes", false, "If set, save changes back to the profile when the session ends")
14831548
browsersCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use for the browser session")
14841549
browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)")
1550+
browsersCreateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60")
1551+
browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list")
14851552

14861553
// Add flags for delete command
14871554
browsersDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
@@ -1510,6 +1577,25 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
15101577
saveChanges, _ := cmd.Flags().GetBool("save-changes")
15111578
proxyID, _ := cmd.Flags().GetString("proxy-id")
15121579
extensions, _ := cmd.Flags().GetStringSlice("extension")
1580+
viewport, _ := cmd.Flags().GetString("viewport")
1581+
viewportInteractive, _ := cmd.Flags().GetBool("viewport-interactive")
1582+
1583+
// Handle interactive viewport selection
1584+
if viewportInteractive {
1585+
if viewport != "" {
1586+
pterm.Warning.Println("Both --viewport and --viewport-interactive specified; using interactive mode")
1587+
}
1588+
options := getAvailableViewports()
1589+
selectedViewport, err := pterm.DefaultInteractiveSelect.
1590+
WithOptions(options).
1591+
WithDefaultText("Select a viewport size:").
1592+
Show()
1593+
if err != nil {
1594+
pterm.Error.Printf("Failed to select viewport: %v\n", err)
1595+
return nil
1596+
}
1597+
viewport = selectedViewport
1598+
}
15131599

15141600
in := BrowsersCreateInput{
15151601
PersistenceID: persistenceID,
@@ -1521,6 +1607,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
15211607
ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges},
15221608
ProxyID: proxyID,
15231609
Extensions: extensions,
1610+
Viewport: viewport,
15241611
}
15251612

15261613
svc := client.Browsers

cmd/browsers_test.go

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,11 @@ func setupStdoutCapture(t *testing.T) {
5353

5454
// FakeBrowsersService is a configurable fake implementing BrowsersService.
5555
type FakeBrowsersService struct {
56-
ListFunc func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error)
57-
NewFunc func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error)
58-
DeleteFunc func(ctx context.Context, body kernel.BrowserDeleteParams, opts ...option.RequestOption) error
59-
DeleteByIDFunc func(ctx context.Context, id string, opts ...option.RequestOption) error
56+
ListFunc func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error)
57+
NewFunc func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error)
58+
DeleteFunc func(ctx context.Context, body kernel.BrowserDeleteParams, opts ...option.RequestOption) error
59+
DeleteByIDFunc func(ctx context.Context, id string, opts ...option.RequestOption) error
60+
LoadExtensionsFunc func(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) error
6061
}
6162

6263
func (f *FakeBrowsersService) List(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) {
@@ -87,6 +88,13 @@ func (f *FakeBrowsersService) DeleteByID(ctx context.Context, id string, opts ..
8788
return nil
8889
}
8990

91+
func (f *FakeBrowsersService) LoadExtensions(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) error {
92+
if f.LoadExtensionsFunc != nil {
93+
return f.LoadExtensionsFunc(ctx, id, body, opts...)
94+
}
95+
return nil
96+
}
97+
9098
func TestBrowsersList_PrintsEmptyMessage(t *testing.T) {
9199
setupStdoutCapture(t)
92100

@@ -873,3 +881,112 @@ func __writeTempFile(t *testing.T, data string) string {
873881
_ = f.Close()
874882
return f.Name()
875883
}
884+
885+
func TestParseViewport_ValidFormats(t *testing.T) {
886+
tests := []struct {
887+
input string
888+
wantWidth int64
889+
wantHeight int64
890+
wantRefresh int64
891+
}{
892+
{"1920x1080@25", 1920, 1080, 25},
893+
{"2560x1440@10", 2560, 1440, 10},
894+
{"1024x768@60", 1024, 768, 60},
895+
{"1920x1080", 1920, 1080, 0},
896+
}
897+
898+
for _, tt := range tests {
899+
t.Run(tt.input, func(t *testing.T) {
900+
w, h, r, err := parseViewport(tt.input)
901+
assert.NoError(t, err)
902+
assert.Equal(t, tt.wantWidth, w)
903+
assert.Equal(t, tt.wantHeight, h)
904+
assert.Equal(t, tt.wantRefresh, r)
905+
})
906+
}
907+
}
908+
909+
func TestParseViewport_InvalidFormats(t *testing.T) {
910+
tests := []struct {
911+
input string
912+
desc string
913+
}{
914+
{"1920", "missing height"},
915+
{"1920x", "incomplete dimension"},
916+
{"x1080", "missing width"},
917+
{"1920x1080@", "missing refresh rate"},
918+
{"1920x1080@abc", "non-numeric refresh rate"},
919+
{"abcxdef", "non-numeric dimensions"},
920+
{"1920x1080@25@30", "too many @ signs"},
921+
}
922+
923+
for _, tt := range tests {
924+
t.Run(tt.desc, func(t *testing.T) {
925+
_, _, _, err := parseViewport(tt.input)
926+
assert.Error(t, err)
927+
})
928+
}
929+
}
930+
931+
func TestGetAvailableViewports_ReturnsExpectedOptions(t *testing.T) {
932+
viewports := getAvailableViewports()
933+
assert.Len(t, viewports, 5)
934+
assert.Contains(t, viewports, "2560x1440@10")
935+
assert.Contains(t, viewports, "1920x1080@25")
936+
assert.Contains(t, viewports, "1920x1200@25")
937+
assert.Contains(t, viewports, "1440x900@25")
938+
assert.Contains(t, viewports, "1024x768@60")
939+
}
940+
941+
func TestBrowsersCreate_WithViewport(t *testing.T) {
942+
setupStdoutCapture(t)
943+
var captured kernel.BrowserNewParams
944+
fake := &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) {
945+
captured = body
946+
return &kernel.BrowserNewResponse{SessionID: "session123", CdpWsURL: "ws://example"}, nil
947+
}}
948+
b := BrowsersCmd{browsers: fake}
949+
950+
err := b.Create(context.Background(), BrowsersCreateInput{
951+
Viewport: "1920x1080@25",
952+
})
953+
954+
assert.NoError(t, err)
955+
assert.Equal(t, int64(1920), captured.Viewport.Width)
956+
assert.Equal(t, int64(1080), captured.Viewport.Height)
957+
assert.True(t, captured.Viewport.RefreshRate.Valid())
958+
assert.Equal(t, int64(25), captured.Viewport.RefreshRate.Value)
959+
}
960+
961+
func TestBrowsersCreate_WithViewportNoRefreshRate(t *testing.T) {
962+
setupStdoutCapture(t)
963+
var captured kernel.BrowserNewParams
964+
fake := &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) {
965+
captured = body
966+
return &kernel.BrowserNewResponse{SessionID: "session123", CdpWsURL: "ws://example"}, nil
967+
}}
968+
b := BrowsersCmd{browsers: fake}
969+
970+
err := b.Create(context.Background(), BrowsersCreateInput{
971+
Viewport: "1920x1080",
972+
})
973+
974+
assert.NoError(t, err)
975+
assert.Equal(t, int64(1920), captured.Viewport.Width)
976+
assert.Equal(t, int64(1080), captured.Viewport.Height)
977+
assert.False(t, captured.Viewport.RefreshRate.Valid())
978+
}
979+
980+
func TestBrowsersCreate_WithInvalidViewport(t *testing.T) {
981+
setupStdoutCapture(t)
982+
fake := &FakeBrowsersService{}
983+
b := BrowsersCmd{browsers: fake}
984+
985+
err := b.Create(context.Background(), BrowsersCreateInput{
986+
Viewport: "invalid",
987+
})
988+
989+
assert.NoError(t, err)
990+
out := outBuf.String()
991+
assert.Contains(t, out, "Invalid viewport format")
992+
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ require (
88
github.com/charmbracelet/fang v0.2.0
99
github.com/golang-jwt/jwt/v5 v5.2.2
1010
github.com/joho/godotenv v1.5.1
11-
github.com/onkernel/kernel-go-sdk v0.14.1
11+
github.com/onkernel/kernel-go-sdk v0.14.2-0.20251013154713-58a9d56ff62f
1212
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
1313
github.com/pterm/pterm v0.12.80
1414
github.com/samber/lo v1.51.0
@@ -48,7 +48,7 @@ require (
4848
github.com/rivo/uniseg v0.4.7 // indirect
4949
github.com/spf13/pflag v1.0.6 // indirect
5050
github.com/tidwall/gjson v1.18.0 // indirect
51-
github.com/tidwall/match v1.1.1 // indirect
51+
github.com/tidwall/match v1.2.0 // indirect
5252
github.com/tidwall/pretty v1.2.1 // indirect
5353
github.com/tidwall/sjson v1.2.5 // indirect
5454
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
9393
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
9494
github.com/onkernel/kernel-go-sdk v0.14.1 h1:r4drk5uM1phiXl0dZXhnH1zz5iTmApPC0cGSSiNKbVk=
9595
github.com/onkernel/kernel-go-sdk v0.14.1/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc=
96+
github.com/onkernel/kernel-go-sdk v0.14.2-0.20251013154713-58a9d56ff62f h1:/cXzVNPxWryqNsIo2Kvc5fYLBlk7CHus3JZIv1JVoU4=
97+
github.com/onkernel/kernel-go-sdk v0.14.2-0.20251013154713-58a9d56ff62f/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc=
9698
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
9799
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
98100
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -131,6 +133,8 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
131133
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
132134
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
133135
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
136+
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
137+
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
134138
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
135139
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
136140
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=

0 commit comments

Comments
 (0)