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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +520 -26
M cmd/cef-helper/main.go +3 -1
| @@ -14,6 +14,8 @@ import ( | |||
| 14 | 14 | "runtime" | |
| 15 | 15 | ||
| 16 | 16 | cef "github.com/bnema/purego-cef/cef" | |
| 17 | + | ||
| 18 | + | dumbercef "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| 19 | 21 | func main() { | |
| @@ -25,7 +27,7 @@ func main() { | |||
| 25 | 27 | // If it returns (code < 0), this is not a subprocess — shouldn't | |
| 26 | 28 | // happen in normal operation since CEF only launches this binary | |
| 27 | 29 | // with --type= flags. | |
| 28 | - | cef.MaybeExitSubprocess() | |
| 30 | + | cef.MaybeExitSubprocessWithApp(dumbercef.NewSubprocessApp()) | |
| 29 | 31 | ||
| 30 | 32 | // If we get here, something went wrong. | |
| 31 | 33 | 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't | 27 | // If it returns (code < 0), this is not a subprocess — shouldn't | ||
| 26 | // happen in normal operation since CEF only launches this binary | 28 | // 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 ( | |||
| 6 | 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 | 41 | // dumberApp implements purecef.App to provide a BrowserProcessHandler with | |
| 10 | 42 | // demand-driven message pump scheduling (OnScheduleMessagePumpWork). | |
| 11 | 43 | type dumberApp struct { | |
| @@ -23,20 +55,19 @@ func newDumberApp(engine *Engine) purecef.App { | |||
| 23 | 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 | 64 | // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths. | |
| 27 | 65 | const maxCmdLineLogLen = 200 | |
| 28 | 66 | ||
| 29 | 67 | func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) { | |
| 30 | 68 | log := logging.FromContext(a.engine.ctx) | |
| 31 | 69 | 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) | |
| 40 | 71 | ||
| 41 | 72 | cmdline := commandLine.GetCommandLineString() | |
| 42 | 73 | if len(cmdline) > maxCmdLineLogLen { | |
| @@ -52,18 +83,25 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin | |||
| 52 | 83 | } | |
| 53 | 84 | } | |
| 54 | 85 | 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) | |
| 61 | 87 | logging.FromContext(a.engine.ctx).Debug().Msg("cef: registered dumb:// custom scheme") | |
| 62 | 88 | } | |
| 63 | 89 | func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | |
| 64 | 90 | func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph } | |
| 65 | 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 | 105 | // dumberBPH implements purecef.BrowserProcessHandler. Only | |
| 68 | 106 | // OnScheduleMessagePumpWork carries real logic; the rest are no-ops. | |
| 69 | 107 | 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 with | 41 | // 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 = 200 | 65 | 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. Only | 105 | // 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( | |||
| 154 | 154 | schemeHandler := newDumbSchemeHandler(ctx, messageRouter) | |
| 155 | 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 | 160 | if result != 1 { | |
| 159 | 161 | logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory") | |
| 160 | 162 | } else { | |
| 161 | 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 | 178 | eng.messageRouter = messageRouter | |
| 165 | 179 | eng.schemeHandler = schemeHandler | |
| 166 | 180 | ||
| @@ -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 = messageRouter | 178 | eng.messageRouter = messageRouter | ||
| 165 | eng.schemeHandler = schemeHandler | 179 | 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 ( | |||
| 18 | 18 | ||
| 19 | 19 | purecef "github.com/bnema/purego-cef/cef" | |
| 20 | 20 | ||
| 21 | + | "github.com/bnema/dumber/internal/application/port" | |
| 21 | 22 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 23 | + | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 22 | 24 | "github.com/bnema/dumber/internal/logging" | |
| 23 | 25 | "github.com/rs/zerolog" | |
| 24 | 26 | ) | |
| @@ -32,7 +34,8 @@ const ( | |||
| 32 | 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 | 39 | type dumbSchemeHandler struct { | |
| 37 | 40 | ctx context.Context | |
| 38 | 41 | messageRouter *MessageRouter | |
| @@ -40,10 +43,11 @@ type dumbSchemeHandler struct { | |||
| 40 | 43 | assetsSet bool | |
| 41 | 44 | assetDir string | |
| 42 | 45 | logger zerolog.Logger | |
| 46 | + | hwSurveyor *env.HardwareSurveyor | |
| 43 | 47 | 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 | 51 | func newDumbSchemeHandler(ctx context.Context, router *MessageRouter) *dumbSchemeHandler { | |
| 48 | 52 | log := logging.FromContext(ctx) | |
| 49 | 53 | return &dumbSchemeHandler{ | |
| @@ -51,6 +55,7 @@ func newDumbSchemeHandler(ctx context.Context, router *MessageRouter) *dumbSchem | |||
| 51 | 55 | messageRouter: router, | |
| 52 | 56 | assetDir: "webui", | |
| 53 | 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 | 68 | h.logger.Debug().Msg("assets filesystem configured") | |
| 64 | 69 | } | |
| 65 | 70 | ||
| 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. | |
| 68 | 73 | func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { | |
| 69 | 74 | reqURL := request.GetURL() | |
| 70 | 75 | method := request.GetMethod() | |
| @@ -80,12 +85,22 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, | |||
| 80 | 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 | 102 | // Route API requests. | |
| 84 | 103 | path := u.Path | |
| 85 | - | if path == "" && u.Opaque != "" { | |
| 86 | - | // Handle opaque URLs like dumb:home — no path component. | |
| 87 | - | path = "" | |
| 88 | - | } | |
| 89 | 104 | ||
| 90 | 105 | if strings.HasPrefix(path, "/api/") { | |
| 91 | 106 | return h.handleAPI(method, path, request) | |
| @@ -122,13 +137,29 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque | |||
| 122 | 137 | ||
| 123 | 138 | // handleConfigAPI returns a JSON response with the given config. | |
| 124 | 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 | 146 | if err != nil { | |
| 127 | 147 | return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | |
| 128 | 148 | } | |
| 129 | 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 | 163 | // handleAsset serves static files from the embedded filesystem. | |
| 133 | 164 | func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |
| 134 | 165 | h.mu.RLock() | |
| @@ -162,12 +193,25 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||
| 162 | 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 | 198 | func resolveAssetPath(u *url.URL) (string, bool) { | |
| 167 | 199 | if u == nil { | |
| 168 | 200 | 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 | 215 | rootByHost := map[string]string{ | |
| 172 | 216 | homePath: indexHTML, | |
| 173 | 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 | 402 | // getMimeType determines the MIME type for a given file path. | |
| 206 | 403 | func getMimeType(filename string) string { | |
| 207 | 404 | ext := strings.ToLower(filepath.Ext(filename)) | |
| @@ -270,6 +467,7 @@ type staticResourceHandler struct { | |||
| 270 | 467 | data []byte | |
| 271 | 468 | contentType string | |
| 272 | 469 | statusCode int | |
| 470 | + | headers map[string]string | |
| 273 | 471 | offset int | |
| 274 | 472 | } | |
| 275 | 473 | ||
| @@ -312,11 +510,17 @@ func (rh *staticResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Cal | |||
| 312 | 510 | // the charset must be set separately via SetCharset. | |
| 313 | 511 | func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, responseLength unsafe.Pointer, _ uintptr) { | |
| 314 | 512 | response.SetStatus(int32(rh.statusCode)) | |
| 513 | + | if text := http.StatusText(rh.statusCode); text != "" { | |
| 514 | + | response.SetStatusText(text) | |
| 515 | + | } | |
| 315 | 516 | mimeType, charset := splitMimeCharset(rh.contentType) | |
| 316 | 517 | response.SetMimeType(mimeType) | |
| 317 | 518 | if charset != "" { | |
| 318 | 519 | response.SetCharset(charset) | |
| 319 | 520 | } | |
| 521 | + | for name, value := range rh.headers { | |
| 522 | + | response.SetHeaderByName(name, value, 1) | |
| 523 | + | } | |
| 320 | 524 | *(*int64)(responseLength) = int64(len(rh.data)) | |
| 321 | 525 | } | |
| 322 | 526 | ||
| @@ -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.Context | 40 | ctx context.Context | ||
| 38 | messageRouter *MessageRouter | 41 | messageRouter *MessageRouter | ||
| @@ -40,10 +43,11 @@ type dumbSchemeHandler struct { | |||||
| 40 | assetsSet bool | 43 | assetsSet bool | ||
| 41 | assetDir string | 44 | assetDir string | ||
| 42 | logger zerolog.Logger | 45 | logger zerolog.Logger | ||
| 46 | + | hwSurveyor *env.HardwareSurveyor | |||
| 43 | mu sync.RWMutex | 47 | 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 each | 71 | + | // 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.Path | 103 | 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 "", false | 200 | 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 []byte | 467 | data []byte | ||
| 271 | contentType string | 468 | contentType string | ||
| 272 | statusCode int | 469 | statusCode int | ||
| 470 | + | headers map[string]string | |||
| 273 | offset int | 471 | 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 | ||||