Skip to content
Merged
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
2 changes: 1 addition & 1 deletion images/chromium-headful/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ RUN --mount=type=cache,target=/tmp/cache/ffmpeg,sharing=locked,id=$CACHEIDPREFIX
rm -rf /tmp/ffmpeg*
EOT

FROM ghcr.io/kernel/neko/base:3.0.8-v1.3.0 AS neko
FROM ghcr.io/kernel/neko/base:3.0.8-v1.4.0 AS neko
# ^--- now has event.SYSTEM_PONG with legacy support to keepalive
FROM node:22-bullseye-slim AS node-22
FROM docker.io/ubuntu:22.04
Expand Down
14 changes: 13 additions & 1 deletion images/chromium-headful/xorg.conf
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ Section "Monitor"
Modeline "960x720_60.00" 55.86 960 1008 1104 1248 720 721 724 746 -HSync +Vsync
# 800x600 @ 60.00 Hz (GTF) hsync: 37.32 kHz; pclk: 38.22 MHz
Modeline "800x600_60.00" 38.22 800 832 912 1024 600 601 604 622 -HSync +Vsync
# 768x1024 @ 60.00 Hz (GTF) hsync: 63.60 kHz; pclk: 65.13 MHz
Modeline "768x1024_60.00" 65.13 768 816 896 1024 1024 1025 1028 1060 -HSync +Vsync
# 390x844 @ 60.00 Hz (manual, non-CVT) pclk: 27.26 MHz (mobile: iPhone 14/15)
Modeline "390x844_60.00" 27.26 390 406 446 520 844 845 848 874 -HSync +Vsync
# 2560x1440 @ 60.00 Hz (GTF) hsync: 89.52 kHz; pclk: 312.25 MHz
Modeline "2560x1440_60.00" 312.25 2560 2752 3024 3488 1440 1443 1448 1493 -HSync +Vsync
# 1024x768 @ 60.00 Hz (GTF) hsync: 47.70 kHz; pclk: 63.50 MHz
Expand All @@ -89,6 +93,10 @@ Section "Monitor"
Modeline "960x720_30.00" 25.33 960 960 1056 1152 720 721 724 733 -HSync +Vsync
# 800x600 @ 30.00 Hz (GTF) hsync: 18.33 kHz; pclk: 17.01 MHz
Modeline "800x600_30.00" 17.01 800 792 864 928 600 601 604 611 -HSync +Vsync
# 768x1024 @ 30.00 Hz (GTF) hsync: 31.26 kHz; pclk: 30.01 MHz
Modeline "768x1024_30.00" 30.01 768 784 864 960 1024 1025 1028 1042 -HSync +Vsync
# 390x844 @ 30.00 Hz (manual, non-CVT) pclk: 12.57 MHz (mobile: iPhone 14/15)
Modeline "390x844_30.00" 12.57 390 406 446 488 844 845 848 859 -HSync +Vsync
# 1920x1200 @ 30.00 Hz (GTF) hsync: 36.90 kHz; pclk: 96.00 MHz
Modeline "1920x1200_30.00" 96.00 1920 2000 2200 2528 1200 1203 1209 1235 -HSync +Vsync
# 1440x900 @ 30.00 Hz (GTF) hsync: 27.72 kHz; pclk: 52.80 MHz
Expand Down Expand Up @@ -120,6 +128,10 @@ Section "Monitor"
Modeline "1200x800_25.00" 31.48 1200 1224 1352 1540 800 801 804 818 -HSync +Vsync
# 800x1600 @ 24.92 Hz (CVT) hsync: 40.53 kHz; pclk: 41.50 MHz
Modeline "800x1600_25.00" 41.50 800 832 912 1024 1600 1603 1613 1626 -Hsync +Vsync
# 768x1024 @ 25.00 Hz (GTF) hsync: 25.97 kHz; pclk: 24.52 MHz
Modeline "768x1024_25.00" 24.52 768 784 856 944 1024 1025 1028 1039 -HSync +Vsync
# 390x844 @ 25.00 Hz (manual, non-CVT) pclk: 11.14 MHz (mobile: iPhone 14/15)
Modeline "390x844_25.00" 11.14 390 406 446 520 844 845 848 857 -HSync +Vsync

