feat: initial commit
This commit is contained in:
35
pkg/browser/browser.go
Normal file
35
pkg/browser/browser.go
Normal file
@ -0,0 +1,35 @@
|
||||
package browser
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusUnknown Status = iota
|
||||
StatusIdle
|
||||
StatusCasting
|
||||
)
|
||||
|
||||
func (s Status) String() string {
|
||||
switch s {
|
||||
case StatusIdle:
|
||||
return "idle"
|
||||
case StatusCasting:
|
||||
return "casting"
|
||||
default:
|
||||
return fmt.Sprintf("unknown (%d)", s)
|
||||
}
|
||||
}
|
||||
|
||||
type Browser interface {
|
||||
// Cast loads an URL
|
||||
Load(url string) error
|
||||
// Reset resets the browser to the given idle URL
|
||||
Reset(url string) error
|
||||
// Status returns the browser's current status
|
||||
Status() (Status, error)
|
||||
// Title returns the browser's currently loaded page title
|
||||
Title() (string, error)
|
||||
// URL returns the browser's currently loaded page URL
|
||||
URL() (string, error)
|
||||
}
|
52
pkg/browser/dummy/browser.go
Normal file
52
pkg/browser/dummy/browser.go
Normal file
@ -0,0 +1,52 @@
|
||||
package dummy
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/pkg/browser"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Browser struct {
|
||||
status browser.Status
|
||||
url string
|
||||
}
|
||||
|
||||
// Load implements browser.Browser.
|
||||
func (b *Browser) Load(url string) error {
|
||||
logger.Debug(context.Background(), "loading url", logger.F("url", url))
|
||||
b.status = browser.StatusCasting
|
||||
b.url = url
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status implements browser.Browser.
|
||||
func (b *Browser) Status() (browser.Status, error) {
|
||||
return b.status, nil
|
||||
}
|
||||
|
||||
// Title implements browser.Browser.
|
||||
func (b *Browser) Title() (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// URL implements browser.Browser.
|
||||
func (b *Browser) URL() (string, error) {
|
||||
return b.url, nil
|
||||
}
|
||||
|
||||
// Reset implements browser.Browser.
|
||||
func (b *Browser) Reset(url string) error {
|
||||
b.status = browser.StatusIdle
|
||||
b.url = url
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewBrowser() *Browser {
|
||||
return &Browser{
|
||||
status: browser.StatusIdle,
|
||||
url: "",
|
||||
}
|
||||
}
|
||||
|
||||
var _ browser.Browser = &Browser{}
|
118
pkg/browser/gioui/browser.go
Normal file
118
pkg/browser/gioui/browser.go
Normal file
@ -0,0 +1,118 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/pkg/browser"
|
||||
"gioui.org/app"
|
||||
"gioui.org/f32"
|
||||
"gioui.org/layout"
|
||||
"github.com/gioui-plugins/gio-plugins/webviewer"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Browser struct {
|
||||
window *app.Window
|
||||
tag int
|
||||
|
||||
url string
|
||||
changed bool
|
||||
|
||||
status browser.Status
|
||||
title string
|
||||
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func (b *Browser) Layout(gtx layout.Context) {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
|
||||
events := gtx.Events(&b.tag)
|
||||
for _, evt := range events {
|
||||
switch ev := evt.(type) {
|
||||
case webviewer.TitleEvent:
|
||||
b.title = ev.Title
|
||||
case webviewer.NavigationEvent:
|
||||
b.url = ev.URL
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
logger.Debug(ctx, "drawing")
|
||||
|
||||
webviewer.WebViewOp{Tag: &b.tag}.Push(gtx.Ops)
|
||||
|
||||
webviewer.OffsetOp{Point: f32.Point{Y: float32(gtx.Constraints.Max.Y - gtx.Constraints.Max.Y)}}.Add(gtx.Ops)
|
||||
webviewer.RectOp{Size: f32.Point{X: float32(gtx.Constraints.Max.X), Y: float32(gtx.Constraints.Max.Y)}}.Add(gtx.Ops)
|
||||
|
||||
if b.changed {
|
||||
logger.Debug(ctx, "url changed", logger.F("url", b.url))
|
||||
webviewer.NavigateOp{URL: b.url}.Add(gtx.Ops)
|
||||
b.changed = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load implements browser.Browser.
|
||||
func (b *Browser) Load(url string) error {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
|
||||
b.url = url
|
||||
b.changed = true
|
||||
b.status = browser.StatusCasting
|
||||
|
||||
b.window.Invalidate()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status implements browser.Browser.
|
||||
func (b *Browser) Status() (browser.Status, error) {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
|
||||
return b.status, nil
|
||||
}
|
||||
|
||||
// Title implements browser.Browser.
|
||||
func (b *Browser) Title() (string, error) {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
|
||||
return b.title, nil
|
||||
}
|
||||
|
||||
// URL implements browser.Browser.
|
||||
func (b *Browser) URL() (string, error) {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
|
||||
return b.url, nil
|
||||
}
|
||||
|
||||
// Reset implements browser.Browser.
|
||||
func (b *Browser) Reset(url string) error {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
|
||||
b.url = url
|
||||
b.changed = true
|
||||
b.status = browser.StatusIdle
|
||||
|
||||
b.window.Invalidate()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewBrowser(window *app.Window) *Browser {
|
||||
return &Browser{
|
||||
window: window,
|
||||
url: "",
|
||||
changed: true,
|
||||
status: browser.StatusIdle,
|
||||
}
|
||||
}
|
||||
|
||||
var _ browser.Browser = &Browser{}
|
100
pkg/browser/lorca/browser.go
Normal file
100
pkg/browser/lorca/browser.go
Normal file
@ -0,0 +1,100 @@
|
||||
package lorca
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/pkg/browser"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/zserge/lorca"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Browser struct {
|
||||
ui lorca.UI
|
||||
status browser.Status
|
||||
opts *Options
|
||||
}
|
||||
|
||||
func (b *Browser) Start() error {
|
||||
logger.Debug(context.Background(), "starting browser", logger.F("opts", b.opts))
|
||||
|
||||
ui, err := lorca.New("", "", b.opts.Width, b.opts.Height, b.opts.ChromeArgs...)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
b.ui = ui
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Browser) Stop() error {
|
||||
if err := b.ui.Close(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
b.ui = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Browser) Wait() {
|
||||
<-b.ui.Done()
|
||||
}
|
||||
|
||||
// Load implements browser.Browser.
|
||||
func (b *Browser) Load(url string) error {
|
||||
if err := b.ui.Load(url); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
b.status = browser.StatusCasting
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unload implements browser.Browser.
|
||||
func (b *Browser) Reset(url string) error {
|
||||
if err := b.ui.Load(url); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
b.status = browser.StatusIdle
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status implements browser.Browser.
|
||||
func (b *Browser) Status() (browser.Status, error) {
|
||||
return b.status, nil
|
||||
}
|
||||
|
||||
// Title implements browser.Browser.
|
||||
func (b *Browser) Title() (string, error) {
|
||||
result := b.ui.Eval("document.title.toString()")
|
||||
if err := result.Err(); err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
// URL implements browser.Browser.
|
||||
func (b *Browser) URL() (string, error) {
|
||||
result := b.ui.Eval("window.location.toString()")
|
||||
if err := result.Err(); err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
func NewBrowser(funcs ...OptionsFunc) *Browser {
|
||||
opts := NewOptions(funcs...)
|
||||
return &Browser{
|
||||
status: browser.StatusIdle,
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
|
||||
var _ browser.Browser = &Browser{}
|
40
pkg/browser/lorca/options.go
Normal file
40
pkg/browser/lorca/options.go
Normal file
@ -0,0 +1,40 @@
|
||||
package lorca
|
||||
|
||||
type Options struct {
|
||||
Width int
|
||||
Height int
|
||||
ChromeArgs []string
|
||||
}
|
||||
|
||||
type OptionsFunc func(opts *Options)
|
||||
|
||||
var DefaultChromeArgs = []string{
|
||||
"--remote-allow-origins=*",
|
||||
}
|
||||
|
||||
func NewOptions(funcs ...OptionsFunc) *Options {
|
||||
opts := &Options{
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
ChromeArgs: DefaultChromeArgs,
|
||||
}
|
||||
|
||||
for _, fn := range funcs {
|
||||
fn(opts)
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func WithWindowSize(width, height int) OptionsFunc {
|
||||
return func(opts *Options) {
|
||||
opts.Width = width
|
||||
opts.Height = height
|
||||
}
|
||||
}
|
||||
|
||||
func WithAdditionalChromeArgs(args ...string) OptionsFunc {
|
||||
return func(opts *Options) {
|
||||
opts.ChromeArgs = append(args, DefaultChromeArgs...)
|
||||
}
|
||||
}
|
81
pkg/client/api.go
Normal file
81
pkg/client/api.go
Normal file
@ -0,0 +1,81 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func (c *Client) apiGet(ctx context.Context, url string, result any, funcs ...HTTPOptionFunc) error {
|
||||
if err := c.apiDo(ctx, http.MethodGet, url, nil, result, funcs...); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) apiPost(ctx context.Context, url string, payload any, result any, funcs ...HTTPOptionFunc) error {
|
||||
if err := c.apiDo(ctx, http.MethodPost, url, payload, result, funcs...); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) apiDelete(ctx context.Context, url string, payload any, result any, funcs ...HTTPOptionFunc) error {
|
||||
if err := c.apiDo(ctx, http.MethodDelete, url, payload, result, funcs...); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) apiDo(ctx context.Context, method string, url string, payload any, response any, funcs ...HTTPOptionFunc) error {
|
||||
opts := NewHTTPOptions(funcs...)
|
||||
|
||||
logger.Debug(
|
||||
ctx, "new http request",
|
||||
logger.F("method", method),
|
||||
logger.F("url", url),
|
||||
logger.F("payload", payload),
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
encoder := json.NewEncoder(&buf)
|
||||
|
||||
if err := encoder.Encode(payload); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, &buf)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
for key, values := range opts.Headers {
|
||||
for _, v := range values {
|
||||
req.Header.Add(key, v)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
decoder := json.NewDecoder(res.Body)
|
||||
|
||||
if err := decoder.Decode(&api.Response{Data: &response}); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
27
pkg/client/cast.go
Normal file
27
pkg/client/cast.go
Normal file
@ -0,0 +1,27 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/pkg/server"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Status = server.StatusResponse
|
||||
|
||||
func (c *Client) Cast(ctx context.Context, addr string, url string) (*Status, error) {
|
||||
endpoint := fmt.Sprintf("http://%s/api/v1/cast", addr)
|
||||
|
||||
req := &server.CastRequest{
|
||||
URL: url,
|
||||
}
|
||||
|
||||
res := &server.StatusResponse{}
|
||||
|
||||
if err := c.apiPost(ctx, endpoint, &req, &res); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
15
pkg/client/client.go
Normal file
15
pkg/client/client.go
Normal file
@ -0,0 +1,15 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{
|
||||
http: &http.Client{},
|
||||
}
|
||||
}
|
43
pkg/client/options.go
Normal file
43
pkg/client/options.go
Normal file
@ -0,0 +1,43 @@
|
||||
package client
|
||||
|
||||
import "net/http"
|
||||
|
||||
type HTTPOptions struct {
|
||||
Headers http.Header
|
||||
}
|
||||
|
||||
type HTTPOptionFunc func(opts *HTTPOptions)
|
||||
|
||||
func NewHTTPOptions(funcs ...HTTPOptionFunc) *HTTPOptions {
|
||||
opts := &HTTPOptions{}
|
||||
|
||||
for _, fn := range funcs {
|
||||
fn(opts)
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
type ScanOptions struct {
|
||||
PlayerIDs []string
|
||||
}
|
||||
|
||||
func NewScanOptions(funcs ...ScanOptionFunc) *ScanOptions {
|
||||
opts := &ScanOptions{
|
||||
PlayerIDs: make([]string, 0),
|
||||
}
|
||||
|
||||
for _, fn := range funcs {
|
||||
fn(opts)
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
type ScanOptionFunc func(opts *ScanOptions)
|
||||
|
||||
func WithPlayerIDs(ids ...string) ScanOptionFunc {
|
||||
return func(opts *ScanOptions) {
|
||||
opts.PlayerIDs = ids
|
||||
}
|
||||
}
|
11
pkg/client/player.go
Normal file
11
pkg/client/player.go
Normal file
@ -0,0 +1,11 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
type Player struct {
|
||||
ID string
|
||||
IPs []net.IP
|
||||
Port int
|
||||
}
|
21
pkg/client/reset.go
Normal file
21
pkg/client/reset.go
Normal file
@ -0,0 +1,21 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/pkg/server"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (c *Client) Reset(ctx context.Context, addr string) (*Status, error) {
|
||||
endpoint := fmt.Sprintf("http://%s/api/v1/cast", addr)
|
||||
|
||||
res := &server.StatusResponse{}
|
||||
|
||||
if err := c.apiDelete(ctx, endpoint, nil, &res); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
69
pkg/client/scan.go
Normal file
69
pkg/client/scan.go
Normal file
@ -0,0 +1,69 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/pkg/server"
|
||||
"github.com/grandcat/zeroconf"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (c *Client) Scan(ctx context.Context, funcs ...ScanOptionFunc) ([]*Player, error) {
|
||||
opts := NewScanOptions(funcs...)
|
||||
|
||||
resolver, err := zeroconf.NewResolver(nil)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
players := make([]*Player, 0)
|
||||
|
||||
entries := make(chan *zeroconf.ServiceEntry)
|
||||
go func(results <-chan *zeroconf.ServiceEntry) {
|
||||
defer close(done)
|
||||
|
||||
for entry := range results {
|
||||
addPlayer := func() {
|
||||
players = append(players, &Player{
|
||||
ID: entry.Instance,
|
||||
IPs: entry.AddrIPv4,
|
||||
Port: entry.Port,
|
||||
})
|
||||
}
|
||||
|
||||
searchedLen := len(opts.PlayerIDs)
|
||||
if searchedLen == 0 {
|
||||
addPlayer()
|
||||
continue
|
||||
}
|
||||
|
||||
if slices.Contains(opts.PlayerIDs, entry.Instance) {
|
||||
addPlayer()
|
||||
}
|
||||
|
||||
if searchedLen == len(players) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
done <- struct{}{}
|
||||
}(entries)
|
||||
|
||||
err = resolver.Browse(ctx, server.MDNSService, server.MDNSDomain, entries)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return players, nil
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); err != nil {
|
||||
return players, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return players, nil
|
||||
}
|
||||
}
|
21
pkg/client/status.go
Normal file
21
pkg/client/status.go
Normal file
@ -0,0 +1,21 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/pkg/server"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (c *Client) Status(ctx context.Context, addr string) (*Status, error) {
|
||||
endpoint := fmt.Sprintf("http://%s/api/v1/status", addr)
|
||||
|
||||
res := &server.StatusResponse{}
|
||||
|
||||
if err := c.apiGet(ctx, endpoint, res); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
191
pkg/server/http.go
Normal file
191
pkg/server/http.go
Normal file
@ -0,0 +1,191 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed templates/idle.html.gotmpl
|
||||
rawIdleTemplate []byte
|
||||
idleTemplate *template.Template
|
||||
)
|
||||
|
||||
func init() {
|
||||
tmpl, err := template.New("").Parse(string(rawIdleTemplate))
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "could not parse idle template"))
|
||||
}
|
||||
|
||||
idleTemplate = tmpl
|
||||
}
|
||||
|
||||
func (s *Server) startHTTPServer(ctx context.Context) error {
|
||||
router := chi.NewRouter()
|
||||
|
||||
router.Get("/", s.handleHome)
|
||||
router.Post("/api/v1/cast", s.handleCast)
|
||||
router.Delete("/api/v1/cast", s.handleReset)
|
||||
router.Get("/api/v1/status", s.handleStatus)
|
||||
|
||||
server := http.Server{
|
||||
Addr: s.address,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", s.address)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
host, rawPort, err := net.SplitHostPort(listener.Addr().String())
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
port, err := strconv.ParseInt(rawPort, 10, 32)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not parse listening port '%v'", rawPort)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "listening for tcp connections", logger.F("port", port), logger.F("host", host))
|
||||
|
||||
s.port = int(port)
|
||||
|
||||
go func() {
|
||||
logger.Debug(ctx, "starting http server")
|
||||
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error(ctx, "could not listen", logger.CapturedE(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
logger.Debug(ctx, "closing http server")
|
||||
if err := server.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close http server", logger.CapturedE(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := s.resetBrowser(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type CastRequest struct {
|
||||
URL string `json:"url" validate:"required"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCast(w http.ResponseWriter, r *http.Request) {
|
||||
req := &CastRequest{}
|
||||
if ok := api.Bind(w, r, req); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.browser.Load(req.URL); err != nil {
|
||||
logger.Error(r.Context(), "could not load url", logger.CapturedE(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.resetBrowser(); err != nil {
|
||||
logger.Error(r.Context(), "could not unload url", logger.CapturedE(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) resetBrowser() error {
|
||||
idleURL := fmt.Sprintf("http://localhost:%d", s.port)
|
||||
if err := s.browser.Reset(idleURL); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
url, err := s.browser.URL()
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not retrieve browser url", logger.CapturedE(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
status, err := s.browser.Status()
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not retrieve browser status", logger.CapturedE(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
title, err := s.browser.Title()
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not retrieve browser page title", logger.CapturedE(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, &StatusResponse{
|
||||
ID: s.instanceID,
|
||||
URL: url,
|
||||
Status: status.String(),
|
||||
Title: title,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
type templateData struct {
|
||||
IPs []string
|
||||
Port int
|
||||
ID string
|
||||
}
|
||||
|
||||
ips, err := getLANIPv4Addrs()
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not retrieve lan ip addresses", logger.CapturedE(errors.WithStack(err)))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
d := templateData{
|
||||
ID: s.instanceID,
|
||||
IPs: ips,
|
||||
Port: s.port,
|
||||
}
|
||||
|
||||
if err := idleTemplate.Execute(w, d); err != nil {
|
||||
logger.Error(r.Context(), "could not render idle page", logger.CapturedE(errors.WithStack(err)))
|
||||
}
|
||||
}
|
43
pkg/server/mdns.go
Normal file
43
pkg/server/mdns.go
Normal file
@ -0,0 +1,43 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grandcat/zeroconf"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/wlynxg/anet"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
MDNSService = "_arcast._http._tcp"
|
||||
MDNSDomain = "local."
|
||||
)
|
||||
|
||||
func (s *Server) startMDNServer(ctx context.Context) error {
|
||||
logger.Debug(ctx, "starting mdns server")
|
||||
ifaces, err := anet.Interfaces()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
ips, err := getLANIPv4Addrs()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "advertising ips", logger.F("ips", ips))
|
||||
|
||||
server, err := zeroconf.RegisterProxy(s.instanceID, MDNSService, MDNSDomain, s.port, s.instanceID, ips, []string{}, ifaces)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
logger.Debug(ctx, "closing mdns server")
|
||||
server.Shutdown()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
60
pkg/server/network.go
Normal file
60
pkg/server/network.go
Normal file
@ -0,0 +1,60 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/wlynxg/anet"
|
||||
)
|
||||
|
||||
var (
|
||||
_, lanA, _ = net.ParseCIDR("10.0.0.0/8")
|
||||
_, lanB, _ = net.ParseCIDR("172.16.0.0/12")
|
||||
_, lanC, _ = net.ParseCIDR("192.168.0.0/16")
|
||||
)
|
||||
|
||||
func getLANIPv4Addrs() ([]string, error) {
|
||||
ips := make([]string, 0)
|
||||
|
||||
addrs, err := anet.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
for _, address := range addrs {
|
||||
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
ipv4 := ipnet.IP.To4()
|
||||
if ipv4 == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
isLAN := lanA.Contains(ipv4) || lanB.Contains(ipv4) || lanC.Contains(ipv4)
|
||||
if !isLAN {
|
||||
continue
|
||||
}
|
||||
|
||||
ips = append(ips, ipv4.String())
|
||||
}
|
||||
}
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func FindPreferredLocalAddress(ips ...net.IP) (string, error) {
|
||||
localAddrs, err := anet.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
for _, addr := range localAddrs {
|
||||
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
for _, ip := range ips {
|
||||
if ipnet.Contains(ip) {
|
||||
return ip.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ips[0].String(), nil
|
||||
}
|
61
pkg/server/options.go
Normal file
61
pkg/server/options.go
Normal file
@ -0,0 +1,61 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/jaevor/go-nanoid"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var newRandomInstanceID func() string
|
||||
|
||||
func init() {
|
||||
generator, err := nanoid.Standard(21)
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "could not generate random instance id"))
|
||||
}
|
||||
|
||||
newRandomInstanceID = generator
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
InstanceID string
|
||||
Address string
|
||||
DisableServiceDiscovery bool
|
||||
}
|
||||
|
||||
type OptionFunc func(opts *Options)
|
||||
|
||||
func NewOptions(funcs ...OptionFunc) *Options {
|
||||
opts := &Options{
|
||||
InstanceID: NewRandomInstanceID(),
|
||||
Address: ":",
|
||||
DisableServiceDiscovery: false,
|
||||
}
|
||||
|
||||
for _, fn := range funcs {
|
||||
fn(opts)
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func WithAddress(addr string) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.Address = addr
|
||||
}
|
||||
}
|
||||
|
||||
func WithInstanceID(id string) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.InstanceID = id
|
||||
}
|
||||
}
|
||||
|
||||
func WithServiceDiscoveryDisabled(disabled bool) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.DisableServiceDiscovery = disabled
|
||||
}
|
||||
}
|
||||
|
||||
func NewRandomInstanceID() string {
|
||||
return newRandomInstanceID()
|
||||
}
|
76
pkg/server/server.go
Normal file
76
pkg/server/server.go
Normal file
@ -0,0 +1,76 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/pkg/browser"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
browser browser.Browser
|
||||
|
||||
instanceID string
|
||||
address string
|
||||
port int
|
||||
disableServiceDiscovery bool
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
serverCtx, cancelServer := context.WithCancel(context.Background())
|
||||
|
||||
s.cancel = cancelServer
|
||||
s.ctx = serverCtx
|
||||
|
||||
httpServerCtx, cancelHTTPServer := context.WithCancel(serverCtx)
|
||||
if err := s.startHTTPServer(httpServerCtx); err != nil {
|
||||
cancelHTTPServer()
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if !s.disableServiceDiscovery {
|
||||
mdnsServerCtx, cancelMDNSServer := context.WithCancel(serverCtx)
|
||||
if err := s.startMDNServer(mdnsServerCtx); err != nil {
|
||||
cancelHTTPServer()
|
||||
cancelMDNSServer()
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
} else {
|
||||
logger.Info(serverCtx, "service discovery disabled")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Stop() error {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Wait() error {
|
||||
<-s.ctx.Done()
|
||||
|
||||
if err := s.ctx.Err(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func New(browser browser.Browser, funcs ...OptionFunc) *Server {
|
||||
opts := NewOptions(funcs...)
|
||||
|
||||
return &Server{
|
||||
browser: browser,
|
||||
instanceID: opts.InstanceID,
|
||||
address: opts.Address,
|
||||
disableServiceDiscovery: opts.DisableServiceDiscovery,
|
||||
}
|
||||
}
|
101
pkg/server/templates/idle.html.gotmpl
Normal file
101
pkg/server/templates/idle.html.gotmpl
Normal file
@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Arcast - Idle</title>
|
||||
<style>
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #e1e1e1;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
body,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
ol,
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: block;
|
||||
background-color: #fff;
|
||||
border-radius: 5px;
|
||||
padding: 10px 20px;
|
||||
box-shadow: 2px 2px #3333331d;
|
||||
}
|
||||
|
||||
.panel p, .panel ul {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.text-centered {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-small {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<h1>Arcast</h1>
|
||||
<p>Instance ID:</p>
|
||||
<p class="text-centered text-small">
|
||||
<code>{{ .ID }}</code>
|
||||
</p>
|
||||
<p>Addresses:</p>
|
||||
<ul class="text-italic text-small">
|
||||
{{ $port := .Port }}
|
||||
{{range .IPs}}
|
||||
<li><code>{{ . }}:{{ $port }}</code></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
Reference in New Issue
Block a user