detached 4749d51

feat(cef): internal HTTPS origin for dumb:// pages, localStorage support

- Redirect dumb:// URLs to actual HTTPS origin (dumber.invalid) so
  Chromium applies full web security model including localStorage
- Register scheme handler factory for both dumb:// and the internal
  HTTPS origin
- Add CSP bypassing to custom scheme options
- Enable LocalStorage in BrowserSettings (CEF_STATE_ENABLED)
- Extract internal URL helpers into internal_urls.go with tests
- Add subprocess app for consistent scheme registration across processes
- Split MIME type and charset for CEF response headers
commit
4749d51ba2ca43cf95891f04ef0b5cae8c927315
author
brice · · 2026-03-24 05:43 UTC
committer
brice · · 2026-03-28 04:38 UTC
parent
62d919e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +520 -26
M cmd/cef-helper/main.go +3 -1
@@ -14,6 +14,8 @@ import (
1414 "runtime"
1515
1616 cef "github.com/bnema/purego-cef/cef"
17+
18+ dumbercef "github.com/bnema/dumber/internal/infrastructure/cef"
1719 )
1820
1921 func main() {
@@ -25,7 +27,7 @@ func main() {
2527 // If it returns (code < 0), this is not a subprocess — shouldn't
2628 // happen in normal operation since CEF only launches this binary
2729 // with --type= flags.
28- cef.MaybeExitSubprocess()
30+ cef.MaybeExitSubprocessWithApp(dumbercef.NewSubprocessApp())
2931
3032 // If we get here, something went wrong.
3133 os.Exit(1)
@@ -14,6 +14,8 @@ import (
14 "runtime"14 "runtime"
15 15
16 cef "github.com/bnema/purego-cef/cef"16 cef "github.com/bnema/purego-cef/cef"
17+
18+ dumbercef "github.com/bnema/dumber/internal/infrastructure/cef"
17 )19 )
18 20
19 func main() {21 func main() {
@@ -25,7 +27,7 @@ func main() {
25 // If it returns (code < 0), this is not a subprocess — shouldn't27 // If it returns (code < 0), this is not a subprocess — shouldn't
26 // happen in normal operation since CEF only launches this binary28 // happen in normal operation since CEF only launches this binary
27 // with --type= flags.29 // with --type= flags.
28- cef.MaybeExitSubprocess()30+ cef.MaybeExitSubprocessWithApp(dumbercef.NewSubprocessApp())
29 31
30 // If we get here, something went wrong.32 // If we get here, something went wrong.
31 os.Exit(1)33 os.Exit(1)
M internal/infrastructure/cef/app.go +52 -14
@@ -6,6 +6,38 @@ import (
66 "github.com/bnema/dumber/internal/logging"
77 )
88
9+const dumbSchemeName = "dumb"
10+
11+func dumbSchemeOptions() int32 {
12+ return int32(purecef.SchemeOptionsSchemeOptionStandard |
13+ purecef.SchemeOptionsSchemeOptionSecure |
14+ purecef.SchemeOptionsSchemeOptionCorsEnabled |
15+ purecef.SchemeOptionsSchemeOptionCspBypassing |
16+ purecef.SchemeOptionsSchemeOptionFetchEnabled)
17+}
18+
19+func registerDumbScheme(registrar purecef.SchemeRegistrar) {
20+ if registrar == nil {
21+ return
22+ }
23+ registrar.AddCustomScheme(dumbSchemeName, dumbSchemeOptions())
24+}
25+
26+func configureCommandLine(commandLine purecef.CommandLine) {
27+ if commandLine == nil {
28+ return
29+ }
30+
31+ // Enable Chromium's built-in smooth scrolling animation — without this,
32+ // mouse wheel scroll jumps in discrete steps with no momentum/easing.
33+ commandLine.AppendSwitch("enable-smooth-scrolling")
34+
35+ // Allow video autoplay without requiring user gesture — sites like
36+ // Reddit autoplay muted videos in the feed; without this policy
37+ // Chromium blocks them, showing an infinite spinner.
38+ commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")
39+}
40+
941 // dumberApp implements purecef.App to provide a BrowserProcessHandler with
1042 // demand-driven message pump scheduling (OnScheduleMessagePumpWork).
1143 type dumberApp struct {
@@ -23,20 +55,19 @@ func newDumberApp(engine *Engine) purecef.App {
2355 return purecef.NewApp(app)
2456 }
2557
58+// NewSubprocessApp returns a lightweight App for helper processes so CEF sees
59+// the same custom scheme registration in renderer/GPU/utility processes.
60+func NewSubprocessApp() purecef.App {
61+ return purecef.NewApp(&subprocessApp{})
62+}
63+
2664 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
2765 const maxCmdLineLogLen = 200
2866
2967 func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) {
3068 log := logging.FromContext(a.engine.ctx)
3169 if commandLine != nil {
32- // Enable Chromium's built-in smooth scrolling animation — without this,
33- // mouse wheel scroll jumps in discrete steps with no momentum/easing.
34- commandLine.AppendSwitch("enable-smooth-scrolling")
35-
36- // Allow video autoplay without requiring user gesture — sites like
37- // Reddit autoplay muted videos in the feed; without this policy
38- // Chromium blocks them, showing an infinite spinner.
39- commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")
70+ configureCommandLine(commandLine)
4071
4172 cmdline := commandLine.GetCommandLineString()
4273 if len(cmdline) > maxCmdLineLogLen {
@@ -52,18 +83,25 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin
5283 }
5384 }
5485 func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
55- options := int32(purecef.SchemeOptionsSchemeOptionStandard |
56- purecef.SchemeOptionsSchemeOptionLocal |
57- purecef.SchemeOptionsSchemeOptionSecure |
58- purecef.SchemeOptionsSchemeOptionCorsEnabled |
59- purecef.SchemeOptionsSchemeOptionFetchEnabled)
60- registrar.AddCustomScheme("dumb", options)
86+ registerDumbScheme(registrar)
6187 logging.FromContext(a.engine.ctx).Debug().Msg("cef: registered dumb:// custom scheme")
6288 }
6389 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
6490 func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }
6591 func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
6692
93+type subprocessApp struct{}
94+
95+func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) {
96+ configureCommandLine(commandLine)
97+}
98+func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
99+ registerDumbScheme(registrar)
100+}
101+func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
102+func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil }
103+func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
104+
67105 // dumberBPH implements purecef.BrowserProcessHandler. Only
68106 // OnScheduleMessagePumpWork carries real logic; the rest are no-ops.
69107 type dumberBPH struct {
@@ -6,6 +6,38 @@ import (
6 "github.com/bnema/dumber/internal/logging"6 "github.com/bnema/dumber/internal/logging"
7 )7 )
8 8
9+const dumbSchemeName = "dumb"
10+
11+func dumbSchemeOptions() int32 {
12+ return int32(purecef.SchemeOptionsSchemeOptionStandard |
13+ purecef.SchemeOptionsSchemeOptionSecure |
14+ purecef.SchemeOptionsSchemeOptionCorsEnabled |
15+ purecef.SchemeOptionsSchemeOptionCspBypassing |
16+ purecef.SchemeOptionsSchemeOptionFetchEnabled)
17+}
18+
19+func registerDumbScheme(registrar purecef.SchemeRegistrar) {
20+ if registrar == nil {
21+ return
22+ }
23+ registrar.AddCustomScheme(dumbSchemeName, dumbSchemeOptions())
24+}
25+
26+func configureCommandLine(commandLine purecef.CommandLine) {
27+ if commandLine == nil {
28+ return
29+ }
30+
31+ // Enable Chromium's built-in smooth scrolling animation — without this,
32+ // mouse wheel scroll jumps in discrete steps with no momentum/easing.
33+ commandLine.AppendSwitch("enable-smooth-scrolling")
34+
35+ // Allow video autoplay without requiring user gesture — sites like
36+ // Reddit autoplay muted videos in the feed; without this policy
37+ // Chromium blocks them, showing an infinite spinner.
38+ commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")
39+}
40+
9 // dumberApp implements purecef.App to provide a BrowserProcessHandler with41 // dumberApp implements purecef.App to provide a BrowserProcessHandler with
10 // demand-driven message pump scheduling (OnScheduleMessagePumpWork).42 // demand-driven message pump scheduling (OnScheduleMessagePumpWork).
11 type dumberApp struct {43 type dumberApp struct {
@@ -23,20 +55,19 @@ func newDumberApp(engine *Engine) purecef.App {
23 return purecef.NewApp(app)55 return purecef.NewApp(app)
24 }56 }
25 57
58+// NewSubprocessApp returns a lightweight App for helper processes so CEF sees
59+// the same custom scheme registration in renderer/GPU/utility processes.
60+func NewSubprocessApp() purecef.App {
61+ return purecef.NewApp(&subprocessApp{})
62+}
63+
26 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.64 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
27 const maxCmdLineLogLen = 20065 const maxCmdLineLogLen = 200
28 66
29 func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) {67 func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) {
30 log := logging.FromContext(a.engine.ctx)68 log := logging.FromContext(a.engine.ctx)
31 if commandLine != nil {69 if commandLine != nil {
32- // Enable Chromium's built-in smooth scrolling animation — without this,70+ configureCommandLine(commandLine)
33- // mouse wheel scroll jumps in discrete steps with no momentum/easing.
34- commandLine.AppendSwitch("enable-smooth-scrolling")
35-
36- // Allow video autoplay without requiring user gesture — sites like
37- // Reddit autoplay muted videos in the feed; without this policy
38- // Chromium blocks them, showing an infinite spinner.
39- commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")
40 71
41 cmdline := commandLine.GetCommandLineString()72 cmdline := commandLine.GetCommandLineString()
42 if len(cmdline) > maxCmdLineLogLen {73 if len(cmdline) > maxCmdLineLogLen {
@@ -52,18 +83,25 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin
52 }83 }
53 }84 }
54 func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {85 func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
55- options := int32(purecef.SchemeOptionsSchemeOptionStandard |86+ registerDumbScheme(registrar)
56- purecef.SchemeOptionsSchemeOptionLocal |
57- purecef.SchemeOptionsSchemeOptionSecure |
58- purecef.SchemeOptionsSchemeOptionCorsEnabled |
59- purecef.SchemeOptionsSchemeOptionFetchEnabled)
60- registrar.AddCustomScheme("dumb", options)
61 logging.FromContext(a.engine.ctx).Debug().Msg("cef: registered dumb:// custom scheme")87 logging.FromContext(a.engine.ctx).Debug().Msg("cef: registered dumb:// custom scheme")
62 }88 }
63 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }89 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
64 func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }90 func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }
65 func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }91 func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
66 92
93+type subprocessApp struct{}
94+
95+func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) {
96+ configureCommandLine(commandLine)
97+}
98+func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
99+ registerDumbScheme(registrar)
100+}
101+func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
102+func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil }
103+func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
104+
67 // dumberBPH implements purecef.BrowserProcessHandler. Only105 // dumberBPH implements purecef.BrowserProcessHandler. Only
68 // OnScheduleMessagePumpWork carries real logic; the rest are no-ops.106 // OnScheduleMessagePumpWork carries real logic; the rest are no-ops.
69 type dumberBPH struct {107 type dumberBPH struct {
A internal/infrastructure/cef/app_test.go +43 -0
@@ -0,0 +1,43 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+)
8+
9+type schemeRegistrarStub struct {
10+ scheme string
11+ options int32
12+ calls int
13+}
14+
15+func (s *schemeRegistrarStub) AddCustomScheme(schemeName string, options int32) int32 {
16+ s.calls++
17+ s.scheme = schemeName
18+ s.options = options
19+ return 1
20+}
21+
22+func TestRegisterDumbScheme(t *testing.T) {
23+ t.Parallel()
24+
25+ stub := &schemeRegistrarStub{}
26+ registerDumbScheme(stub)
27+
28+ wantOptions := int32(purecef.SchemeOptionsSchemeOptionStandard |
29+ purecef.SchemeOptionsSchemeOptionSecure |
30+ purecef.SchemeOptionsSchemeOptionCorsEnabled |
31+ purecef.SchemeOptionsSchemeOptionCspBypassing |
32+ purecef.SchemeOptionsSchemeOptionFetchEnabled)
33+
34+ if stub.calls != 1 {
35+ t.Fatalf("AddCustomScheme call count = %d, want 1", stub.calls)
36+ }
37+ if stub.scheme != dumbSchemeName {
38+ t.Fatalf("scheme = %q, want %q", stub.scheme, dumbSchemeName)
39+ }
40+ if stub.options != wantOptions {
41+ t.Fatalf("options = %d, want %d", stub.options, wantOptions)
42+ }
43+}
@@ -0,0 +1,43 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+)
8+
9+type schemeRegistrarStub struct {
10+ scheme string
11+ options int32
12+ calls int
13+}
14+
15+func (s *schemeRegistrarStub) AddCustomScheme(schemeName string, options int32) int32 {
16+ s.calls++
17+ s.scheme = schemeName
18+ s.options = options
19+ return 1
20+}
21+
22+func TestRegisterDumbScheme(t *testing.T) {
23+ t.Parallel()
24+
25+ stub := &schemeRegistrarStub{}
26+ registerDumbScheme(stub)
27+
28+ wantOptions := int32(purecef.SchemeOptionsSchemeOptionStandard |
29+ purecef.SchemeOptionsSchemeOptionSecure |
30+ purecef.SchemeOptionsSchemeOptionCorsEnabled |
31+ purecef.SchemeOptionsSchemeOptionCspBypassing |
32+ purecef.SchemeOptionsSchemeOptionFetchEnabled)
33+
34+ if stub.calls != 1 {
35+ t.Fatalf("AddCustomScheme call count = %d, want 1", stub.calls)
36+ }
37+ if stub.scheme != dumbSchemeName {
38+ t.Fatalf("scheme = %q, want %q", stub.scheme, dumbSchemeName)
39+ }
40+ if stub.options != wantOptions {
41+ t.Fatalf("options = %d, want %d", stub.options, wantOptions)
42+ }
43+}
M internal/infrastructure/cef/engine_init.go +15 -1
@@ -154,13 +154,27 @@ func wireEngine(
154154 schemeHandler := newDumbSchemeHandler(ctx, messageRouter)
155155 schemeHandler.setAssets(assets.WebUIAssets)
156156
157- result := purecef.RegisterSchemeHandlerFactory("dumb", "", purecef.NewSchemeHandlerFactory(schemeHandler))
157+ schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
158+
159+ result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory)
158160 if result != 1 {
159161 logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory")
160162 } else {
161163 logger.Info().Msg("cef: registered dumb:// scheme handler factory")
162164 }
163165
166+ result = purecef.RegisterSchemeHandlerFactory(actualInternalScheme, actualInternalHost, schemeFactory)
167+ if result != 1 {
168+ logger.Error().
169+ Int32("result", result).
170+ Str("origin", actualInternalOrigin).
171+ Msg("cef: failed to register internal https handler factory")
172+ } else {
173+ logger.Info().
174+ Str("origin", actualInternalOrigin).
175+ Msg("cef: registered internal https handler factory")
176+ }
177+
164178 eng.messageRouter = messageRouter
165179 eng.schemeHandler = schemeHandler
166180
@@ -154,13 +154,27 @@ func wireEngine(
154 schemeHandler := newDumbSchemeHandler(ctx, messageRouter)154 schemeHandler := newDumbSchemeHandler(ctx, messageRouter)
155 schemeHandler.setAssets(assets.WebUIAssets)155 schemeHandler.setAssets(assets.WebUIAssets)
156 156
157- result := purecef.RegisterSchemeHandlerFactory("dumb", "", purecef.NewSchemeHandlerFactory(schemeHandler))157+ schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
158+
159+ result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory)
158 if result != 1 {160 if result != 1 {
159 logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory")161 logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory")
160 } else {162 } else {
161 logger.Info().Msg("cef: registered dumb:// scheme handler factory")163 logger.Info().Msg("cef: registered dumb:// scheme handler factory")
162 }164 }
163 165
166+ result = purecef.RegisterSchemeHandlerFactory(actualInternalScheme, actualInternalHost, schemeFactory)
167+ if result != 1 {
168+ logger.Error().
169+ Int32("result", result).
170+ Str("origin", actualInternalOrigin).
171+ Msg("cef: failed to register internal https handler factory")
172+ } else {
173+ logger.Info().
174+ Str("origin", actualInternalOrigin).
175+ Msg("cef: registered internal https handler factory")
176+ }
177+
164 eng.messageRouter = messageRouter178 eng.messageRouter = messageRouter
165 eng.schemeHandler = schemeHandler179 eng.schemeHandler = schemeHandler
166 180
A internal/infrastructure/cef/internal_urls.go +110 -0
@@ -0,0 +1,110 @@
1+package cef
2+
3+import (
4+ "net/url"
5+ "path"
6+ "strings"
7+)
8+
9+const (
10+ conceptualInternalSchemePrefix = "dumb://"
11+ actualInternalScheme = "https"
12+ actualInternalHost = "dumber.invalid"
13+ actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost
14+)
15+
16+func isConceptualInternalURL(raw string) bool {
17+ return strings.HasPrefix(raw, conceptualInternalSchemePrefix) || strings.HasPrefix(raw, "dumb:")
18+}
19+
20+func isActualInternalURL(raw string) bool {
21+ parsed, err := url.Parse(raw)
22+ if err != nil {
23+ return false
24+ }
25+ return strings.EqualFold(parsed.Scheme, actualInternalScheme) && strings.EqualFold(parsed.Host, actualInternalHost)
26+}
27+
28+func toActualInternalURL(raw string) string {
29+ if raw == "" || isActualInternalURL(raw) {
30+ return raw
31+ }
32+
33+ parsed, err := url.Parse(raw)
34+ if err != nil || !strings.EqualFold(parsed.Scheme, "dumb") {
35+ return raw
36+ }
37+
38+ page := parsed.Host
39+ if page == "" {
40+ page = parsed.Opaque
41+ }
42+ if page == "" {
43+ return raw
44+ }
45+
46+ actualPath := "/" + page
47+ trimmedPath := strings.Trim(parsed.Path, "/")
48+ switch {
49+ case trimmedPath == "":
50+ // Keep page roots at /home instead of /home/ so relative assets resolve
51+ // from the origin root.
52+ case strings.HasPrefix(trimmedPath, "api/"):
53+ actualPath = "/" + trimmedPath
54+ case path.Ext(trimmedPath) != "":
55+ actualPath = "/" + trimmedPath
56+ default:
57+ actualPath = "/" + page + "/" + trimmedPath
58+ }
59+
60+ actual := url.URL{
61+ Scheme: actualInternalScheme,
62+ Host: actualInternalHost,
63+ Path: actualPath,
64+ RawQuery: parsed.RawQuery,
65+ Fragment: parsed.Fragment,
66+ }
67+ return actual.String()
68+}
69+
70+func toConceptualInternalURL(raw string) string {
71+ if raw == "" || !isActualInternalURL(raw) {
72+ return raw
73+ }
74+
75+ parsed, err := url.Parse(raw)
76+ if err != nil {
77+ return raw
78+ }
79+
80+ trimmedPath := strings.Trim(parsed.Path, "/")
81+ if trimmedPath == "" {
82+ return raw
83+ }
84+
85+ parts := strings.SplitN(trimmedPath, "/", 2)
86+ page := parts[0]
87+ if !isInternalPageHost(page) {
88+ return raw
89+ }
90+
91+ conceptual := url.URL{
92+ Scheme: "dumb",
93+ Host: page,
94+ RawQuery: parsed.RawQuery,
95+ Fragment: parsed.Fragment,
96+ }
97+ if len(parts) == 2 && parts[1] != "" {
98+ conceptual.Path = "/" + parts[1]
99+ }
100+ return conceptual.String()
101+}
102+
103+func isInternalPageHost(host string) bool {
104+ switch host {
105+ case homePath, configPath, webrtcPath, errorPath:
106+ return true
107+ default:
108+ return false
109+ }
110+}
@@ -0,0 +1,110 @@
1+package cef
2+
3+import (
4+ "net/url"
5+ "path"
6+ "strings"
7+)
8+
9+const (
10+ conceptualInternalSchemePrefix = "dumb://"
11+ actualInternalScheme = "https"
12+ actualInternalHost = "dumber.invalid"
13+ actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost
14+)
15+
16+func isConceptualInternalURL(raw string) bool {
17+ return strings.HasPrefix(raw, conceptualInternalSchemePrefix) || strings.HasPrefix(raw, "dumb:")
18+}
19+
20+func isActualInternalURL(raw string) bool {
21+ parsed, err := url.Parse(raw)
22+ if err != nil {
23+ return false
24+ }
25+ return strings.EqualFold(parsed.Scheme, actualInternalScheme) && strings.EqualFold(parsed.Host, actualInternalHost)
26+}
27+
28+func toActualInternalURL(raw string) string {
29+ if raw == "" || isActualInternalURL(raw) {
30+ return raw
31+ }
32+
33+ parsed, err := url.Parse(raw)
34+ if err != nil || !strings.EqualFold(parsed.Scheme, "dumb") {
35+ return raw
36+ }
37+
38+ page := parsed.Host
39+ if page == "" {
40+ page = parsed.Opaque
41+ }
42+ if page == "" {
43+ return raw
44+ }
45+
46+ actualPath := "/" + page
47+ trimmedPath := strings.Trim(parsed.Path, "/")
48+ switch {
49+ case trimmedPath == "":
50+ // Keep page roots at /home instead of /home/ so relative assets resolve
51+ // from the origin root.
52+ case strings.HasPrefix(trimmedPath, "api/"):
53+ actualPath = "/" + trimmedPath
54+ case path.Ext(trimmedPath) != "":
55+ actualPath = "/" + trimmedPath
56+ default:
57+ actualPath = "/" + page + "/" + trimmedPath
58+ }
59+
60+ actual := url.URL{
61+ Scheme: actualInternalScheme,
62+ Host: actualInternalHost,
63+ Path: actualPath,
64+ RawQuery: parsed.RawQuery,
65+ Fragment: parsed.Fragment,
66+ }
67+ return actual.String()
68+}
69+
70+func toConceptualInternalURL(raw string) string {
71+ if raw == "" || !isActualInternalURL(raw) {
72+ return raw
73+ }
74+
75+ parsed, err := url.Parse(raw)
76+ if err != nil {
77+ return raw
78+ }
79+
80+ trimmedPath := strings.Trim(parsed.Path, "/")
81+ if trimmedPath == "" {
82+ return raw
83+ }
84+
85+ parts := strings.SplitN(trimmedPath, "/", 2)
86+ page := parts[0]
87+ if !isInternalPageHost(page) {
88+ return raw
89+ }
90+
91+ conceptual := url.URL{
92+ Scheme: "dumb",
93+ Host: page,
94+ RawQuery: parsed.RawQuery,
95+ Fragment: parsed.Fragment,
96+ }
97+ if len(parts) == 2 && parts[1] != "" {
98+ conceptual.Path = "/" + parts[1]
99+ }
100+ return conceptual.String()
101+}
102+
103+func isInternalPageHost(host string) bool {
104+ switch host {
105+ case homePath, configPath, webrtcPath, errorPath:
106+ return true
107+ default:
108+ return false
109+ }
110+}
A internal/infrastructure/cef/internal_urls_test.go +83 -0
@@ -0,0 +1,83 @@
1+package cef
2+
3+import "testing"
4+
5+func TestToActualInternalURL(t *testing.T) {
6+ t.Parallel()
7+
8+ tests := []struct {
9+ name string
10+ in string
11+ want string
12+ }{
13+ {
14+ name: "page root",
15+ in: "dumb://home",
16+ want: "https://dumber.invalid/home",
17+ },
18+ {
19+ name: "api path stays at origin root",
20+ in: "dumb://home/api/message",
21+ want: "https://dumber.invalid/api/message",
22+ },
23+ {
24+ name: "root asset stays at origin root",
25+ in: "dumb://home/favicon.ico",
26+ want: "https://dumber.invalid/favicon.ico",
27+ },
28+ {
29+ name: "page subroute stays under page namespace",
30+ in: "dumb://home/crash?url=https%3A%2F%2Fexample.com",
31+ want: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",
32+ },
33+ {
34+ name: "actual internal URL unchanged",
35+ in: "https://dumber.invalid/config",
36+ want: "https://dumber.invalid/config",
37+ },
38+ }
39+
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ t.Parallel()
43+ if got := toActualInternalURL(tt.in); got != tt.want {
44+ t.Fatalf("toActualInternalURL(%q) = %q, want %q", tt.in, got, tt.want)
45+ }
46+ })
47+ }
48+}
49+
50+func TestToConceptualInternalURL(t *testing.T) {
51+ t.Parallel()
52+
53+ tests := []struct {
54+ name string
55+ in string
56+ want string
57+ }{
58+ {
59+ name: "page root",
60+ in: "https://dumber.invalid/home",
61+ want: "dumb://home",
62+ },
63+ {
64+ name: "page subroute",
65+ in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",
66+ want: "dumb://home/crash?url=https%3A%2F%2Fexample.com",
67+ },
68+ {
69+ name: "root asset is left unchanged",
70+ in: "https://dumber.invalid/favicon.ico",
71+ want: "https://dumber.invalid/favicon.ico",
72+ },
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ t.Parallel()
78+ if got := toConceptualInternalURL(tt.in); got != tt.want {
79+ t.Fatalf("toConceptualInternalURL(%q) = %q, want %q", tt.in, got, tt.want)
80+ }
81+ })
82+ }
83+}
@@ -0,0 +1,83 @@
1+package cef
2+
3+import "testing"
4+
5+func TestToActualInternalURL(t *testing.T) {
6+ t.Parallel()
7+
8+ tests := []struct {
9+ name string
10+ in string
11+ want string
12+ }{
13+ {
14+ name: "page root",
15+ in: "dumb://home",
16+ want: "https://dumber.invalid/home",
17+ },
18+ {
19+ name: "api path stays at origin root",
20+ in: "dumb://home/api/message",
21+ want: "https://dumber.invalid/api/message",
22+ },
23+ {
24+ name: "root asset stays at origin root",
25+ in: "dumb://home/favicon.ico",
26+ want: "https://dumber.invalid/favicon.ico",
27+ },
28+ {
29+ name: "page subroute stays under page namespace",
30+ in: "dumb://home/crash?url=https%3A%2F%2Fexample.com",
31+ want: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",
32+ },
33+ {
34+ name: "actual internal URL unchanged",
35+ in: "https://dumber.invalid/config",
36+ want: "https://dumber.invalid/config",
37+ },
38+ }
39+
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ t.Parallel()
43+ if got := toActualInternalURL(tt.in); got != tt.want {
44+ t.Fatalf("toActualInternalURL(%q) = %q, want %q", tt.in, got, tt.want)
45+ }
46+ })
47+ }
48+}
49+
50+func TestToConceptualInternalURL(t *testing.T) {
51+ t.Parallel()
52+
53+ tests := []struct {
54+ name string
55+ in string
56+ want string
57+ }{
58+ {
59+ name: "page root",
60+ in: "https://dumber.invalid/home",
61+ want: "dumb://home",
62+ },
63+ {
64+ name: "page subroute",
65+ in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",
66+ want: "dumb://home/crash?url=https%3A%2F%2Fexample.com",
67+ },
68+ {
69+ name: "root asset is left unchanged",
70+ in: "https://dumber.invalid/favicon.ico",
71+ want: "https://dumber.invalid/favicon.ico",
72+ },
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ t.Parallel()
78+ if got := toConceptualInternalURL(tt.in); got != tt.want {
79+ t.Fatalf("toConceptualInternalURL(%q) = %q, want %q", tt.in, got, tt.want)
80+ }
81+ })
82+ }
83+}
M internal/infrastructure/cef/scheme_handler.go +214 -10
@@ -18,7 +18,9 @@ import (
1818
1919 purecef "github.com/bnema/purego-cef/cef"
2020
21+ "github.com/bnema/dumber/internal/application/port"
2122 "github.com/bnema/dumber/internal/infrastructure/config"
23+ "github.com/bnema/dumber/internal/infrastructure/env"
2224 "github.com/bnema/dumber/internal/logging"
2325 "github.com/rs/zerolog"
2426 )
@@ -32,7 +34,8 @@ const (
3234 indexHTML = "index.html"
3335 )
3436
35-// dumbSchemeHandler implements purecef.SchemeHandlerFactory for the dumb:// scheme.
37+// dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
38+// internal https://dumber.invalid origin used by CEF.
3639 type dumbSchemeHandler struct {
3740 ctx context.Context
3841 messageRouter *MessageRouter
@@ -40,10 +43,11 @@ type dumbSchemeHandler struct {
4043 assetsSet bool
4144 assetDir string
4245 logger zerolog.Logger
46+ hwSurveyor *env.HardwareSurveyor
4347 mu sync.RWMutex
4448 }
4549
46-// newDumbSchemeHandler creates a handler for the dumb:// scheme.
50+// newDumbSchemeHandler creates a handler for internal CEF pages.
4751 func newDumbSchemeHandler(ctx context.Context, router *MessageRouter) *dumbSchemeHandler {
4852 log := logging.FromContext(ctx)
4953 return &dumbSchemeHandler{
@@ -51,6 +55,7 @@ func newDumbSchemeHandler(ctx context.Context, router *MessageRouter) *dumbSchem
5155 messageRouter: router,
5256 assetDir: "webui",
5357 logger: log.With().Str("component", "scheme-handler").Logger(),
58+ hwSurveyor: env.NewHardwareSurveyor(),
5459 }
5560 }
5661
@@ -63,8 +68,8 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
6368 h.logger.Debug().Msg("assets filesystem configured")
6469 }
6570
66-// Create implements purecef.SchemeHandlerFactory. Called on CEF IO thread for each
67-// request to the dumb:// scheme. Returns a ResourceHandler to serve the response.
71+// Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
72+// for requests to either dumb:// or the internal https origin.
6873 func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {
6974 reqURL := request.GetURL()
7075 method := request.GetMethod()
@@ -80,12 +85,22 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string,
8085 return h.newErrorResourceHandler(http.StatusBadRequest, "Invalid URL")
8186 }
8287
88+ if isCEFCrashPageURL(u) {
89+ originalURI := sanitizeCEFCrashPageOriginalURI(strings.TrimSpace(u.Query().Get("url")))
90+ return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI)))
91+ }
92+
93+ // Keep dumb:// as the app-level abstraction, but redirect CEF loads to a
94+ // normal HTTPS origin so Chromium applies the standard web security model.
95+ if isConceptualInternalURL(reqURL) {
96+ targetURL := toActualInternalURL(reqURL)
97+ if targetURL != "" && targetURL != reqURL {
98+ return h.newRedirectResourceHandler(http.StatusTemporaryRedirect, targetURL)
99+ }
100+ }
101+
83102 // Route API requests.
84103 path := u.Path
85- if path == "" && u.Opaque != "" {
86- // Handle opaque URLs like dumb:home — no path component.
87- path = ""
88- }
89104
90105 if strings.HasPrefix(path, "/api/") {
91106 return h.handleAPI(method, path, request)
@@ -122,13 +137,29 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque
122137
123138 // handleConfigAPI returns a JSON response with the given config.
124139 func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.ResourceHandler {
125- data, err := json.Marshal(cfg)
140+ var hwInfo *port.HardwareInfo
141+ if h.hwSurveyor != nil {
142+ survey := h.hwSurveyor.Survey(context.Background())
143+ hwInfo = &survey
144+ }
145+ data, err := json.Marshal(config.BuildWebUIConfigPayload(cfg, hwInfo))
126146 if err != nil {
127147 return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
128148 }
129149 return h.newRawResourceHandler(http.StatusOK, "application/json", data)
130150 }
131151
152+func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
153+ return purecef.NewResourceHandler(&staticResourceHandler{
154+ contentType: "text/plain; charset=utf-8",
155+ statusCode: status,
156+ headers: map[string]string{
157+ "Location": location,
158+ "Cache-Control": "no-store",
159+ },
160+ })
161+}
162+
132163 // handleAsset serves static files from the embedded filesystem.
133164 func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
134165 h.mu.RLock()
@@ -162,12 +193,25 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
162193 return h.newRawResourceHandler(http.StatusOK, contentType, data)
163194 }
164195
165-// resolveAssetPath maps a dumb:// URL to a relative asset path.
196+// resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
197+// to a relative asset path inside assets/webui.
166198 func resolveAssetPath(u *url.URL) (string, bool) {
167199 if u == nil {
168200 return "", false
169201 }
170202
203+ if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {
204+ return resolveActualAssetPath(u)
205+ }
206+
207+ return resolveConceptualAssetPath(u)
208+}
209+
210+func resolveConceptualAssetPath(u *url.URL) (string, bool) {
211+ if u == nil {
212+ return "", false
213+ }
214+
171215 rootByHost := map[string]string{
172216 homePath: indexHTML,
173217 configPath: "config.html",
@@ -202,6 +246,159 @@ func resolveAssetPath(u *url.URL) (string, bool) {
202246 }
203247 }
204248
249+func resolveActualAssetPath(u *url.URL) (string, bool) {
250+ if u == nil {
251+ return "", false
252+ }
253+
254+ path := strings.Trim(u.Path, "/")
255+ if path == "" {
256+ return "", false
257+ }
258+
259+ rootByPath := map[string]string{
260+ homePath: indexHTML,
261+ configPath: "config.html",
262+ webrtcPath: "webrtc.html",
263+ errorPath: "error.html",
264+ }
265+ if root, ok := rootByPath[path]; ok {
266+ return root, true
267+ }
268+
269+ if strings.HasPrefix(path, "api/") {
270+ return "", false
271+ }
272+
273+ parts := strings.SplitN(path, "/", 2)
274+ if len(parts) == 2 && isInternalPageHost(parts[0]) {
275+ if parts[1] == "crash" && parts[0] == homePath {
276+ return "", false
277+ }
278+ }
279+
280+ // Serve root assets like homepage.min.js, style.css and favicon.png.
281+ return path, true
282+}
283+
284+func isCEFCrashPageURL(u *url.URL) bool {
285+ if u == nil {
286+ return false
287+ }
288+
289+ switch {
290+ case strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost):
291+ return strings.Trim(u.Path, "/") == homePath+"/crash"
292+ case strings.EqualFold(u.Scheme, "dumb") && u.Host == homePath:
293+ return strings.Trim(u.Path, "/") == "crash"
294+ default:
295+ return false
296+ }
297+}
298+
299+func sanitizeCEFCrashPageOriginalURI(originalURI string) string {
300+ if originalURI == "" {
301+ return ""
302+ }
303+ parsed, err := url.Parse(originalURI)
304+ if err != nil {
305+ return ""
306+ }
307+ switch strings.ToLower(parsed.Scheme) {
308+ case "http", "https":
309+ if parsed.Host == "" {
310+ return ""
311+ }
312+ return parsed.String()
313+ case "dumb":
314+ if parsed.Host == "" && parsed.Opaque == "" {
315+ return ""
316+ }
317+ return parsed.String()
318+ default:
319+ return ""
320+ }
321+}
322+
323+func buildCEFCrashPageHTML(originalURI string) string {
324+ escapedURI := html.EscapeString(originalURI)
325+ return fmt.Sprintf(`<!DOCTYPE html>
326+<html lang="en">
327+<head>
328+ <meta charset="utf-8">
329+ <meta name="viewport" content="width=device-width, initial-scale=1">
330+ <title>Renderer crashed</title>
331+ <style>
332+ :root { color-scheme: dark; font-family: "IBM Plex Sans", "Segoe UI", sans-serif; }
333+ body {
334+ margin: 0;
335+ min-height: 100vh;
336+ display: flex;
337+ align-items: center;
338+ justify-content: center;
339+ background: radial-gradient(circle at top, #253447, #101622 55%%);
340+ color: #f2f6fa;
341+ padding: 24px;
342+ }
343+ .card {
344+ width: min(640px, 100%%);
345+ background: rgba(10, 16, 26, 0.86);
346+ border: 1px solid rgba(144, 173, 205, 0.35);
347+ border-radius: 16px;
348+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
349+ padding: 28px;
350+ }
351+ .url {
352+ margin: 16px 0 20px;
353+ padding: 12px;
354+ border-radius: 10px;
355+ background: rgba(26, 38, 56, 0.85);
356+ border: 1px solid rgba(139, 167, 194, 0.28);
357+ font-family: "IBM Plex Mono", "Fira Code", monospace;
358+ overflow-wrap: anywhere;
359+ }
360+ .actions { display: flex; gap: 12px; flex-wrap: wrap; }
361+ button {
362+ border: 0;
363+ border-radius: 10px;
364+ padding: 10px 16px;
365+ cursor: pointer;
366+ font-size: 0.95rem;
367+ font-weight: 600;
368+ }
369+ .primary { background: #4dd0e1; color: #061018; }
370+ .secondary { background: #233346; color: #d6e5f5; }
371+ </style>
372+</head>
373+<body>
374+ <div class="card">
375+ <h1>Renderer process ended</h1>
376+ <p>The current page was interrupted. You can reload it to continue browsing.</p>
377+ <div class="url">%s</div>
378+ <div class="actions">
379+ <button class="primary" id="reload-btn" data-target="%s">Reload page</button>
380+ <button class="secondary" id="stay-btn">Stay on this page</button>
381+ </div>
382+ </div>
383+ <script>
384+ const reloadButton = document.getElementById('reload-btn');
385+ const targetUrl = (reloadButton.getAttribute('data-target') || '').trim();
386+ reloadButton.addEventListener('click', function() {
387+ if (targetUrl) {
388+ window.location.href = targetUrl;
389+ return;
390+ }
391+ window.location.reload();
392+ });
393+ document.getElementById('stay-btn').addEventListener('click', function() {
394+ this.disabled = true;
395+ this.textContent = 'Staying on page';
396+ });
397+ </script>
398+</body>
399+</html>`, escapedURI, escapedURI)
400+}
401+
205402 // getMimeType determines the MIME type for a given file path.
206403 func getMimeType(filename string) string {
207404 ext := strings.ToLower(filepath.Ext(filename))
@@ -270,6 +467,7 @@ type staticResourceHandler struct {
270467 data []byte
271468 contentType string
272469 statusCode int
470+ headers map[string]string
273471 offset int
274472 }
275473
@@ -312,11 +510,17 @@ func (rh *staticResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Cal
312510 // the charset must be set separately via SetCharset.
313511 func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, responseLength unsafe.Pointer, _ uintptr) {
314512 response.SetStatus(int32(rh.statusCode))
513+ if text := http.StatusText(rh.statusCode); text != "" {
514+ response.SetStatusText(text)
515+ }
315516 mimeType, charset := splitMimeCharset(rh.contentType)
316517 response.SetMimeType(mimeType)
317518 if charset != "" {
318519 response.SetCharset(charset)
319520 }
521+ for name, value := range rh.headers {
522+ response.SetHeaderByName(name, value, 1)
523+ }
320524 *(*int64)(responseLength) = int64(len(rh.data))
321525 }
322526
@@ -18,7 +18,9 @@ import (
18 18
19 purecef "github.com/bnema/purego-cef/cef"19 purecef "github.com/bnema/purego-cef/cef"
20 20
21+ "github.com/bnema/dumber/internal/application/port"
21 "github.com/bnema/dumber/internal/infrastructure/config"22 "github.com/bnema/dumber/internal/infrastructure/config"
23+ "github.com/bnema/dumber/internal/infrastructure/env"
22 "github.com/bnema/dumber/internal/logging"24 "github.com/bnema/dumber/internal/logging"
23 "github.com/rs/zerolog"25 "github.com/rs/zerolog"
24 )26 )
@@ -32,7 +34,8 @@ const (
32 indexHTML = "index.html"34 indexHTML = "index.html"
33 )35 )
34 36
35-// dumbSchemeHandler implements purecef.SchemeHandlerFactory for the dumb:// scheme.37+// dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
38+// internal https://dumber.invalid origin used by CEF.
36 type dumbSchemeHandler struct {39 type dumbSchemeHandler struct {
37 ctx context.Context40 ctx context.Context
38 messageRouter *MessageRouter41 messageRouter *MessageRouter
@@ -40,10 +43,11 @@ type dumbSchemeHandler struct {
40 assetsSet bool43 assetsSet bool
41 assetDir string44 assetDir string
42 logger zerolog.Logger45 logger zerolog.Logger
46+ hwSurveyor *env.HardwareSurveyor
43 mu sync.RWMutex47 mu sync.RWMutex
44 }48 }
45 49
46-// newDumbSchemeHandler creates a handler for the dumb:// scheme.50+// newDumbSchemeHandler creates a handler for internal CEF pages.
47 func newDumbSchemeHandler(ctx context.Context, router *MessageRouter) *dumbSchemeHandler {51 func newDumbSchemeHandler(ctx context.Context, router *MessageRouter) *dumbSchemeHandler {
48 log := logging.FromContext(ctx)52 log := logging.FromContext(ctx)
49 return &dumbSchemeHandler{53 return &dumbSchemeHandler{
@@ -51,6 +55,7 @@ func newDumbSchemeHandler(ctx context.Context, router *MessageRouter) *dumbSchem
51 messageRouter: router,55 messageRouter: router,
52 assetDir: "webui",56 assetDir: "webui",
53 logger: log.With().Str("component", "scheme-handler").Logger(),57 logger: log.With().Str("component", "scheme-handler").Logger(),
58+ hwSurveyor: env.NewHardwareSurveyor(),
54 }59 }
55 }60 }
56 61
@@ -63,8 +68,8 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
63 h.logger.Debug().Msg("assets filesystem configured")68 h.logger.Debug().Msg("assets filesystem configured")
64 }69 }
65 70
66-// Create implements purecef.SchemeHandlerFactory. Called on CEF IO thread for each71+// Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
67-// request to the dumb:// scheme. Returns a ResourceHandler to serve the response.72+// for requests to either dumb:// or the internal https origin.
68 func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {73 func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {
69 reqURL := request.GetURL()74 reqURL := request.GetURL()
70 method := request.GetMethod()75 method := request.GetMethod()
@@ -80,12 +85,22 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string,
80 return h.newErrorResourceHandler(http.StatusBadRequest, "Invalid URL")85 return h.newErrorResourceHandler(http.StatusBadRequest, "Invalid URL")
81 }86 }
82 87
88+ if isCEFCrashPageURL(u) {
89+ originalURI := sanitizeCEFCrashPageOriginalURI(strings.TrimSpace(u.Query().Get("url")))
90+ return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI)))
91+ }
92+
93+ // Keep dumb:// as the app-level abstraction, but redirect CEF loads to a
94+ // normal HTTPS origin so Chromium applies the standard web security model.
95+ if isConceptualInternalURL(reqURL) {
96+ targetURL := toActualInternalURL(reqURL)
97+ if targetURL != "" && targetURL != reqURL {
98+ return h.newRedirectResourceHandler(http.StatusTemporaryRedirect, targetURL)
99+ }
100+ }
101+
83 // Route API requests.102 // Route API requests.
84 path := u.Path103 path := u.Path
85- if path == "" && u.Opaque != "" {
86- // Handle opaque URLs like dumb:home — no path component.
87- path = ""
88- }
89 104
90 if strings.HasPrefix(path, "/api/") {105 if strings.HasPrefix(path, "/api/") {
91 return h.handleAPI(method, path, request)106 return h.handleAPI(method, path, request)
@@ -122,13 +137,29 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque
122 137
123 // handleConfigAPI returns a JSON response with the given config.138 // handleConfigAPI returns a JSON response with the given config.
124 func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.ResourceHandler {139 func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.ResourceHandler {
125- data, err := json.Marshal(cfg)140+ var hwInfo *port.HardwareInfo
141+ if h.hwSurveyor != nil {
142+ survey := h.hwSurveyor.Survey(context.Background())
143+ hwInfo = &survey
144+ }
145+ data, err := json.Marshal(config.BuildWebUIConfigPayload(cfg, hwInfo))
126 if err != nil {146 if err != nil {
127 return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})147 return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
128 }148 }
129 return h.newRawResourceHandler(http.StatusOK, "application/json", data)149 return h.newRawResourceHandler(http.StatusOK, "application/json", data)
130 }150 }
131 151
152+func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
153+ return purecef.NewResourceHandler(&staticResourceHandler{
154+ contentType: "text/plain; charset=utf-8",
155+ statusCode: status,
156+ headers: map[string]string{
157+ "Location": location,
158+ "Cache-Control": "no-store",
159+ },
160+ })
161+}
162+
132 // handleAsset serves static files from the embedded filesystem.163 // handleAsset serves static files from the embedded filesystem.
133 func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {164 func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
134 h.mu.RLock()165 h.mu.RLock()
@@ -162,12 +193,25 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
162 return h.newRawResourceHandler(http.StatusOK, contentType, data)193 return h.newRawResourceHandler(http.StatusOK, contentType, data)
163 }194 }
164 195
165-// resolveAssetPath maps a dumb:// URL to a relative asset path.196+// resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
197+// to a relative asset path inside assets/webui.
166 func resolveAssetPath(u *url.URL) (string, bool) {198 func resolveAssetPath(u *url.URL) (string, bool) {
167 if u == nil {199 if u == nil {
168 return "", false200 return "", false
169 }201 }
170 202
203+ if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {
204+ return resolveActualAssetPath(u)
205+ }
206+
207+ return resolveConceptualAssetPath(u)
208+}
209+
210+func resolveConceptualAssetPath(u *url.URL) (string, bool) {
211+ if u == nil {
212+ return "", false
213+ }
214+
171 rootByHost := map[string]string{215 rootByHost := map[string]string{
172 homePath: indexHTML,216 homePath: indexHTML,
173 configPath: "config.html",217 configPath: "config.html",
@@ -202,6 +246,159 @@ func resolveAssetPath(u *url.URL) (string, bool) {
202 }246 }
203 }247 }
204 248
249+func resolveActualAssetPath(u *url.URL) (string, bool) {
250+ if u == nil {
251+ return "", false
252+ }
253+
254+ path := strings.Trim(u.Path, "/")
255+ if path == "" {
256+ return "", false
257+ }
258+
259+ rootByPath := map[string]string{
260+ homePath: indexHTML,
261+ configPath: "config.html",
262+ webrtcPath: "webrtc.html",
263+ errorPath: "error.html",
264+ }
265+ if root, ok := rootByPath[path]; ok {
266+ return root, true
267+ }
268+
269+ if strings.HasPrefix(path, "api/") {
270+ return "", false
271+ }
272+
273+ parts := strings.SplitN(path, "/", 2)
274+ if len(parts) == 2 && isInternalPageHost(parts[0]) {
275+ if parts[1] == "crash" && parts[0] == homePath {
276+ return "", false
277+ }
278+ }
279+
280+ // Serve root assets like homepage.min.js, style.css and favicon.png.
281+ return path, true
282+}
283+
284+func isCEFCrashPageURL(u *url.URL) bool {
285+ if u == nil {
286+ return false
287+ }
288+
289+ switch {
290+ case strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost):
291+ return strings.Trim(u.Path, "/") == homePath+"/crash"
292+ case strings.EqualFold(u.Scheme, "dumb") && u.Host == homePath:
293+ return strings.Trim(u.Path, "/") == "crash"
294+ default:
295+ return false
296+ }
297+}
298+
299+func sanitizeCEFCrashPageOriginalURI(originalURI string) string {
300+ if originalURI == "" {
301+ return ""
302+ }
303+ parsed, err := url.Parse(originalURI)
304+ if err != nil {
305+ return ""
306+ }
307+ switch strings.ToLower(parsed.Scheme) {
308+ case "http", "https":
309+ if parsed.Host == "" {
310+ return ""
311+ }
312+ return parsed.String()
313+ case "dumb":
314+ if parsed.Host == "" && parsed.Opaque == "" {
315+ return ""
316+ }
317+ return parsed.String()
318+ default:
319+ return ""
320+ }
321+}
322+
323+func buildCEFCrashPageHTML(originalURI string) string {
324+ escapedURI := html.EscapeString(originalURI)
325+ return fmt.Sprintf(`<!DOCTYPE html>
326+<html lang="en">
327+<head>
328+ <meta charset="utf-8">
329+ <meta name="viewport" content="width=device-width, initial-scale=1">
330+ <title>Renderer crashed</title>
331+ <style>
332+ :root { color-scheme: dark; font-family: "IBM Plex Sans", "Segoe UI", sans-serif; }
333+ body {
334+ margin: 0;
335+ min-height: 100vh;
336+ display: flex;
337+ align-items: center;
338+ justify-content: center;
339+ background: radial-gradient(circle at top, #253447, #101622 55%%);
340+ color: #f2f6fa;
341+ padding: 24px;
342+ }
343+ .card {
344+ width: min(640px, 100%%);
345+ background: rgba(10, 16, 26, 0.86);
346+ border: 1px solid rgba(144, 173, 205, 0.35);
347+ border-radius: 16px;
348+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
349+ padding: 28px;
350+ }
351+ .url {
352+ margin: 16px 0 20px;
353+ padding: 12px;
354+ border-radius: 10px;
355+ background: rgba(26, 38, 56, 0.85);
356+ border: 1px solid rgba(139, 167, 194, 0.28);
357+ font-family: "IBM Plex Mono", "Fira Code", monospace;
358+ overflow-wrap: anywhere;
359+ }
360+ .actions { display: flex; gap: 12px; flex-wrap: wrap; }
361+ button {
362+ border: 0;
363+ border-radius: 10px;
364+ padding: 10px 16px;
365+ cursor: pointer;
366+ font-size: 0.95rem;
367+ font-weight: 600;
368+ }
369+ .primary { background: #4dd0e1; color: #061018; }
370+ .secondary { background: #233346; color: #d6e5f5; }
371+ </style>
372+</head>
373+<body>
374+ <div class="card">
375+ <h1>Renderer process ended</h1>
376+ <p>The current page was interrupted. You can reload it to continue browsing.</p>
377+ <div class="url">%s</div>
378+ <div class="actions">
379+ <button class="primary" id="reload-btn" data-target="%s">Reload page</button>
380+ <button class="secondary" id="stay-btn">Stay on this page</button>
381+ </div>
382+ </div>
383+ <script>
384+ const reloadButton = document.getElementById('reload-btn');
385+ const targetUrl = (reloadButton.getAttribute('data-target') || '').trim();
386+ reloadButton.addEventListener('click', function() {
387+ if (targetUrl) {
388+ window.location.href = targetUrl;
389+ return;
390+ }
391+ window.location.reload();
392+ });
393+ document.getElementById('stay-btn').addEventListener('click', function() {
394+ this.disabled = true;
395+ this.textContent = 'Staying on page';
396+ });
397+ </script>
398+</body>
399+</html>`, escapedURI, escapedURI)
400+}
401+
205 // getMimeType determines the MIME type for a given file path.402 // getMimeType determines the MIME type for a given file path.
206 func getMimeType(filename string) string {403 func getMimeType(filename string) string {
207 ext := strings.ToLower(filepath.Ext(filename))404 ext := strings.ToLower(filepath.Ext(filename))
@@ -270,6 +467,7 @@ type staticResourceHandler struct {
270 data []byte467 data []byte
271 contentType string468 contentType string
272 statusCode int469 statusCode int
470+ headers map[string]string
273 offset int471 offset int
274 }472 }
275 473
@@ -312,11 +510,17 @@ func (rh *staticResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Cal
312 // the charset must be set separately via SetCharset.510 // the charset must be set separately via SetCharset.
313 func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, responseLength unsafe.Pointer, _ uintptr) {511 func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, responseLength unsafe.Pointer, _ uintptr) {
314 response.SetStatus(int32(rh.statusCode))512 response.SetStatus(int32(rh.statusCode))
513+ if text := http.StatusText(rh.statusCode); text != "" {
514+ response.SetStatusText(text)
515+ }
315 mimeType, charset := splitMimeCharset(rh.contentType)516 mimeType, charset := splitMimeCharset(rh.contentType)
316 response.SetMimeType(mimeType)517 response.SetMimeType(mimeType)
317 if charset != "" {518 if charset != "" {
318 response.SetCharset(charset)519 response.SetCharset(charset)
319 }520 }
521+ for name, value := range rh.headers {
522+ response.SetHeaderByName(name, value, 1)
523+ }
320 *(*int64)(responseLength) = int64(len(rh.data))524 *(*int64)(responseLength) = int64(len(rh.data))
321 }525 }
322 526

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close