# 2560x1440 @ 10.00 Hz (GTF) hsync: 14.65 kHz; pclk: 48.76 MHz
Modeline "2560x1440_10.00" 48.76 2560 2568 2816 3104 1440 1441 1444 1465 -HSync +Vsync
Expand All @@ -143,7 +155,7 @@ Section "Screen"
SubSection "Display"
Viewport 0 0
Depth 24
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"
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"
EndSubSection
EndSection

Expand Down
1 change: 1 addition & 0 deletions server/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ go.work

.tmp/
bin/
chromium-launcher
recordings/

# downconverted openapi spec
Expand Down
40 changes: 26 additions & 14 deletions server/cmd/api/api/chromium.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ import (

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

const (
// chromiumFlagsPath is the runtime flags file read by the chromium-launcher at startup.
chromiumFlagsPath = "/chromium/flags"

// appModeURL is the URL loaded in --app mode for small viewports. Keep in
// sync with "NewTabPageLocation" in shared/chromium-policies/managed/policy.json.
appModeURL = "https://start.duckduckgo.com"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URL trailing slash mismatch with policy file

Low Severity

The appModeURL constant is "https://start.duckduckgo.com" (no trailing slash), but NewTabPageLocation in shared/chromium-policies/managed/policy.json is "https://start.duckduckgo.com/" (with trailing slash). The comment on line 28–29 explicitly says to keep these in sync. While browsers typically normalize these, the mismatch could cause the ensureAppMode no-op detection to miss an exact match if any other code path writes the policy-matching URL with a trailing slash.

Fix in Cursor Fix in Web

)

// UploadExtensionsAndRestart handles multipart upload of one or more extension zips, extracts
// them under /home/kernel/extensions/<name>, writes /chromium/flags to enable them, restarts
// Chromium via supervisord, and waits (via UpstreamManager) until DevTools is ready.
Expand Down Expand Up @@ -291,14 +300,12 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap
}

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

const flagsPath = "/chromium/flags"

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

// Ensure the chromium directory exists
if err := os.MkdirAll("/chromium", 0o755); err != nil {
log.Error("failed to create chromium dir", "error", err)
return nil, fmt.Errorf("failed to create chromium dir: %w", err)
}

// Write flags file with merged flags
if err := chromiumflags.WriteFlagFile(flagsPath, mergedTokens); err != nil {
if err := writeChromiumFlags(mergedTokens); err != nil {
log.Error("failed to write flags", "error", err)
return nil, fmt.Errorf("failed to write flags: %w", err)
return nil, err
}

log.Info("flags written", "merged", mergedTokens)
return mergedTokens, nil
}

// writeChromiumFlags ensures the /chromium directory exists and writes tokens
// to chromiumFlagsPath. Shared by mergeAndWriteChromiumFlags and ensureAppMode.
func writeChromiumFlags(tokens []string) error {
if err := os.MkdirAll("/chromium", 0o755); err != nil {
return fmt.Errorf("failed to create chromium dir: %w", err)
}
if err := chromiumflags.WriteFlagFile(chromiumFlagsPath, tokens); err != nil {
return fmt.Errorf("failed to write flags file: %w", err)
}
return nil
}

// restartChromiumAndWait restarts Chromium via supervisorctl and waits for DevTools to be ready.
// Returns an error if the restart fails or times out.
func (s *ApiService) restartChromiumAndWait(ctx context.Context, operation string) error {
Expand Down
68 changes: 68 additions & 0 deletions server/cmd/api/api/display.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"

nekooapi "github.com/m1k1o/neko/server/lib/oapi"
"github.com/onkernel/kernel-images/server/lib/chromiumflags"
"github.com/onkernel/kernel-images/server/lib/logger"
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
)
Expand Down Expand Up @@ -88,6 +89,21 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ
restartChrome = *req.Body.RestartChromium
}

