Skip to content

Commit c8a53d5

Browse files
feat: auto-toggle Chromium app mode for small viewports (#153)
## Summary - Automatically enables Chromium `--app` mode when the requested display width < 500px or height < 200px, removing the tab/toolbar chrome that enforces a ~500px minimum window width - Adds `RemoveFlagsByPrefix` and `HasFlagWithPrefix` helpers to the `chromiumflags` package for dynamic flag management - Adds manually crafted modelines for exact 390x844 (iPhone 14/15) and 768x1024 (tablet) viewports at 60/30/25 Hz, bypassing CVT's multiple-of-8 width convention since the Xorg dummy driver doesn't require it ## Context Chromium enforces a minimum window size of approximately 500px wide due to the tab bar and toolbar chrome. Launching with `--app=<url>` removes this chrome, allowing the window to resize to mobile viewports like 390x844. The `PatchDisplay` endpoint now automatically toggles this flag based on the requested dimensions and restarts Chromium when needed. The modelines use exact pixel widths (e.g. 390 instead of 392) with manually calculated timing parameters that keep the pixel clock above the Xorg dummy driver's 11 MHz minimum. CVT's convention of rounding widths to multiples of 8 is a standard for real monitor compatibility — the dummy driver is more permissive. Depends on kernel/neko#11 for correct width handling (removes Neko's Go-level rounding that was truncating 390 → 384). ## Test plan - [x] `PATCH /display` with `{"width": 390, "height": 844}` — enables app mode, display is exactly 390x844 - [x] `PATCH /display` with `{"width": 768, "height": 1024}` — no app mode, display is 768x1024 - [x] `PATCH /display` with `{"width": 1920, "height": 1080}` — disables app mode if previously enabled - [x] Verify `RemoveFlagsByPrefix` and `HasFlagWithPrefix` unit tests pass <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes runtime Chromium flag management and automatically triggers restarts based on viewport size, which can impact session stability and resize behavior; also updates Neko/image dependencies that affect display handling. > > **Overview** > Automatically toggles Chromium *app mode* during `PATCH /display`: when the requested size is below `500x200`, the API adds `--app=https://start.duckduckgo.com` to the runtime flags and forces a Chromium restart; when returning to normal sizes it removes the flag. > > Refactors Chromium runtime flag writing around a shared `chromiumFlagsPath` constant + `writeChromiumFlags`, adds `chromiumflags.RemoveFlagsByPrefix`/`HasFlagWithPrefix` helpers (with new tests), and extends the headful Xorg dummy config with exact `390x844` (iPhone) and `768x1024` modelines at 60/30/25 Hz. Also bumps the `neko` base image/dependency version and ignores the built `chromium-launcher` binary in `server/.gitignore`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9e9dfdf. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3ed0d70 commit c8a53d5

File tree

11 files changed

+197
-19
lines changed

11 files changed

+197
-19
lines changed

images/chromium-headful/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ RUN --mount=type=cache,target=/tmp/cache/ffmpeg,sharing=locked,id=$CACHEIDPREFIX
146146
rm -rf /tmp/ffmpeg*
147147
EOT
148148

149-
FROM ghcr.io/kernel/neko/base:3.0.8-v1.3.0 AS neko
149+
FROM ghcr.io/kernel/neko/base:3.0.8-v1.4.0 AS neko
150150
# ^--- now has event.SYSTEM_PONG with legacy support to keepalive
151151
FROM node:22-bullseye-slim AS node-22
152152
FROM docker.io/ubuntu:22.04

images/chromium-headful/xorg.conf

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ Section "Monitor"
6666
Modeline "960x720_60.00" 55.86 960 1008 1104 1248 720 721 724 746 -HSync +Vsync
6767
# 800x600 @ 60.00 Hz (GTF) hsync: 37.32 kHz; pclk: 38.22 MHz
6868
Modeline "800x600_60.00" 38.22 800 832 912 1024 600 601 604 622 -HSync +Vsync
69+
# 768x1024 @ 60.00 Hz (GTF) hsync: 63.60 kHz; pclk: 65.13 MHz
70+
Modeline "768x1024_60.00" 65.13 768 816 896 1024 1024 1025 1028 1060 -HSync +Vsync
71+
# 390x844 @ 60.00 Hz (manual, non-CVT) pclk: 27.26 MHz (mobile: iPhone 14/15)
72+
Modeline "390x844_60.00" 27.26 390 406 446 520 844 845 848 874 -HSync +Vsync
6973
# 2560x1440 @ 60.00 Hz (GTF) hsync: 89.52 kHz; pclk: 312.25 MHz
7074
Modeline "2560x1440_60.00" 312.25 2560 2752 3024 3488 1440 1443 1448 1493 -HSync +Vsync
7175
# 1024x768 @ 60.00 Hz (GTF) hsync: 47.70 kHz; pclk: 63.50 MHz
@@ -89,6 +93,10 @@ Section "Monitor"
8993
Modeline "960x720_30.00" 25.33 960 960 1056 1152 720 721 724 733 -HSync +Vsync
9094
# 800x600 @ 30.00 Hz (GTF) hsync: 18.33 kHz; pclk: 17.01 MHz
9195
Modeline "800x600_30.00" 17.01 800 792 864 928 600 601 604 611 -HSync +Vsync
96+
# 768x1024 @ 30.00 Hz (GTF) hsync: 31.26 kHz; pclk: 30.01 MHz
97+
Modeline "768x1024_30.00" 30.01 768 784 864 960 1024 1025 1028 1042 -HSync +Vsync
98+
# 390x844 @ 30.00 Hz (manual, non-CVT) pclk: 12.57 MHz (mobile: iPhone 14/15)
99+
Modeline "390x844_30.00" 12.57 390 406 446 488 844 845 848 859 -HSync +Vsync
92100
# 1920x1200 @ 30.00 Hz (GTF) hsync: 36.90 kHz; pclk: 96.00 MHz
93101
Modeline "1920x1200_30.00" 96.00 1920 2000 2200 2528 1200 1203 1209 1235 -HSync +Vsync
94102
# 1440x900 @ 30.00 Hz (GTF) hsync: 27.72 kHz; pclk: 52.80 MHz
@@ -120,6 +128,10 @@ Section "Monitor"
120128
Modeline "1200x800_25.00" 31.48 1200 1224 1352 1540 800 801 804 818 -HSync +Vsync
121129
# 800x1600 @ 24.92 Hz (CVT) hsync: 40.53 kHz; pclk: 41.50 MHz
122130
Modeline "800x1600_25.00" 41.50 800 832 912 1024 1600 1603 1613 1626 -Hsync +Vsync
131+
# 768x1024 @ 25.00 Hz (GTF) hsync: 25.97 kHz; pclk: 24.52 MHz
132+
Modeline "768x1024_25.00" 24.52 768 784 856 944 1024 1025 1028 1039 -HSync +Vsync
133+
# 390x844 @ 25.00 Hz (manual, non-CVT) pclk: 11.14 MHz (mobile: iPhone 14/15)
134+
Modeline "390x844_25.00" 11.14 390 406 446 520 844 845 848 857 -HSync +Vsync
123135

124136
# 2560x1440 @ 10.00 Hz (GTF) hsync: 14.65 kHz; pclk: 48.76 MHz
125137
Modeline "2560x1440_10.00" 48.76 2560 2568 2816 3104 1440 1441 1444 1465 -HSync +Vsync
@@ -143,7 +155,7 @@ Section "Screen"
143155
SubSection "Display"
144156
Viewport 0 0
145157
Depth 24
146-
Modes "2560x1440_60.00" "1920x1080_60.00" "1920x1200_60.00" "1440x900_60.00" "1280x720_60.00" "1200x800_60.00" "1152x648_60.00" "1024x768_60.00" "1024x576_60.00" "960x720_60.00" "800x600_60.00" "2560x1440_30.00" "1920x1080_30.00" "1920x1200_30.00" "1440x900_30.00" "1368x768_30.00" "1280x720_30.00" "1200x800_30.00" "1152x648_30.00" "1024x768_30.00" "1024x576_30.00" "960x720_30.00" "800x600_30.00" "800x1600_30.00" "3840x2160_25.00" "2560x1440_25.00" "1920x1080_25.00" "1920x1200_25.00" "1600x900_25.00" "1440x900_25.00" "1368x768_25.00" "1200x800_25.00" "1024x768_25.00" "800x1600_25.00" "2560x1440_10.00" "1920x1080_10.00" "1920x1200_10.00" "1440x900_10.00" "1200x800_10.00" "1024x768_10.00"
158+
Modes "2560x1440_60.00" "1920x1080_60.00" "1920x1200_60.00" "1440x900_60.00" "1280x720_60.00" "1200x800_60.00" "1152x648_60.00" "1024x768_60.00" "1024x576_60.00" "960x720_60.00" "800x600_60.00" "768x1024_60.00" "390x844_60.00" "2560x1440_30.00" "1920x1080_30.00" "1920x1200_30.00" "1440x900_30.00" "1368x768_30.00" "1280x720_30.00" "1200x800_30.00" "1152x648_30.00" "1024x768_30.00" "1024x576_30.00" "960x720_30.00" "800x600_30.00" "800x1600_30.00" "768x1024_30.00" "390x844_30.00" "3840x2160_25.00" "2560x1440_25.00" "1920x1080_25.00" "1920x1200_25.00" "1600x900_25.00" "1440x900_25.00" "1368x768_25.00" "1200x800_25.00" "1024x768_25.00" "800x1600_25.00" "768x1024_25.00" "390x844_25.00" "2560x1440_10.00" "1920x1080_10.00" "1920x1200_10.00" "1440x900_10.00" "1200x800_10.00" "1024x768_10.00"
147159
EndSubSection
148160
EndSection
149161

server/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ go.work
2828

2929
.tmp/
3030
bin/
31+
chromium-launcher
3132
recordings/
3233

3334
# downconverted openapi spec

server/cmd/api/api/chromium.go

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ import (
2121

2222
var nameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]{1,255}$`)
2323

24+
const (
25+
// chromiumFlagsPath is the runtime flags file read by the chromium-launcher at startup.
26+
chromiumFlagsPath = "/chromium/flags"
27+
28+
// appModeURL is the URL loaded in --app mode for small viewports. Keep in
29+
// sync with "NewTabPageLocation" in shared/chromium-policies/managed/policy.json.
30+
appModeURL = "https://start.duckduckgo.com"
31+
)
32+
2433
// UploadExtensionsAndRestart handles multipart upload of one or more extension zips, extracts
2534
// them under /home/kernel/extensions/<name>, writes /chromium/flags to enable them, restarts
2635
// Chromium via supervisord, and waits (via UpstreamManager) until DevTools is ready.
@@ -291,14 +300,12 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap
291300
}
292301

293302
// mergeAndWriteChromiumFlags reads existing flags, merges them with new flags,
294-
// and writes the result back to /chromium/flags. Returns the merged tokens or an error.
303+
// and writes the result back to chromiumFlagsPath. Returns the merged tokens or an error.
295304
func (s *ApiService) mergeAndWriteChromiumFlags(ctx context.Context, newTokens []string) ([]string, error) {
296305
log := logger.FromContext(ctx)
297306

298-
const flagsPath = "/chromium/flags"
299-
300-
// Read existing runtime flags from /chromium/flags (if any)
301-
existingTokens, err := chromiumflags.ReadOptionalFlagFile(flagsPath)
307+
// Read existing runtime flags (if any)
308+
existingTokens, err := chromiumflags.ReadOptionalFlagFile(chromiumFlagsPath)
302309
if err != nil {
303310
log.Error("failed to read existing flags", "error", err)
304311
return nil, fmt.Errorf("failed to read existing flags: %w", err)
@@ -309,22 +316,27 @@ func (s *ApiService) mergeAndWriteChromiumFlags(ctx context.Context, newTokens [
309316
// Merge existing flags with new flags using token-aware API
310317
mergedTokens := chromiumflags.MergeFlags(existingTokens, newTokens)
311318

312-
// Ensure the chromium directory exists
313-
if err := os.MkdirAll("/chromium", 0o755); err != nil {
314-
log.Error("failed to create chromium dir", "error", err)
315-
return nil, fmt.Errorf("failed to create chromium dir: %w", err)
316-
}
317-
318-
// Write flags file with merged flags
319-
if err := chromiumflags.WriteFlagFile(flagsPath, mergedTokens); err != nil {
319+
if err := writeChromiumFlags(mergedTokens); err != nil {
320320
log.Error("failed to write flags", "error", err)
321-
return nil, fmt.Errorf("failed to write flags: %w", err)
321+
return nil, err
322322
}
323323

324324
log.Info("flags written", "merged", mergedTokens)
325325
return mergedTokens, nil
326326
}
327327

328+
// writeChromiumFlags ensures the /chromium directory exists and writes tokens
329+
// to chromiumFlagsPath. Shared by mergeAndWriteChromiumFlags and ensureAppMode.
330+
func writeChromiumFlags(tokens []string) error {
331+
if err := os.MkdirAll("/chromium", 0o755); err != nil {
332+
return fmt.Errorf("failed to create chromium dir: %w", err)
333+
}
334+
if err := chromiumflags.WriteFlagFile(chromiumFlagsPath, tokens); err != nil {
335+
return fmt.Errorf("failed to write flags file: %w", err)
336+
}
337+
return nil
338+
}
339+
328340
// restartChromiumAndWait restarts Chromium via supervisorctl and waits for DevTools to be ready.
329341
// Returns an error if the restart fails or times out.
330342
func (s *ApiService) restartChromiumAndWait(ctx context.Context, operation string) error {

server/cmd/api/api/display.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111

1212
nekooapi "github.com/m1k1o/neko/server/lib/oapi"
13+
"github.com/onkernel/kernel-images/server/lib/chromiumflags"
1314
"github.com/onkernel/kernel-images/server/lib/logger"
1415
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
1516
)
@@ -88,6 +89,21 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ
8889
restartChrome = *req.Body.RestartChromium
8990
}
9091

92+
// App mode: Chromium's --app flag removes the tab bar and toolbar chrome,
93+
// allowing the window to resize below the normal ~500px minimum width
94+
// and ~200px minimum height.
95+
// We automatically toggle this based on the requested viewport dimensions.
96+
const appModeWidthThreshold = 500
97+
const appModeHeightThreshold = 200
98+
needsAppMode := width < appModeWidthThreshold || height < appModeHeightThreshold
99+
if toggled, err := s.ensureAppMode(ctx, needsAppMode); err != nil {
100+
log.Error("failed to toggle app mode", "error", err)
101+
// Non-fatal: continue with the resize even if app mode toggle fails.
102+
} else if toggled {
103+
// App mode changed — force a chromium restart so it picks up the new flags.
104+
restartChrome = true
105+
}
106+
91107
// Route to appropriate resolution change handler
92108
if displayMode == "xorg" {
93109
if s.isNekoEnabled() {
@@ -390,3 +406,55 @@ func (s *ApiService) setResolutionViaNeko(ctx context.Context, width, height, re
390406
log.Info("successfully changed resolution via Neko API", "width", width, "height", height, "refresh_rate", refreshRate)
391407
return nil
392408
}
409+
410+
// ensureAppMode adds or removes the --app flag from the Chromium runtime flags
411+
// file. When enabling, any existing --app flag is removed first so the URL is
412+
// always exactly appModeURL (defined in chromium.go). It returns (true, nil)
413+
// when the flag state was changed (meaning Chromium needs a restart), or
414+
// (false, nil) when no change was needed.
415+
func (s *ApiService) ensureAppMode(ctx context.Context, enable bool) (toggled bool, err error) {
416+
log := logger.FromContext(ctx)
417+
const appPrefix = "--app"
418+
wantFlag := appPrefix + "=" + appModeURL
419+
420+
existing, err := chromiumflags.ReadOptionalFlagFile(chromiumFlagsPath)
421+
if err != nil {
422+
return false, fmt.Errorf("failed to read flags file: %w", err)
423+
}
424+
425+
// Always strip any --app/--app=... flags so we can compare cleanly.
426+
stripped := chromiumflags.RemoveFlagsByPrefix(existing, appPrefix)
427+
428+
if enable {
429+
updated := append(stripped, wantFlag)
430+
// If the exact flag was already present and nothing else changed, no-op.
431+
if chromiumflags.HasFlagWithPrefix(existing, appPrefix) && len(updated) == len(existing) {
432+
// Check the existing flag is the exact one we want.
433+
for _, tok := range existing {
434+
if tok == wantFlag {
435+
log.Info("app mode already enabled with correct URL, no change needed")
436+
return false, nil
437+
}
438+
}
439+
}
440+
log.Info("enabling app mode for small viewport", "flag", wantFlag)
441+
if err := writeChromiumFlags(updated); err != nil {
442+
return false, err
443+
}
444+
log.Info("app mode toggled", "enabled", true, "flags", updated)
445+
return true, nil
446+
}
447+
448+
// Disabling: if nothing was stripped, already disabled.
449+
if len(stripped) == len(existing) {
450+
log.Info("app mode already disabled, no change needed")
451+
return false, nil
452+
}
453+
454+
log.Info("disabling app mode for normal viewport")
455+
if err := writeChromiumFlags(stripped); err != nil {
456+
return false, err
457+
}
458+
log.Info("app mode toggled", "enabled", false, "flags", stripped)
459+
return true, nil
460+
}

server/cmd/chromium-launcher/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ func main() {
116116
}
117117
}
118118

119+
119120
// execLookPath helps satisfy syscall.Exec's requirement to pass an absolute path.
120121
func execLookPath(file string) (string, error) {
121122
if strings.ContainsRune(file, os.PathSeparator) {

server/cmd/chromium-launcher/main_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,4 @@ func TestExecLookPath(t *testing.T) {
3434
t.Fatalf("execLookPath PATH search failed: p=%q err=%v", p, err)
3535
}
3636
}
37+

server/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,4 @@ require (
101101
modernc.org/sqlite v1.23.1 // indirect
102102
)
103103

104-
replace github.com/m1k1o/neko/server => github.com/onkernel/neko/server v0.0.0-20251008185748-46e2fc7d3866
104+
replace github.com/m1k1o/neko/server => github.com/onkernel/neko/server v0.0.0-20260213021128-abe9ac59a634

server/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//J
133133
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
134134
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
135135
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
136-
github.com/onkernel/neko/server v0.0.0-20251008185748-46e2fc7d3866 h1:Cix/sgZLCsavpiTFxDLPbUOXob50IekCg5mgh+i4D4Q=
137-
github.com/onkernel/neko/server v0.0.0-20251008185748-46e2fc7d3866/go.mod h1:0+zactiySvtKwfe5JFjyNrSuQLA+EEPZl5bcfcZf1RM=
136+
github.com/onkernel/neko/server v0.0.0-20260213021128-abe9ac59a634 h1:Q8v6O/VRVLKcEHMSGC0ItDmLFShKLny/0bBggC/1jjk=
137+
github.com/onkernel/neko/server v0.0.0-20260213021128-abe9ac59a634/go.mod h1:0+zactiySvtKwfe5JFjyNrSuQLA+EEPZl5bcfcZf1RM=
138138
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
139139
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
140140
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=

server/lib/chromiumflags/chromiumflags.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,31 @@ func MergeExtensionPath(args []string, extPath string) []string {
224224
return result
225225
}
226226

227+
// RemoveFlagsByPrefix returns a new slice with any tokens that match the given
228+
// prefix removed. For example, prefix "--app" removes "--app", "--app=about:blank", etc.
229+
// It does NOT match longer flag names that merely share the prefix
230+
// (e.g. "--app" will not match "--application-name").
231+
func RemoveFlagsByPrefix(tokens []string, prefix string) []string {
232+
out := make([]string, 0, len(tokens))
233+
for _, tok := range tokens {
234+
if tok == prefix || strings.HasPrefix(tok, prefix+"=") {
235+
continue
236+
}
237+
out = append(out, tok)
238+
}
239+
return out
240+
}
241+
242+
// HasFlagWithPrefix returns true if any token equals prefix or starts with prefix + "=".
243+
func HasFlagWithPrefix(tokens []string, prefix string) bool {
244+
for _, tok := range tokens {
245+
if tok == prefix || strings.HasPrefix(tok, prefix+"=") {
246+
return true
247+
}
248+
}
249+
return false
250+
}
251+
227252
// WriteFlagFile writes the provided tokens to the given path as JSON in the
228253
// form: { "flags": ["--foo", "--bar=1"] } with file mode 0644.
229254
// The function creates or truncates the file.

0 commit comments

Comments
 (0)