commit b7cd3080160bd630d49041d187d78cf78e57b798 Author: Magnus Åhall Date: Mon Nov 10 08:18:54 2025 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5da3471 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +chrome-dev diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b6a9251 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module chrome-dev + +go 1.25.4 + +require ( + github.com/chromedp/chromedp v0.14.2 + github.com/google/uuid v1.6.0 +) + +require ( + github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect + golang.org/x/sys v0.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f41ace3 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= +github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= +github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/main.go b/main.go new file mode 100644 index 0000000..1774e8d --- /dev/null +++ b/main.go @@ -0,0 +1,138 @@ +// Command remote is a chromedp example demonstrating how to connect to an +// existing Chrome DevTools instance using a remote WebSocket URL. +package main + +import ( + // Standard + "embed" + "encoding/json" + "flag" + "html/template" + "io" + "log" + "net/http" + "os" +) + +var ( + flagVerbose bool + flagWsURL string + flagWatch string + + //go:embed static + fs embed.FS +) + +func main() { + flag.BoolVar(&flagVerbose, "v", false, "verbose") + flag.StringVar(&flagWsURL, "ws", "ws://127.0.0.1:9222", "devtools url") + flag.StringVar(&flagWatch, "watch", "", "Files to watch") + flag.Parse() + + http.HandleFunc("/", pageIndex) + http.HandleFunc("/css/main.css", pageCSS) + + http.HandleFunc("/start", actionStart) + http.HandleFunc("/stop/{uuid}", actionStop) + http.HandleFunc("/sites", actionSites) + + log.Println("Listen on [::]:5123") + http.ListenAndServe("[::]:5123", nil) +} + +func pageIndex(w http.ResponseWriter, r *http.Request) { // {{{ + var tmpl *template.Template + var err error + if false { + tmpl, err = template.ParseFS(fs, "static/html/index.html") + } + tmpl, err = template.ParseFiles("static/html/index.html") + if err != nil { + w.Write([]byte(err.Error())) + return + } + + tmpl.Execute(w, sites) +} // }}} +func pageCSS(w http.ResponseWriter, r *http.Request) { // {{{ + w.Header().Add("Content-Type", "text/css") + + //data, err := fs.ReadFile("static/css/main.css") + data, err := os.ReadFile("static/css/main.css") + if err != nil { + w.Write([]byte(err.Error())) + return + } + w.Write(data) +} // }}} +func actionSites(w http.ResponseWriter, r *http.Request) { // {{{ + j, _ := json.Marshal(struct { + OK bool + Sites map[string]*Site + }{ + true, + sites, + }) + w.Write(j) +} // }}} + +func actionStart(w http.ResponseWriter, r *http.Request) { // {{{ + var req struct { + URL string + Watch string + } + + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &req) + if err != nil { + j, _ := json.Marshal(struct { + OK bool + Error string + }{ + false, + err.Error(), + }) + w.Write(j) + return + } + + var site Site + site, err = NewSite(flagWsURL, req.URL, req.Watch) + if err != nil { + log.Println(err) + } + + j, _ := json.Marshal(struct { + OK bool + Site Site + }{ + true, + site, + }) + w.Write(j) +} // }}} +func actionStop(w http.ResponseWriter, r *http.Request) { // {{{ + siteUUID := r.PathValue("uuid") + + site, found := sites[siteUUID] + if !found { + j, _ := json.Marshal(struct { + OK bool + Error string + }{ + false, + "Site not found", + }) + w.Write(j) + return + } + + site.StopLoop = true + + j, _ := json.Marshal(struct { + OK bool + }{ + true, + }) + w.Write(j) +} // }}} diff --git a/site.go b/site.go new file mode 100644 index 0000000..8f785b0 --- /dev/null +++ b/site.go @@ -0,0 +1,117 @@ +package main + +import ( + // External + "github.com/chromedp/cdproto/target" + "github.com/chromedp/chromedp" + "github.com/google/uuid" + + // Standard + "context" + "fmt" + "log" + "os/exec" + "sync" +) + +var ( + sites map[string]*Site + siteLock *sync.Mutex +) + +type Site struct { + UUID string + URL string + Watch string + WatchLoop *exec.Cmd `json:"-"` + StopLoop bool + Context context.Context `json:"-"` +} + +func init() { + siteLock = &sync.Mutex{} + sites = make(map[string]*Site) +} + +func NewSite(wsURL, url, watch string) (s Site, err error) { + s.UUID = uuid.NewString() + s.URL = url + s.Watch = watch + + allocatorContext, _ := chromedp.NewRemoteAllocator(context.Background(), wsURL) + + // Used to optionally enable debugging output. + var opts []chromedp.ContextOption + if flagVerbose { + opts = append(opts, chromedp.WithDebugf(log.Printf)) + } + s.Context, _ = chromedp.NewContext(allocatorContext, opts...) + + // A tab is started up and navigated to in order to get a context for this instance. + if err = chromedp.Run( + s.Context, + chromedp.Navigate(url), + ); err != nil { + err = fmt.Errorf("Failed getting body of %s: %v", url, err) + return + } + + siteLock.Lock() + sites[s.UUID] = &s + defer siteLock.Unlock() + + // chromedp.Run crashes something really hard and undetectable with recover(). + // ListenBrowser gives the possibility to cancel the watch loop. + chromedp.ListenBrowser(s.Context, func(ev any) { + _, event1 := ev.(*target.EventDetachedFromTarget) + _, event2 := ev.(*target.EventTargetCrashed) + _, event3 := ev.(*target.EventTargetDestroyed) + if event1 || event2 || event3 { + log.Printf("Stopping loop for %s\n", s.UUID) + s.StopLoop = true + if s.WatchLoop != nil { + log.Printf("Killing inotifywait for %s\n", s.UUID) + s.WatchLoop.Process.Kill() + } + + } + }) + + // Start watching file/dir for changes. + go s.watchLoop() + + return +} + +func (s *Site) watchLoop() { + for !s.StopLoop { + log.Println("Starting watching " + s.Watch) + s.WatchLoop = exec.Command("inotifywait", "-e", "close_write", s.Watch) + if err := s.WatchLoop.Run(); err != nil { + log.Println(err) + } + s.ReloadCSS() + } + log.Printf("Stopping watch loop [%s]\n", s.UUID) +} + +func (s *Site) ReloadCSS() { + var buf []byte + + cssReloadScript := chromedp.Evaluate(` + (()=>{ + const stylesheets = document.querySelectorAll('link[rel="stylesheet"]') + for (const ss of stylesheets) { + const url = URL.parse(ss.href, location.protocol + '//' + location.host) + const nextReloadCounter = parseInt(url.searchParams.get('reload') || 0) + 1 + url.searchParams.set('reload', nextReloadCounter) + ss.href = url.toString() + } + })() + `, &buf) + + err := chromedp.Run(s.Context, cssReloadScript) + if err != nil { + fmt.Println(err) + } +} diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..23014e8 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,40 @@ +body { + font-family: sans-serif; + margin: 32px; +} + +input { + font-size: 1em; +} + +.new { + width: 400px; +} + +.sites { + display: grid; + grid-template-columns: repeat(3, min-content); + grid-gap: 8px 16px; + margin-top: 32px; + + div { + white-space: nowrap; + + &.dead { + color: #aaa; + } + } + + .header { + font-weight: bold; + } + + .line { + grid-column: 1 / -1; + border-bottom: 1px solid #ccc; + } + + .stop { + cursor: pointer; + } +} diff --git a/static/html/index.html b/static/html/index.html new file mode 100644 index 0000000..3418ceb --- /dev/null +++ b/static/html/index.html @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + +
+
UUID
+
URL
+
+ + {{ range . }} +
+
{{ .URL }}
+
{{ .Watch }}
+
+ {{ end }} +
+ + +