// App mode: Chromium's --app flag removes the tab bar and toolbar chrome,
// allowing the window to resize below the normal ~500px minimum width
// and ~200px minimum height.
// We automatically toggle this based on the requested viewport dimensions.
const appModeWidthThreshold = 500
const appModeHeightThreshold = 200
needsAppMode := width < appModeWidthThreshold || height < appModeHeightThreshold
if toggled, err := s.ensureAppMode(ctx, needsAppMode); err != nil {
log.Error("failed to toggle app mode", "error", err)
// Non-fatal: continue with the resize even if app mode toggle fails.
} else if toggled {
// App mode changed — force a chromium restart so it picks up the new flags.
restartChrome = true
}

// Route to appropriate resolution change handler
if displayMode == "xorg" {
if s.isNekoEnabled() {
Expand Down Expand Up @@ -390,3 +406,55 @@ func (s *ApiService) setResolutionViaNeko(ctx context.Context, width, height, re
log.Info("successfully changed resolution via Neko API", "width", width, "height", height, "refresh_rate", refreshRate)
return nil
}

// ensureAppMode adds or removes the --app flag from the Chromium runtime flags
// file. When enabling, any existing --app flag is removed first so the URL is
// always exactly appModeURL (defined in chromium.go). It returns (true, nil)
// when the flag state was changed (meaning Chromium needs a restart), or
// (false, nil) when no change was needed.
func (s *ApiService) ensureAppMode(ctx context.Context, enable bool) (toggled bool, err error) {
log := logger.FromContext(ctx)
const appPrefix = "--app"
wantFlag := appPrefix + "=" + appModeURL

existing, err := chromiumflags.ReadOptionalFlagFile(chromiumFlagsPath)
if err != nil {
return false, fmt.Errorf("failed to read flags file: %w", err)
}

// Always strip any --app/--app=... flags so we can compare cleanly.
stripped := chromiumflags.RemoveFlagsByPrefix(existing, appPrefix)

if enable {
updated := append(stripped, wantFlag)
// If the exact flag was already present and nothing else changed, no-op.
if chromiumflags.HasFlagWithPrefix(existing, appPrefix) && len(updated) == len(existing) {
// Check the existing flag is the exact one we want.
for _, tok := range existing {
if tok == wantFlag {
log.Info("app mode already enabled with correct URL, no change needed")
return false, nil
}
}
}
log.Info("enabling app mode for small viewport", "flag", wantFlag)
if err := writeChromiumFlags(updated); err != nil {
return false, err
}
log.Info("app mode toggled", "enabled", true, "flags", updated)
return true, nil
}

// Disabling: if nothing was stripped, already disabled.
if len(stripped) == len(existing) {
log.Info("app mode already disabled, no change needed")
return false, nil
}

log.Info("disabling app mode for normal viewport")
if err := writeChromiumFlags(stripped); err != nil {
return false, err
}
log.Info("app mode toggled", "enabled", false, "flags", stripped)
return true, nil
}
1 change: 1 addition & 0 deletions server/cmd/chromium-launcher/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ func main() {
}
}


// execLookPath helps satisfy syscall.Exec's requirement to pass an absolute path.
func execLookPath(file string) (string, error) {
if strings.ContainsRune(file, os.PathSeparator) {
Expand Down
1 change: 1 addition & 0 deletions server/cmd/chromium-launcher/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ func TestExecLookPath(t *testing.T) {
t.Fatalf("execLookPath PATH search failed: p=%q err=%v", p, err)
}
}

2 changes: 1 addition & 1 deletion server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,4 @@ require (
modernc.org/sqlite v1.23.1 // indirect
)

replace github.com/m1k1o/neko/server => github.com/onkernel/neko/server v0.0.0-20251008185748-46e2fc7d3866
replace github.com/m1k1o/neko/server => github.com/onkernel/neko/server v0.0.0-20260213021128-abe9ac59a634
4 changes: 2 additions & 2 deletions server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//J
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/onkernel/neko/server v0.0.0-20251008185748-46e2fc7d3866 h1:Cix/sgZLCsavpiTFxDLPbUOXob50IekCg5mgh+i4D4Q=
github.com/onkernel/neko/server v0.0.0-20251008185748-46e2fc7d3866/go.mod h1:0+zactiySvtKwfe5JFjyNrSuQLA+EEPZl5bcfcZf1RM=
github.com/onkernel/neko/server v0.0.0-20260213021128-abe9ac59a634 h1:Q8v6O/VRVLKcEHMSGC0ItDmLFShKLny/0bBggC/1jjk=
github.com/onkernel/neko/server v0.0.0-20260213021128-abe9ac59a634/go.mod h1:0+zactiySvtKwfe5JFjyNrSuQLA+EEPZl5bcfcZf1RM=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
Expand Down
25 changes: 25 additions & 0 deletions server/lib/chromiumflags/chromiumflags.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,31 @@ func MergeExtensionPath(args []string, extPath string) []string {
return result
}

// RemoveFlagsByPrefix returns a new slice with any tokens that match the given
// prefix removed. For example, prefix "--app" removes "--app", "--app=about:blank", etc.
// It does NOT match longer flag names that merely share the prefix
// (e.g. "--app" will not match "--application-name").
func RemoveFlagsByPrefix(tokens []string, prefix string) []string {
out := make([]string, 0, len(tokens))
for _, tok := range tokens {
if tok == prefix || strings.HasPrefix(tok, prefix+"=") {
continue
}
out = append(out, tok)
}
return out
}

// HasFlagWithPrefix returns true if any token equals prefix or starts with prefix + "=".
func HasFlagWithPrefix(tokens []string, prefix string) bool {
for _, tok := range tokens {
if tok == prefix || strings.HasPrefix(tok, prefix+"=") {
return true
}
}
return false
}

// WriteFlagFile writes the provided tokens to the given path as JSON in the
// form: { "flags": ["--foo", "--bar=1"] } with file mode 0644.
// The function creates or truncates the file.
Expand Down
58 changes: 58 additions & 0 deletions server/lib/chromiumflags/chromiumflags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"reflect"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func TestParseFlags(t *testing.T) {
Expand Down Expand Up @@ -214,6 +216,62 @@ func TestWriteFlagFileAndReadBack(t *testing.T) {

// TestWriteFlagFileFromString removed: callers should use WriteFlagFile with tokens.

func TestRemoveFlagsByPrefix(t *testing.T) {
tests := []struct {
name string
tokens []string
prefix string
want []string
}{
{
name: "remove exact match",
tokens: []string{"--foo", "--app", "--bar"},
prefix: "--app",
want: []string{"--foo", "--bar"},
},
{
name: "remove prefix=value match",
tokens: []string{"--foo", "--app=about:blank", "--bar"},
prefix: "--app",
want: []string{"--foo", "--bar"},
},
{
name: "does not remove longer flag names",
tokens: []string{"--foo", "--application-name=test", "--app=about:blank"},
prefix: "--app",
want: []string{"--foo", "--application-name=test"},
},
{
name: "nothing to remove",
tokens: []string{"--foo", "--bar"},
prefix: "--app",
want: []string{"--foo", "--bar"},
},
{
name: "empty input",
tokens: []string{},
prefix: "--app",
want: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := RemoveFlagsByPrefix(tt.tokens, tt.prefix)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("RemoveFlagsByPrefix() = %#v, want %#v", got, tt.want)
}
})
}
}

func TestHasFlagWithPrefix(t *testing.T) {
assert.True(t, HasFlagWithPrefix([]string{"--app=about:blank", "--foo"}, "--app"))
assert.True(t, HasFlagWithPrefix([]string{"--foo", "--app"}, "--app"))
assert.False(t, HasFlagWithPrefix([]string{"--application-name=test"}, "--app"))
assert.False(t, HasFlagWithPrefix([]string{"--foo", "--bar"}, "--app"))
assert.False(t, HasFlagWithPrefix([]string{}, "--app"))
}

func TestMergeFlags(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading