Initial commit

This commit is contained in:
Magnus Åhall 2025-11-10 08:18:54 +01:00
commit b7cd308016
7 changed files with 400 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
chrome-dev

18
go.mod Normal file
View file

@ -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
)

23
go.sum Normal file
View file

@ -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=

138
main.go Normal file
View file

@ -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)
} // }}}

117
site.go Normal file
View file

@ -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)
}
}

40
static/css/main.css Normal file
View file

@ -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;
}
}

63
static/html/index.html Normal file
View file

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
<link rel="stylesheet" type="text/css" href="/css/main.css">
</head>
<script>
function newSite() {
const url = document.querySelector('.new.url').value
const watch = document.querySelector('.new.watch').value
fetch('/start', {
method: 'POST',
body: JSON.stringify({url, watch}),
})
.then(() => location.reload())
}
function stopSite(uuid) {
fetch(`/stop/${uuid}`)
.then(() => location.reload())
}
function siteStatus() {
fetch('/sites')
.then(data => data.json())
.then(json => {
for (const uuid of Object.keys(json.Sites)) {
const site = json.Sites[uuid]
if (site.StopLoop) {
document.querySelector(`.url[data-uuid="${uuid}"]`)?.classList.add('dead')
document.querySelector(`.watch[data-uuid="${uuid}"]`)?.classList.add('dead')
}
}
})
}
setInterval(siteStatus, 2000)
</script>
<body>
<input type="text" class="new url" placeholder="https://example.com" value="https://scan.euterm.n44.se">
<input type="text" class="new watch" placeholder="~/example.com/css/"
value="/home/magnus/repo/euterm/euscan/static/css">
<button onclick="newSite()">Start</button>
<div class="sites">
<div class="header">UUID</div>
<div class="header">URL</div>
<div class="header"></div>
{{ range . }}
<div class="line"></div>
<div class="url {{ if .StopLoop }}dead{{ end }}" data-uuid="{{ .UUID }}">{{ .URL }}</div>
<div class="watch {{ if .StopLoop }}dead{{ end }}" data-uuid="{{ .UUID }}">{{ .Watch }}</div>
<div class="stop" onclick="stopSite('{{ .UUID }}')"></div>
{{ end }}
</div>
</body>
</html>