diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 6095b077..3b8ba2ce 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -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 diff --git a/images/chromium-headful/xorg.conf b/images/chromium-headful/xorg.conf index d5338248..d2d4c85c 100644 --- a/images/chromium-headful/xorg.conf +++ b/images/chromium-headful/xorg.conf @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/server/.gitignore b/server/.gitignore index 031de437..7ea6b685 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -28,6 +28,7 @@ go.work .tmp/ bin/ +chromium-launcher recordings/ # downconverted openapi spec diff --git a/server/cmd/api/api/chromium.go b/server/cmd/api/api/chromium.go index 20d940ca..a6815e26 100644 --- a/server/cmd/api/api/chromium.go +++ b/server/cmd/api/api/chromium.go @@ -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" +) + // UploadExtensionsAndRestart handles multipart upload of one or more extension zips, extracts // them under /home/kernel/extensions/, writes /chromium/flags to enable them, restarts // Chromium via supervisord, and waits (via UpstreamManager) until DevTools is ready. @@ -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) @@ -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 { diff --git a/server/cmd/api/api/display.go b/server/cmd/api/api/display.go index 51a5a20f..cac2f7b4 100644 --- a/server/cmd/api/api/display.go +++ b/server/cmd/api/api/display.go @@ -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" ) @@ -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() { @@ -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 +} diff --git a/server/cmd/chromium-launcher/main.go b/server/cmd/chromium-launcher/main.go index e606d322..8c83ece5 100644 --- a/server/cmd/chromium-launcher/main.go +++ b/server/cmd/chromium-launcher/main.go @@ -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) { diff --git a/server/cmd/chromium-launcher/main_test.go b/server/cmd/chromium-launcher/main_test.go index 7b4ba40a..e8f19d35 100644 --- a/server/cmd/chromium-launcher/main_test.go +++ b/server/cmd/chromium-launcher/main_test.go @@ -34,3 +34,4 @@ func TestExecLookPath(t *testing.T) { t.Fatalf("execLookPath PATH search failed: p=%q err=%v", p, err) } } + diff --git a/server/go.mod b/server/go.mod index f0270958..ea5629b9 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/go.sum b/server/go.sum index 559fc057..ac2dd499 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/lib/chromiumflags/chromiumflags.go b/server/lib/chromiumflags/chromiumflags.go index 6da8c595..eb988b38 100644 --- a/server/lib/chromiumflags/chromiumflags.go +++ b/server/lib/chromiumflags/chromiumflags.go @@ -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. diff --git a/server/lib/chromiumflags/chromiumflags_test.go b/server/lib/chromiumflags/chromiumflags_test.go index 3900457a..89b3f11a 100644 --- a/server/lib/chromiumflags/chromiumflags_test.go +++ b/server/lib/chromiumflags/chromiumflags_test.go @@ -7,6 +7,8 @@ import ( "reflect" "strings" "testing" + + "github.com/stretchr/testify/assert" ) func TestParseFlags(t *testing.T) { @@ -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