yt/twitch fixes 2026
This commit is contained in:
@@ -48,7 +48,7 @@ This is the main StreamGoose component. It provides three main functions:
|
||||
- providing the unified message queue for the frontend side consumption;
|
||||
- serving static files that make up the frontend (`index.html`, `script.js`, `style.css` and images) over the plain HTTP protocol.
|
||||
|
||||
Additionally, the server binary also launches the backends it knows about and opens a simple GUI window to show the Web content locally, feeding its unified output into the JS side via server-sent events. The latter part became possible thanks to the [webview_go](https://pkg.go.dev/centrifuge.hectabit.org/HectaBit/webview_go) library.
|
||||
Additionally, the server binary also launches the backends it knows about and opens a simple GUI window to show the Web content locally, feeding its unified output into the JS side via server-sent events. The latter part became possible thanks to the [webview_go](https://github.com/webview/webview_go) library.
|
||||
|
||||
### Backends
|
||||
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
name: CI Pipeline
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.image }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Go 1.23 was the latest version as of 2024-08-31 but the action output a "warning":
|
||||
# Failed to restore: "/usr/bin/tar" failed with error: The process '/usr/bin/tar' failed with exit code 2
|
||||
# Go 1.22 didn't output the above "warning" so we're using this version.
|
||||
# Go 1.14 made -mod=vendor default.
|
||||
# Go 1.13 is the version set in go.mod.
|
||||
include:
|
||||
- { image: macos-14, go: '1.22' }
|
||||
- { image: macos-14, go: '1.21' }
|
||||
- { image: macos-14, go: '1.20' }
|
||||
# Go 1.19 is the earliest version available for ARM64.
|
||||
- { image: macos-14, go: '1.19' }
|
||||
# macos-12 runner has x86_64 support which is needed for old versions of Go
|
||||
- { image: macos-12, go: '1.18' }
|
||||
- { image: macos-12, go: '1.17' }
|
||||
- { image: macos-12, go: '1.16' }
|
||||
- { image: macos-12, go: '1.15' }
|
||||
- { image: macos-12, go: '1.14' }
|
||||
- { image: macos-12, go: '1.13' }
|
||||
- { image: ubuntu-22.04, go: '1.22', apt: libgtk-4-dev libwebkitgtk-6.0-dev }
|
||||
- { image: ubuntu-22.04, go: '1.22', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
|
||||
- { image: ubuntu-22.04, go: '1.22', apt: libgtk-3-dev libwebkit2gtk-4.0-dev }
|
||||
- { image: ubuntu-22.04, go: '1.21', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
|
||||
- { image: ubuntu-22.04, go: '1.20', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
|
||||
- { image: ubuntu-22.04, go: '1.19', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
|
||||
- { image: ubuntu-22.04, go: '1.18', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
|
||||
- { image: ubuntu-22.04, go: '1.17', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
|
||||
- { image: ubuntu-22.04, go: '1.16', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
|
||||
- { image: ubuntu-22.04, go: '1.15', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
|
||||
- { image: ubuntu-22.04, go: '1.14', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
|
||||
- { image: ubuntu-22.04, go: '1.13', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
|
||||
- { image: windows-2022, go: '1.22' }
|
||||
- { image: windows-2022, go: '1.21' }
|
||||
# Go 1.20 is the earliest version that is usable in this environment as of 2024-08-31.
|
||||
# Compilation fails with older versions:
|
||||
# [...]/bin/ld.exe: [...]\go-link-3209855222\000008.o: in function `x_cgo_thread_start':
|
||||
# \\_\_\runtime\cgo/gcc_util.c:18: undefined reference to `__imp___iob_func'
|
||||
- { image: windows-2022, go: '1.20' }
|
||||
name: Build (${{ matrix.image }}, go ${{ matrix.go }})
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
- name: Build examples
|
||||
run: >
|
||||
go build
|
||||
./examples/basic
|
||||
./examples/bind
|
||||
- name: Run tests
|
||||
run: go test
|
||||
@@ -0,0 +1,15 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
### Migrating from v0.1.1 to v0.10.0
|
||||
|
||||
1. `Webview.Open()` has been removed. Use other webview APIs to create a window, open a link and run main UI loop.
|
||||
2. `Webview.Debug()` and `webview.Debugf()` have been removed. Use your favorite logging library to debug webview apps.
|
||||
3. `Webview.Settings` struct has been removed. Title, URL and size are controlled via other API setters and can be updated at any time, not only when webview is created.
|
||||
4. `Webview.Loop()` has been removed. Use `Run()` instead.
|
||||
5. `WebView.Run()`, `WebView.Terminate()`, `WebView.SetTitle()`, `WebView.Dispatch()` stayed the same.
|
||||
6. `WebView.Exit()` has been renamed to `WebView.Destroy()`
|
||||
7. `WebView.SetColor()` and `WebView.SetFullScreen()` have been removed. Use `Window()` to get native window handle and probably write some Cgo code to adjust native window to your taste.
|
||||
8. `Webview.Dialog` has been removed. But it is likely to be brought back as a standalone module.
|
||||
9. `WebView.Eval()` remained the same.
|
||||
10. `WebView.InjectCSS()` has been removed. Use eval to inject style tag with CSS inside.
|
||||
11. `WebView.Bind()` kept the name, but changed the semantics. Only functions can be bound. Not the structs, like in Lorca.
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 Serge Zaitsev
|
||||
Copyright (c) 2020 webview
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
# webview_go
|
||||
|
||||
[](https://godoc.org/github.com/webview/webview_go)
|
||||
[](https://goreportcard.com/report/github.com/webview/webview_go)
|
||||
|
||||
Go language binding for the [webview library][webview].
|
||||
|
||||
> [!NOTE]
|
||||
> Versions <= 0.1.1 are available in the [old repository][webview].
|
||||
|
||||
### Getting Started
|
||||
|
||||
See [Go package documentation][go-docs] for the Go API documentation, or simply read the source code.
|
||||
|
||||
Start with creating a new directory structure for your project.
|
||||
|
||||
```sh
|
||||
mkdir my-project && cd my-project
|
||||
```
|
||||
|
||||
Create a new Go module.
|
||||
|
||||
```sh
|
||||
go mod init example.com/app
|
||||
```
|
||||
|
||||
Save one of the example programs into your project directory.
|
||||
|
||||
```sh
|
||||
curl -sSLo main.go "https://raw.githubusercontent.com/webview/webview_go/master/examples/basic/main.go"
|
||||
```
|
||||
|
||||
Install dependencies.
|
||||
|
||||
```sh
|
||||
go get github.com/webview/webview_go
|
||||
```
|
||||
|
||||
Build the example. On Windows, add `-ldflags="-H windowsgui"` to the command line.
|
||||
|
||||
```sh
|
||||
go build
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
Calling `Eval()` or `Dispatch()` before `Run()` does not work because the webview instance has only been configured and not yet started.
|
||||
|
||||
[go-docs]: https://pkg.go.dev/github.com/webview/webview_go
|
||||
[webview]: https://github.com/webview/webview
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
import webview "github.com/webview/webview_go"
|
||||
|
||||
func main() {
|
||||
w := webview.New(false)
|
||||
defer w.Destroy()
|
||||
w.SetTitle("Basic Example")
|
||||
w.SetSize(480, 320, webview.HintNone)
|
||||
w.SetHtml("Thanks for using webview!")
|
||||
w.Run()
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import webview "github.com/webview/webview_go"
|
||||
|
||||
const html = `<button id="increment">Tap me</button>
|
||||
<div>You tapped <span id="count">0</span> time(s).</div>
|
||||
<script>
|
||||
const [incrementElement, countElement] =
|
||||
document.querySelectorAll("#increment, #count");
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
incrementElement.addEventListener("click", () => {
|
||||
window.increment().then(result => {
|
||||
countElement.textContent = result.count;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>`
|
||||
|
||||
type IncrementResult struct {
|
||||
Count uint `json:"count"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var count uint = 0
|
||||
w := webview.New(false)
|
||||
defer w.Destroy()
|
||||
w.SetTitle("Bind Example")
|
||||
w.SetSize(480, 320, webview.HintNone)
|
||||
|
||||
// A binding that increments a value and immediately returns the new value.
|
||||
w.Bind("increment", func() IncrementResult {
|
||||
count++
|
||||
return IncrementResult{Count: count}
|
||||
})
|
||||
|
||||
w.SetHtml(html)
|
||||
w.Run()
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
#include "webview.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
|
||||
struct binding_context {
|
||||
webview_t w;
|
||||
uintptr_t index;
|
||||
};
|
||||
|
||||
void _webviewDispatchGoCallback(void *);
|
||||
void _webviewBindingGoCallback(webview_t, char *, char *, uintptr_t);
|
||||
|
||||
static void _webview_dispatch_cb(webview_t w, void *arg) {
|
||||
_webviewDispatchGoCallback(arg);
|
||||
}
|
||||
|
||||
static void _webview_binding_cb(const char *id, const char *req, void *arg) {
|
||||
struct binding_context *ctx = (struct binding_context *) arg;
|
||||
_webviewBindingGoCallback(ctx->w, (char *)id, (char *)req, ctx->index);
|
||||
}
|
||||
|
||||
void CgoWebViewDispatch(webview_t w, uintptr_t arg) {
|
||||
webview_dispatch(w, _webview_dispatch_cb, (void *)arg);
|
||||
}
|
||||
|
||||
void CgoWebViewBind(webview_t w, const char *name, uintptr_t index) {
|
||||
struct binding_context *ctx = calloc(1, sizeof(struct binding_context));
|
||||
ctx->w = w;
|
||||
ctx->index = index;
|
||||
webview_bind(w, name, _webview_binding_cb, (void *)ctx);
|
||||
}
|
||||
|
||||
void CgoWebViewUnbind(webview_t w, const char *name) {
|
||||
webview_unbind(w, name);
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
module github.com/webview/webview_go
|
||||
|
||||
go 1.13
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
Copyright (C) Microsoft Corporation. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* The name of Microsoft Corporation, or the names of its contributors
|
||||
may not be used to endorse or promote products derived from this
|
||||
software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
+23568
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
// Dummy file to allow vendoring.
|
||||
package vendor
|
||||
@@ -0,0 +1,2 @@
|
||||
// Dummy file to allow vendoring.
|
||||
package vendor
|
||||
@@ -0,0 +1 @@
|
||||
1.0.1150.38
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 Serge Zaitsev
|
||||
Copyright (c) 2022 Steffen André Langnes
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,2 @@
|
||||
// Dummy file to allow vendoring.
|
||||
package vendor
|
||||
+3598
File diff suppressed because it is too large
Load Diff
+2
@@ -0,0 +1,2 @@
|
||||
// Dummy file to allow vendoring.
|
||||
package vendor
|
||||
@@ -0,0 +1 @@
|
||||
fb6b17d826041411e6346cd9a785a5ceba7987c4
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
#include "webview.h"
|
||||
Vendored
+331
@@ -0,0 +1,331 @@
|
||||
package webview
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -I${SRCDIR}/libs/webview/include
|
||||
#cgo CXXFLAGS: -I${SRCDIR}/libs/webview/include -DWEBVIEW_STATIC
|
||||
|
||||
#cgo linux openbsd freebsd netbsd CXXFLAGS: -DWEBVIEW_GTK -std=c++11
|
||||
#cgo linux openbsd freebsd netbsd LDFLAGS: -ldl
|
||||
#cgo linux openbsd freebsd netbsd pkg-config: gtk+-3.0 webkit2gtk-4.0
|
||||
|
||||
#cgo darwin CXXFLAGS: -DWEBVIEW_COCOA -std=c++11
|
||||
#cgo darwin LDFLAGS: -framework WebKit -ldl
|
||||
|
||||
#cgo windows CXXFLAGS: -DWEBVIEW_EDGE -std=c++14 -I${SRCDIR}/libs/mswebview2/include
|
||||
#cgo windows LDFLAGS: -static -ladvapi32 -lole32 -lshell32 -lshlwapi -luser32 -lversion
|
||||
|
||||
#include "webview.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
|
||||
void CgoWebViewDispatch(webview_t w, uintptr_t arg);
|
||||
void CgoWebViewBind(webview_t w, const char *name, uintptr_t index);
|
||||
void CgoWebViewUnbind(webview_t w, const char *name);
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
_ "github.com/webview/webview_go/libs/mswebview2"
|
||||
_ "github.com/webview/webview_go/libs/mswebview2/include"
|
||||
_ "github.com/webview/webview_go/libs/webview"
|
||||
_ "github.com/webview/webview_go/libs/webview/include"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sync"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Ensure that main.main is called from the main thread
|
||||
runtime.LockOSThread()
|
||||
}
|
||||
|
||||
// Hints are used to configure window sizing and resizing
|
||||
type Hint int
|
||||
|
||||
const (
|
||||
// Width and height are default size
|
||||
HintNone = C.WEBVIEW_HINT_NONE
|
||||
|
||||
// Window size can not be changed by a user
|
||||
HintFixed = C.WEBVIEW_HINT_FIXED
|
||||
|
||||
// Width and height are minimum bounds
|
||||
HintMin = C.WEBVIEW_HINT_MIN
|
||||
|
||||
// Width and height are maximum bounds
|
||||
HintMax = C.WEBVIEW_HINT_MAX
|
||||
)
|
||||
|
||||
type WebView interface {
|
||||
|
||||
// Run runs the main loop until it's terminated. After this function exits -
|
||||
// you must destroy the webview.
|
||||
Run()
|
||||
|
||||
// Terminate stops the main loop. It is safe to call this function from
|
||||
// a background thread.
|
||||
Terminate()
|
||||
|
||||
// Dispatch posts a function to be executed on the main thread. You normally
|
||||
// do not need to call this function, unless you want to tweak the native
|
||||
// window.
|
||||
Dispatch(f func())
|
||||
|
||||
// Destroy destroys a webview and closes the native window.
|
||||
Destroy()
|
||||
|
||||
// Window returns a native window handle pointer. When using GTK backend the
|
||||
// pointer is GtkWindow pointer, when using Cocoa backend the pointer is
|
||||
// NSWindow pointer, when using Win32 backend the pointer is HWND pointer.
|
||||
Window() unsafe.Pointer
|
||||
|
||||
// SetTitle updates the title of the native window. Must be called from the UI
|
||||
// thread.
|
||||
SetTitle(title string)
|
||||
|
||||
// SetSize updates native window size. See Hint constants.
|
||||
SetSize(w int, h int, hint Hint)
|
||||
|
||||
// Navigate navigates webview to the given URL. URL may be a properly encoded data.
|
||||
// URI. Examples:
|
||||
// w.Navigate("https://github.com/webview/webview")
|
||||
// w.Navigate("data:text/html,%3Ch1%3EHello%3C%2Fh1%3E")
|
||||
// w.Navigate("data:text/html;base64,PGgxPkhlbGxvPC9oMT4=")
|
||||
Navigate(url string)
|
||||
|
||||
// SetHtml sets the webview HTML directly.
|
||||
// Example: w.SetHtml(w, "<h1>Hello</h1>");
|
||||
SetHtml(html string)
|
||||
|
||||
// Init injects JavaScript code at the initialization of the new page. Every
|
||||
// time the webview will open a the new page - this initialization code will
|
||||
// be executed. It is guaranteed that code is executed before window.onload.
|
||||
Init(js string)
|
||||
|
||||
// Eval evaluates arbitrary JavaScript code. Evaluation happens asynchronously,
|
||||
// also the result of the expression is ignored. Use RPC bindings if you want
|
||||
// to receive notifications about the results of the evaluation.
|
||||
Eval(js string)
|
||||
|
||||
// Bind binds a callback function so that it will appear under the given name
|
||||
// as a global JavaScript function. Internally it uses webview_init().
|
||||
// Callback receives a request string and a user-provided argument pointer.
|
||||
// Request string is a JSON array of all the arguments passed to the
|
||||
// JavaScript function.
|
||||
//
|
||||
// f must be a function
|
||||
// f must return either value and error or just error
|
||||
Bind(name string, f interface{}) error
|
||||
|
||||
// Removes a callback that was previously set by Bind.
|
||||
Unbind(name string) error
|
||||
}
|
||||
|
||||
type webview struct {
|
||||
w C.webview_t
|
||||
}
|
||||
|
||||
var (
|
||||
m sync.Mutex
|
||||
index uintptr
|
||||
dispatch = map[uintptr]func(){}
|
||||
bindings = map[uintptr]func(id, req string) (interface{}, error){}
|
||||
)
|
||||
|
||||
func boolToInt(b bool) C.int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// New calls NewWindow to create a new window and a new webview instance. If debug
|
||||
// is non-zero - developer tools will be enabled (if the platform supports them).
|
||||
func New(debug bool) WebView { return NewWindow(debug, nil) }
|
||||
|
||||
// NewWindow creates a new webview instance. If debug is non-zero - developer
|
||||
// tools will be enabled (if the platform supports them). Window parameter can be
|
||||
// a pointer to the native window handle. If it's non-null - then child WebView is
|
||||
// embedded into the given parent window. Otherwise a new window is created.
|
||||
// Depending on the platform, a GtkWindow, NSWindow or HWND pointer can be passed
|
||||
// here.
|
||||
func NewWindow(debug bool, window unsafe.Pointer) WebView {
|
||||
w := &webview{}
|
||||
w.w = C.webview_create(boolToInt(debug), window)
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *webview) Destroy() {
|
||||
C.webview_destroy(w.w)
|
||||
}
|
||||
|
||||
func (w *webview) Run() {
|
||||
C.webview_run(w.w)
|
||||
}
|
||||
|
||||
func (w *webview) Terminate() {
|
||||
C.webview_terminate(w.w)
|
||||
}
|
||||
|
||||
func (w *webview) Window() unsafe.Pointer {
|
||||
return C.webview_get_window(w.w)
|
||||
}
|
||||
|
||||
func (w *webview) Navigate(url string) {
|
||||
s := C.CString(url)
|
||||
defer C.free(unsafe.Pointer(s))
|
||||
C.webview_navigate(w.w, s)
|
||||
}
|
||||
|
||||
func (w *webview) SetHtml(html string) {
|
||||
s := C.CString(html)
|
||||
defer C.free(unsafe.Pointer(s))
|
||||
C.webview_set_html(w.w, s)
|
||||
}
|
||||
|
||||
func (w *webview) SetTitle(title string) {
|
||||
s := C.CString(title)
|
||||
defer C.free(unsafe.Pointer(s))
|
||||
C.webview_set_title(w.w, s)
|
||||
}
|
||||
|
||||
func (w *webview) SetSize(width int, height int, hint Hint) {
|
||||
C.webview_set_size(w.w, C.int(width), C.int(height), C.webview_hint_t(hint))
|
||||
}
|
||||
|
||||
func (w *webview) Init(js string) {
|
||||
s := C.CString(js)
|
||||
defer C.free(unsafe.Pointer(s))
|
||||
C.webview_init(w.w, s)
|
||||
}
|
||||
|
||||
func (w *webview) Eval(js string) {
|
||||
s := C.CString(js)
|
||||
defer C.free(unsafe.Pointer(s))
|
||||
C.webview_eval(w.w, s)
|
||||
}
|
||||
|
||||
func (w *webview) Dispatch(f func()) {
|
||||
m.Lock()
|
||||
for ; dispatch[index] != nil; index++ {
|
||||
}
|
||||
dispatch[index] = f
|
||||
m.Unlock()
|
||||
C.CgoWebViewDispatch(w.w, C.uintptr_t(index))
|
||||
}
|
||||
|
||||
//export _webviewDispatchGoCallback
|
||||
func _webviewDispatchGoCallback(index unsafe.Pointer) {
|
||||
m.Lock()
|
||||
f := dispatch[uintptr(index)]
|
||||
delete(dispatch, uintptr(index))
|
||||
m.Unlock()
|
||||
f()
|
||||
}
|
||||
|
||||
//export _webviewBindingGoCallback
|
||||
func _webviewBindingGoCallback(w C.webview_t, id *C.char, req *C.char, index uintptr) {
|
||||
m.Lock()
|
||||
f := bindings[uintptr(index)]
|
||||
m.Unlock()
|
||||
jsString := func(v interface{}) string { b, _ := json.Marshal(v); return string(b) }
|
||||
status, result := 0, ""
|
||||
if res, err := f(C.GoString(id), C.GoString(req)); err != nil {
|
||||
status = -1
|
||||
result = jsString(err.Error())
|
||||
} else if b, err := json.Marshal(res); err != nil {
|
||||
status = -1
|
||||
result = jsString(err.Error())
|
||||
} else {
|
||||
status = 0
|
||||
result = string(b)
|
||||
}
|
||||
s := C.CString(result)
|
||||
defer C.free(unsafe.Pointer(s))
|
||||
C.webview_return(w, id, C.int(status), s)
|
||||
}
|
||||
|
||||
func (w *webview) Bind(name string, f interface{}) error {
|
||||
v := reflect.ValueOf(f)
|
||||
// f must be a function
|
||||
if v.Kind() != reflect.Func {
|
||||
return errors.New("only functions can be bound")
|
||||
}
|
||||
// f must return either value and error or just error
|
||||
if n := v.Type().NumOut(); n > 2 {
|
||||
return errors.New("function may only return a value or a value+error")
|
||||
}
|
||||
|
||||
binding := func(id, req string) (interface{}, error) {
|
||||
raw := []json.RawMessage{}
|
||||
if err := json.Unmarshal([]byte(req), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isVariadic := v.Type().IsVariadic()
|
||||
numIn := v.Type().NumIn()
|
||||
if (isVariadic && len(raw) < numIn-1) || (!isVariadic && len(raw) != numIn) {
|
||||
return nil, errors.New("function arguments mismatch")
|
||||
}
|
||||
args := []reflect.Value{}
|
||||
for i := range raw {
|
||||
var arg reflect.Value
|
||||
if isVariadic && i >= numIn-1 {
|
||||
arg = reflect.New(v.Type().In(numIn - 1).Elem())
|
||||
} else {
|
||||
arg = reflect.New(v.Type().In(i))
|
||||
}
|
||||
if err := json.Unmarshal(raw[i], arg.Interface()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, arg.Elem())
|
||||
}
|
||||
errorType := reflect.TypeOf((*error)(nil)).Elem()
|
||||
res := v.Call(args)
|
||||
switch len(res) {
|
||||
case 0:
|
||||
// No results from the function, just return nil
|
||||
return nil, nil
|
||||
case 1:
|
||||
// One result may be a value, or an error
|
||||
if res[0].Type().Implements(errorType) {
|
||||
if res[0].Interface() != nil {
|
||||
return nil, res[0].Interface().(error)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
return res[0].Interface(), nil
|
||||
case 2:
|
||||
// Two results: first one is value, second is error
|
||||
if !res[1].Type().Implements(errorType) {
|
||||
return nil, errors.New("second return value must be an error")
|
||||
}
|
||||
if res[1].Interface() == nil {
|
||||
return res[0].Interface(), nil
|
||||
}
|
||||
return res[0].Interface(), res[1].Interface().(error)
|
||||
default:
|
||||
return nil, errors.New("unexpected number of return values")
|
||||
}
|
||||
}
|
||||
|
||||
m.Lock()
|
||||
for ; bindings[index] != nil; index++ {
|
||||
}
|
||||
bindings[index] = binding
|
||||
m.Unlock()
|
||||
cname := C.CString(name)
|
||||
defer C.free(unsafe.Pointer(cname))
|
||||
C.CgoWebViewBind(w.w, cname, C.uintptr_t(index))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *webview) Unbind(name string) error {
|
||||
cname := C.CString(name)
|
||||
defer C.free(unsafe.Pointer(cname))
|
||||
C.CgoWebViewUnbind(w.w, cname)
|
||||
return nil
|
||||
}
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
package webview
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Example() {
|
||||
w := New(true)
|
||||
defer w.Destroy()
|
||||
w.SetTitle("Hello")
|
||||
w.Bind("noop", func() string {
|
||||
log.Println("hello")
|
||||
return "hello"
|
||||
})
|
||||
w.Bind("add", func(a, b int) int {
|
||||
return a + b
|
||||
})
|
||||
w.Bind("quit", func() {
|
||||
w.Terminate()
|
||||
})
|
||||
w.SetHtml(`<!doctype html>
|
||||
<html>
|
||||
<body>hello</body>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
document.body.innerText = ` + "`hello, ${navigator.userAgent}`" + `;
|
||||
noop().then(function(res) {
|
||||
console.log('noop res', res);
|
||||
add(1, 2).then(function(res) {
|
||||
console.log('add res', res);
|
||||
quit();
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</html>
|
||||
)`)
|
||||
w.Run()
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if testing.Verbose() {
|
||||
Example()
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
+3
-1
@@ -5,9 +5,9 @@ go 1.23.1
|
||||
replace github.com/pajlada/go-twitch-pubsub => github.com/plugnburn/go-twitch-pubsub v0.0.0-20240922200010-4bf88de12fae
|
||||
|
||||
require (
|
||||
centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16
|
||||
github.com/DaRealFreak/cloudflare-bp-go v1.0.4
|
||||
github.com/plugnburn/go-twitch-pubsub v0.0.0-20240922200010-4bf88de12fae
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -24,3 +24,5 @@ require (
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
honnef.co/go/tools v0.4.7 // indirect
|
||||
)
|
||||
|
||||
replace github.com/webview/webview_go => ../external/webview_go
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16 h1:gOFnYZ+G9R7Prc3Mn7z/v+LkPf/kQ2oUh5NCQd6n39k=
|
||||
centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16/go.mod h1:cAr/pT090G1kcy9uuCEUbZyGhUQRGOo4KVnkocx8BMA=
|
||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/DaRealFreak/cloudflare-bp-go v1.0.4 h1:33X8Z0YMV1DEVvL/kYLku+rjb4wF712+VIh3xBoifQ0=
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build gui && linux
|
||||
/*
|
||||
StreamGoose UI — Go reference implementation for Linux (GTK4 + WebKitGTK 6.0)
|
||||
|
||||
Created by Luxferre in 2024, updated in 2026, released into public domain
|
||||
*/
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo pkg-config: gtk4 webkitgtk-6.0
|
||||
#include <gtk/gtk.h>
|
||||
#include <webkit/webkit.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static void activate(GtkApplication *app, gpointer user_data) {
|
||||
const char *url = (const char *)user_data;
|
||||
GtkWidget *window = gtk_application_window_new(app);
|
||||
gtk_window_set_title(GTK_WINDOW(window), "StreamGoose");
|
||||
gtk_window_set_default_size(GTK_WINDOW(window), 320, 720);
|
||||
|
||||
GtkWidget *webview = webkit_web_view_new();
|
||||
webkit_web_view_load_uri(WEBKIT_WEB_VIEW(webview), url);
|
||||
|
||||
gtk_window_set_child(GTK_WINDOW(window), webview);
|
||||
gtk_window_present(GTK_WINDOW(window));
|
||||
}
|
||||
|
||||
static void run_webview(const char *url) {
|
||||
GtkApplication *app = gtk_application_new("org.luxferre.streamgoose", G_APPLICATION_DEFAULT_FLAGS);
|
||||
g_signal_connect(app, "activate", G_CALLBACK(activate), (gpointer)url);
|
||||
g_application_run(G_APPLICATION(app), 0, NULL);
|
||||
g_object_unref(app);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
/* override GUI entry point */
|
||||
func init() {
|
||||
goose_start_gui = func(config map[string]interface{}) {
|
||||
/* target server listening address */
|
||||
target_addr := fmt.Sprintf("%s:%d", config["server_listen_ip"].(string), int(config["server_port"].(float64)))
|
||||
url := "http://" + target_addr
|
||||
|
||||
curl := C.CString(url)
|
||||
defer C.free(unsafe.Pointer(curl))
|
||||
|
||||
C.run_webview(curl)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build gui
|
||||
//go:build gui && !linux
|
||||
/*
|
||||
StreamGoose UI — Go reference implementation
|
||||
|
||||
@@ -8,7 +8,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"centrifuge.hectabit.org/HectaBit/webview_go" /* for UI launch */
|
||||
"github.com/webview/webview_go" /* for UI launch */
|
||||
)
|
||||
|
||||
/* override GUI entry point */
|
||||
+1
-1
@@ -109,7 +109,7 @@ func kk_process_message(rawmsg map[string]interface{}) {
|
||||
out_message := ChatMessage {
|
||||
Type: "kk",
|
||||
NativeId: rawmsg["id"].(string),
|
||||
AuthorId: string(int64(author["id"].(float64))),
|
||||
AuthorId: fmt.Sprintf("%d", int64(author["id"].(float64))),
|
||||
Username: author["username"].(string),
|
||||
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
Text: rawmsg["content"].(string),
|
||||
|
||||
+114
-25
@@ -19,10 +19,13 @@ import (
|
||||
"path/filepath"
|
||||
"unicode/utf8"
|
||||
"encoding/json"
|
||||
"bufio"
|
||||
"sync"
|
||||
)
|
||||
|
||||
/* Twitch badge URL cache */
|
||||
var TW_BADGE_CACHE map[string]string
|
||||
var TW_USER_ICON_CACHE sync.Map
|
||||
/* Twitch OAuth global cache */
|
||||
var TW_OAUTH_TOKEN string
|
||||
var TW_CLIENT_ID string
|
||||
@@ -70,11 +73,36 @@ func tw_api_req(url string, data string) []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
/* User icon fetching and caching function */
|
||||
func tw_get_user_icon(username string) string {
|
||||
if icon, ok := TW_USER_ICON_CACHE.Load(username); ok {
|
||||
return icon.(string)
|
||||
}
|
||||
|
||||
/* Fallback to decapi.me due to Helix API 401 Token Scoping limitations on generic twitchapps auths */
|
||||
resp, err := http.Get("https://decapi.me/twitch/avatar/" + username)
|
||||
if err == nil {
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err == nil {
|
||||
url := strings.TrimSpace(string(body))
|
||||
if strings.HasPrefix(url, "https://") {
|
||||
TW_USER_ICON_CACHE.Store(username, url)
|
||||
return url
|
||||
}
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
TW_USER_ICON_CACHE.Store(username, "")
|
||||
return ""
|
||||
}
|
||||
|
||||
/* badge cache population function */
|
||||
func tw_fetch_badge_cache(channelname string) map[string]string {
|
||||
readycache := make(map[string]string)
|
||||
var ustruct map[string]interface{}
|
||||
/* get broadcaster ID from the channel name */
|
||||
|
||||
/* get broadcaster ID from the channel name via Helix API */
|
||||
body := tw_api_req("/users?login=" + channelname, "")
|
||||
if len(body) > 0 {
|
||||
err := json.Unmarshal(body, &ustruct)
|
||||
@@ -85,6 +113,7 @@ func tw_fetch_badge_cache(channelname string) map[string]string {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* get the global badges */
|
||||
body = tw_api_req("/chat/badges/global", "")
|
||||
if len(body) > 0 {
|
||||
@@ -102,6 +131,7 @@ func tw_fetch_badge_cache(channelname string) map[string]string {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if TW_BROADCASTER_ID != "" { /* also get the channel-specific badges */
|
||||
body = tw_api_req(fmt.Sprintf("/chat/badges?broadcaster_id=%s", TW_BROADCASTER_ID), "")
|
||||
if len(body) > 0 {
|
||||
@@ -153,49 +183,63 @@ func tw_handle_emotes(text string, emote_data string) string {
|
||||
|
||||
/* main message procesing function */
|
||||
func tw_process_message(rawmsg string) {
|
||||
if !strings.HasPrefix(rawmsg, "@") {
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(rawmsg, " :") /* get raw message parts */
|
||||
/* first part is metadata, second is a full IRC username,
|
||||
the rest is the message ending with \r\n */
|
||||
metadata_str := strings.TrimSpace(parts[0])
|
||||
/* first part is metadata, second is a full IRC sender+command, the rest is message */
|
||||
metadata_str := strings.TrimSpace(parts[0][1:]) // remove the leading '@'
|
||||
msg_text := ""
|
||||
if(len(parts) > 2) {
|
||||
msg_text = strings.Join(parts[2:], " :")
|
||||
msg_text = strings.ReplaceAll(msg_text, "\u0000", "") /* remove excess null bytes */
|
||||
}
|
||||
|
||||
command := ""
|
||||
if len(parts) > 1 {
|
||||
subparts := strings.Fields(parts[1])
|
||||
if len(subparts) > 1 {
|
||||
command = subparts[1]
|
||||
}
|
||||
}
|
||||
|
||||
metadata := make(map[string]string) /* store parsed metadata here */
|
||||
parts = strings.Split(metadata_str, ";")
|
||||
for _, field := range parts { /* iterate over metadata fields */
|
||||
mparts := strings.Split(metadata_str, ";")
|
||||
for _, field := range mparts { /* iterate over metadata fields */
|
||||
if strings.Contains(field, "=") {
|
||||
fieldkey, fieldval, _ := strings.Cut(field, "=")
|
||||
metadata[fieldkey] = fieldval
|
||||
}
|
||||
}
|
||||
/* if the message came from a real user, this is the one we should process */
|
||||
/* but first, check if this is a single-message deletion event */
|
||||
if strings.HasPrefix(rawmsg, "@login=") && strings.Contains(rawmsg, " CLEARMSG ") {
|
||||
|
||||
if command == "CLEARMSG" {
|
||||
/* prepare output message */
|
||||
out_message := ChatMessage {
|
||||
Type: "delmsg",
|
||||
NativeId: metadata["target-msg-id"],
|
||||
}
|
||||
/* send it to the message broker via the message_transport channel */
|
||||
message_transport <- out_message
|
||||
} else if strings.Contains(rawmsg, " CLEARCHAT ") { /* bulk message deletion event */
|
||||
/* prepare output message */
|
||||
} else if command == "CLEARCHAT" {
|
||||
out_message := ChatMessage {
|
||||
Type: "deluser",
|
||||
AuthorId: metadata["target-user-id"],
|
||||
}
|
||||
/* send it to the message broker via the message_transport channel */
|
||||
message_transport <- out_message
|
||||
} else if strings.HasPrefix(rawmsg, "@badge-info=") { /* normal message or usernotice */
|
||||
} else if command == "PRIVMSG" || command == "USERNOTICE" {
|
||||
/* normal message or usernotice */
|
||||
/* Check if it's a valid message by confirming we have a message ID */
|
||||
if metadata["id"] == "" {
|
||||
return
|
||||
}
|
||||
|
||||
/* convert the timestamp */
|
||||
ts, err := strconv.ParseInt(metadata["tmi-sent-ts"], 10, 0)
|
||||
if err != nil { /* fallback to current time if anything */
|
||||
ts = time.Now().UnixMilli()
|
||||
}
|
||||
sent_dt := time.UnixMilli(ts)
|
||||
|
||||
|
||||
/* process Twitch emotes before any positional changes can occur */
|
||||
if _, ok := metadata["emotes"]; ok && len(metadata["emotes"]) > 0 {
|
||||
msg_text = tw_handle_emotes(msg_text, metadata["emotes"])
|
||||
@@ -215,6 +259,19 @@ func tw_process_message(rawmsg string) {
|
||||
Text: strings.TrimSpace(msg_text),
|
||||
Icon: "",
|
||||
}
|
||||
|
||||
// Fallback if display-name is empty
|
||||
if out_message.Username == "" {
|
||||
if len(parts) > 1 {
|
||||
headerParts := strings.Split(parts[1], " ")
|
||||
if len(headerParts) > 0 {
|
||||
senderSplits := strings.Split(headerParts[0], "!")
|
||||
if len(senderSplits) > 0 {
|
||||
out_message.Username = senderSplits[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* process username badges */
|
||||
if _, ok := metadata["badges"]; ok {
|
||||
@@ -250,8 +307,21 @@ func tw_process_message(rawmsg string) {
|
||||
}
|
||||
}
|
||||
|
||||
/* finally, send it to the message broker via the message_transport channel */
|
||||
message_transport <- out_message
|
||||
/* fetch user icons asynchronously so we don't hold up IRC parser speed */
|
||||
if out_message.Username != "" {
|
||||
if icon, ok := TW_USER_ICON_CACHE.Load(out_message.Username); ok {
|
||||
out_message.Icon = icon.(string)
|
||||
message_transport <- out_message
|
||||
} else {
|
||||
go func(msg ChatMessage) {
|
||||
msg.Icon = tw_get_user_icon(msg.Username)
|
||||
message_transport <- msg
|
||||
}(out_message)
|
||||
}
|
||||
} else {
|
||||
/* finally, send it to the message broker via the message_transport channel */
|
||||
message_transport <- out_message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +352,21 @@ func twitch_run(config map[string]interface{}) {
|
||||
/* populate TW_OAUTH_TOKEN and TW_CLIENT_ID globals for non-IRC API calls */
|
||||
TW_CLIENT_ID = strings.TrimSpace(string(cid))
|
||||
TW_OAUTH_TOKEN, _ = strings.CutPrefix(config["tw_token"].(string), "oauth:")
|
||||
|
||||
/* Automatically detect the correct Client-ID for the OAuth token */
|
||||
req, _ := http.NewRequest("GET", "https://id.twitch.tv/oauth2/validate", nil)
|
||||
req.Header.Set("Authorization", "OAuth " + TW_OAUTH_TOKEN)
|
||||
client := &http.Client{}
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
body, _ := ioutil.ReadAll(resp.Body)
|
||||
var vstruct map[string]interface{}
|
||||
if err := json.Unmarshal(body, &vstruct); err == nil {
|
||||
if autoCid, ok := vstruct["client_id"]; ok {
|
||||
TW_CLIENT_ID = autoCid.(string)
|
||||
}
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
/* load the Twitch badge cache */
|
||||
chname, _ := strings.CutPrefix(config["tw_channel"].(string), "#")
|
||||
@@ -294,26 +379,30 @@ func twitch_run(config map[string]interface{}) {
|
||||
log.Fatal("[twitch] Error connecting to Twitch: ", err)
|
||||
}
|
||||
|
||||
tcpwritestr(conn, fmt.Sprintf("PASS %s\n", config["tw_token"]))
|
||||
tcpwritestr(conn, fmt.Sprintf("NICK %s\n", config["tw_nickname"]))
|
||||
tcpwritestr(conn, fmt.Sprintf("JOIN %s\n", config["tw_channel"]))
|
||||
/* request advanced chat message capabilities */
|
||||
tcpwritestr(conn, "CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands\n")
|
||||
/* request advanced chat message capabilities BEFORE login/join */
|
||||
tcpwritestr(conn, "CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands\r\n")
|
||||
tcpwritestr(conn, fmt.Sprintf("PASS %s\r\n", config["tw_token"]))
|
||||
tcpwritestr(conn, fmt.Sprintf("NICK %s\r\n", strings.ToLower(config["tw_nickname"].(string))))
|
||||
tcpwritestr(conn, fmt.Sprintf("JOIN %s\r\n", config["tw_channel"]))
|
||||
|
||||
/* set up subscriptions and start the WebSocket event loop goroutine */
|
||||
go tw_ws_pubsub_readloop()
|
||||
|
||||
log.Println("StreamGoose Twitch Go backend connected")
|
||||
|
||||
var rawmsg string
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
for { /* main loop */
|
||||
rawmsg = tcpreadstr(conn)
|
||||
rawmsg, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Fatal("[twitch] Error reading from socket: ", err)
|
||||
}
|
||||
rawmsg = strings.TrimRight(rawmsg, "\r\n") // remove CRLF
|
||||
if _, ok := config["debug"]; ok && config["debug"].(bool) == true {
|
||||
log.Println(rawmsg)
|
||||
}
|
||||
if strings.HasPrefix(rawmsg, "PING") { /* respond to the PING message */
|
||||
tcpwritestr(conn, "PONG\n")
|
||||
tcpwritestr(conn, "PONG :tmi.twitch.tv\r\n")
|
||||
} else { /* run main procesing */
|
||||
tw_process_message(rawmsg)
|
||||
}
|
||||
|
||||
+129
-26
@@ -2,6 +2,7 @@
|
||||
StreamGoose YouTube backend — Innertube API
|
||||
|
||||
Created by Luxferre in 2024, released into public domain
|
||||
Updated in 2026 to implement more robust logic
|
||||
*/
|
||||
|
||||
package main
|
||||
@@ -27,6 +28,7 @@ type YTIChatOptions struct {
|
||||
Continuation string `json:"continuation"` /* continuation key */
|
||||
ClientName string `json:"clientName"` /* client name string */
|
||||
ClientVersion string `json:"clientVersion"` /* client version string */
|
||||
VisitorData string `json:"visitorData"` /* user tracking ID */
|
||||
}
|
||||
|
||||
/* API request helper function */
|
||||
@@ -36,8 +38,15 @@ func yt_inner_api_req(url string, data []byte) string {
|
||||
method = "POST"
|
||||
}
|
||||
req, err := http.NewRequest(method, url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
req.Header.Set("User-Agent", YT_INNER_USERAGENT)
|
||||
req.Header.Set("Accept-Language", "en-US")
|
||||
if len(data) > 0 {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", "https://www.youtube.com")
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
@@ -60,12 +69,20 @@ func yt_inner_runs_to_text(runs []interface{}) string {
|
||||
run := rrun.(map[string]interface{})
|
||||
if _, ok := run["text"]; ok { /* normal text */
|
||||
out_text += run["text"].(string)
|
||||
} else if _, ok := run["emoji"]; ok { /* this is where the fun begins */
|
||||
} else if _, ok := run["emoji"]; ok { /* emoji handling */
|
||||
emoji := run["emoji"].(map[string]interface{})
|
||||
if _, ok := emoji["isCustomEmoji"]; ok && emoji["isCustomEmoji"].(bool) == true { /* custom emoji */
|
||||
/* TODO: out_text += " #YTEMOTE-" + emoji["image"]["thumbnails"][0]["url"] + "# " */
|
||||
if len(emoji["shortcuts"].([]interface{})) > 0 {
|
||||
out_text += " " + emoji["shortcuts"].([]interface{})[0].(string) + " "
|
||||
if isCustom, ok := emoji["isCustomEmoji"]; ok && isCustom.(bool) == true { /* custom emoji */
|
||||
/* dynamically fetch emoji thumbnail */
|
||||
if image, imgOk := emoji["image"].(map[string]interface{}); imgOk {
|
||||
if thumbnails, thOk := image["thumbnails"].([]interface{}); thOk && len(thumbnails) > 0 {
|
||||
thumb := thumbnails[0].(map[string]interface{})
|
||||
out_text += fmt.Sprintf(" <img src=\"%s\" class=\"yt-emote\" /> ", thumb["url"].(string))
|
||||
}
|
||||
} else {
|
||||
/* fallback to text shortcut */
|
||||
if shortcuts, scOk := emoji["shortcuts"].([]interface{}); scOk && len(shortcuts) > 0 {
|
||||
out_text += " " + shortcuts[0].(string) + " "
|
||||
}
|
||||
}
|
||||
} else { /* standard Unicode emoji, pass as text */
|
||||
out_text += emoji["emojiId"].(string)
|
||||
@@ -77,6 +94,30 @@ func yt_inner_runs_to_text(runs []interface{}) string {
|
||||
|
||||
/* convert inner chat action into a public chat item */
|
||||
func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage {
|
||||
/* Handle Message Deletions */
|
||||
if _, ok := action["markChatItemAsDeletedAction"]; ok {
|
||||
delAction := action["markChatItemAsDeletedAction"].(map[string]interface{})
|
||||
targetId := delAction["targetItemId"].(string)
|
||||
return ChatMessage{
|
||||
Type: "yt^delmsg",
|
||||
NativeId: targetId,
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := action["markChatItemsByAuthorAsDeletedAction"]; ok {
|
||||
delAction := action["markChatItemsByAuthorAsDeletedAction"].(map[string]interface{})
|
||||
authorId := delAction["deletedChannelId"].(string)
|
||||
return ChatMessage{
|
||||
Type: "yt^deluser",
|
||||
AuthorId: authorId,
|
||||
}
|
||||
}
|
||||
|
||||
/* Handle Normal Additions */
|
||||
if _, ok := action["addChatItemAction"]; !ok {
|
||||
return ChatMessage{}
|
||||
}
|
||||
|
||||
item := action["addChatItemAction"].(map[string]interface{})["item"].(map[string]interface{})
|
||||
/* get the renderer */
|
||||
var renderer map[string]interface{}
|
||||
@@ -91,9 +132,11 @@ func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage {
|
||||
} else {
|
||||
renderer = nil
|
||||
}
|
||||
|
||||
if renderer == nil { /* invalid renderer field */
|
||||
return ChatMessage{}
|
||||
}
|
||||
|
||||
/* here, message text is composed as a series of individual runs */
|
||||
var mruns []interface{}
|
||||
if _, ok := renderer["message"]; ok {
|
||||
@@ -114,6 +157,7 @@ func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage {
|
||||
author_icon = thumbnails[0].(map[string]interface{})["url"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
/* fetch the timestamp (microseconds) */
|
||||
ts, err := strconv.ParseInt(renderer["timestampUsec"].(string), 10, 0)
|
||||
if err != nil { /* fallback to current time if anything */
|
||||
@@ -125,13 +169,17 @@ func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage {
|
||||
out_message := ChatMessage {
|
||||
Type: "yt",
|
||||
NativeId: renderer["id"].(string),
|
||||
AuthorId: renderer["authorExternalChannelId"].(string),
|
||||
Username: author_name,
|
||||
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
Text: yt_inner_runs_to_text(mruns),
|
||||
Icon: author_icon,
|
||||
}
|
||||
|
||||
/* Optional field due to structure */
|
||||
if _, ok := renderer["authorExternalChannelId"]; ok {
|
||||
out_message.AuthorId = renderer["authorExternalChannelId"].(string)
|
||||
}
|
||||
|
||||
/* check roles */
|
||||
if _, ok := renderer["authorBadges"]; ok {
|
||||
for _, entry := range renderer["authorBadges"].([]interface{}) {
|
||||
@@ -151,6 +199,7 @@ func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := renderer["purchaseAmountText"]; ok {
|
||||
out_message.Type += "^sponsor"
|
||||
}
|
||||
@@ -169,67 +218,121 @@ func yt_inner_get_chat_options(video_id string) YTIChatOptions {
|
||||
}
|
||||
raw_data := yt_inner_api_req(live_url, nil)
|
||||
opts.ClientName = "WEB"
|
||||
r, _ := regexp.Compile("\"INNERTUBE_API_KEY\":\"(.+?)\"")
|
||||
|
||||
r, _ := regexp.Compile("\"INNERTUBE_API_KEY\":\"([^\"]+)\"")
|
||||
rmatch := r.FindStringSubmatch(raw_data)
|
||||
if len(rmatch) > 0 {
|
||||
opts.Key = rmatch[1]
|
||||
} else {
|
||||
log.Fatal("[youtube] Cannot connect to the YouTube livechat (innertube key)")
|
||||
}
|
||||
r, _ = regexp.Compile("\"continuation\":\"(.+?)\"")
|
||||
|
||||
r, _ = regexp.Compile("\"continuation\":\"([^\"]+)\"")
|
||||
rmatch = r.FindStringSubmatch(raw_data)
|
||||
if len(rmatch) > 0 {
|
||||
opts.Continuation = rmatch[1]
|
||||
} else {
|
||||
log.Fatal("[youtube] Cannot connect to the YouTube livechat (continuation)")
|
||||
}
|
||||
r, _ = regexp.Compile("\"clientVersion\":\"(.+?)\"")
|
||||
|
||||
r, _ = regexp.Compile("\"clientVersion\":\"([^\"]+)\"")
|
||||
rmatch = r.FindStringSubmatch(raw_data)
|
||||
if len(rmatch) > 0 {
|
||||
opts.ClientVersion = rmatch[1]
|
||||
} else {
|
||||
log.Fatal("[youtube] Cannot connect to the YouTube livechat (clientversion)")
|
||||
opts.ClientVersion = "2.20260206.01.00" // Fallback to a known youtubei.js version
|
||||
}
|
||||
|
||||
r, _ = regexp.Compile("\"visitorData\":\"([^\"]+)\"")
|
||||
rmatch = r.FindStringSubmatch(raw_data)
|
||||
if len(rmatch) > 0 {
|
||||
opts.VisitorData = rmatch[1]
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
/* recursive function to find the next continuation token in the payload */
|
||||
func find_continuation(node interface{}) string {
|
||||
switch n := node.(type) {
|
||||
case map[string]interface{}:
|
||||
if cont, ok := n["continuation"]; ok {
|
||||
if str, isStr := cont.(string); isStr {
|
||||
return str
|
||||
}
|
||||
}
|
||||
for _, v := range n {
|
||||
if res := find_continuation(v); res != "" {
|
||||
return res
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, v := range n {
|
||||
if res := find_continuation(v); res != "" {
|
||||
return res
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/* fetch chat data using the chat options */
|
||||
/* return the list of ChatMessage items */
|
||||
/* should update the options structure */
|
||||
/* updates the options structure with the next continuation */
|
||||
func yt_inner_get_chat_data(opts *YTIChatOptions) []ChatMessage {
|
||||
jsondata := make(map[string]interface{})
|
||||
jsondata["context"] = make(map[string]interface{})
|
||||
jsondata["continuation"] = opts.Continuation
|
||||
jsondata["context"].(map[string]interface{})["client"] = make(map[string]string)
|
||||
jsondata["context"].(map[string]interface{})["client"].(map[string]string)["userAgent"] = YT_INNER_USERAGENT
|
||||
jsondata["context"].(map[string]interface{})["client"].(map[string]string)["clientName"] = opts.ClientName
|
||||
jsondata["context"].(map[string]interface{})["client"].(map[string]string)["clientVersion"] = opts.ClientVersion
|
||||
|
||||
clientMap := make(map[string]interface{})
|
||||
clientMap["hl"] = "en"
|
||||
clientMap["gl"] = "US"
|
||||
clientMap["userAgent"] = YT_INNER_USERAGENT
|
||||
clientMap["clientName"] = opts.ClientName
|
||||
clientMap["clientVersion"] = opts.ClientVersion
|
||||
clientMap["timeZone"] = "UTC"
|
||||
if opts.VisitorData != "" {
|
||||
clientMap["visitorData"] = opts.VisitorData
|
||||
}
|
||||
|
||||
jsondata["context"].(map[string]interface{})["client"] = clientMap
|
||||
jsonstr, _ := json.Marshal(&jsondata)
|
||||
|
||||
/* post the marshalled string and get the result into chatdata */
|
||||
target_url := fmt.Sprintf("https://www.youtube.com/youtubei/v1/live_chat/get_live_chat?key=%s", opts.Key)
|
||||
rawresp := yt_inner_api_req(target_url, jsonstr)
|
||||
|
||||
var chatdata map[string]interface{}
|
||||
err := json.Unmarshal([]byte(rawresp), &chatdata)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
/* Don't panic here on unmarshal errors, might be a temporary hiccup */
|
||||
log.Println("[youtube] error decoding JSON continuation:", err)
|
||||
return []ChatMessage{}
|
||||
}
|
||||
|
||||
var chatitems []ChatMessage
|
||||
/* process the upper structure */
|
||||
|
||||
/* Look for the next continuation token aggressively */
|
||||
if cd, ok := chatdata["continuationContents"]; ok {
|
||||
nextCont := find_continuation(cd)
|
||||
if nextCont != "" {
|
||||
opts.Continuation = nextCont
|
||||
} else {
|
||||
log.Println("[youtube] warning: could not find next continuation token")
|
||||
}
|
||||
}
|
||||
|
||||
/* process the upper structure for actions */
|
||||
if _, ok := chatdata["continuationContents"]; ok {
|
||||
cc := chatdata["continuationContents"].(map[string]interface{})
|
||||
if _, ok := cc["liveChatContinuation"]; ok {
|
||||
lcc := cc["liveChatContinuation"].(map[string]interface{})
|
||||
if lcc["actions"] != nil {
|
||||
actions := lcc["actions"].([]interface{})
|
||||
conts := lcc["continuations"].([]interface{})
|
||||
icd := conts[0].(map[string]interface{})["invalidationContinuationData"].(map[string]interface{})
|
||||
opts.Continuation = icd["continuation"].(string)
|
||||
actions := lcc["actions"].([]interface{})
|
||||
for _, action := range actions { /* iterate through the actions */
|
||||
if _, ok := action.(map[string]interface{})["addChatItemAction"]; ok {
|
||||
chatitem := yt_inner_action_to_chatitem(action.(map[string]interface{}))
|
||||
if chatitem.Type != "" {
|
||||
chatitems = append(chatitems, chatitem)
|
||||
}
|
||||
chatitem := yt_inner_action_to_chatitem(action.(map[string]interface{}))
|
||||
if chatitem.Type != "" {
|
||||
chatitems = append(chatitems, chatitem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-28
@@ -2,8 +2,8 @@
|
||||
|
||||
/* escape HTML entities (for non-server-provided stuff) */
|
||||
function esc(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"').replace(/'/g,''')
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/* handle Twitch, YouTube and Kick emotes */
|
||||
@@ -12,13 +12,13 @@ function handleEmotes(text) {
|
||||
var kickurl = 'https://files.kick.com/emotes/$id/fullsize'
|
||||
/* Handle Twitch emotes coming from backends */
|
||||
text = text.replace(/#TWEMOTE-[^\s]+#/g,
|
||||
a=>'<img src="' + twurl.replace('$id', a.split('-')[1].slice(0,-1)) + '">')
|
||||
a => '<img src="' + twurl.replace('$id', a.split('-')[1].slice(0, -1)) + '">')
|
||||
/* Handle Kick emotes coming from backends */
|
||||
text = text.replace(/#KKEMOTE-[^\s]+#/g,
|
||||
a=>'<img src="' + kickurl.replace('$id', a.split('-')[1].slice(0,-1)) + '">')
|
||||
a => '<img src="' + kickurl.replace('$id', a.split('-')[1].slice(0, -1)) + '">')
|
||||
/* Handle YouTube emotes */
|
||||
text = text.replace(/:[^\s]+:/g, a => {
|
||||
if(YT_EMOTES.indexOf(a) > -1) /* found in the database */
|
||||
if (YT_EMOTES.indexOf(a) > -1) /* found in the database */
|
||||
return '<img src="/img/yt-emotes/' + a + '.png">'
|
||||
else return a
|
||||
})
|
||||
@@ -28,67 +28,67 @@ function handleEmotes(text) {
|
||||
/* detect if there are any overridden styles */
|
||||
function overrideStyles(text) {
|
||||
var styles = '', re
|
||||
if(messageHighlightRules) {
|
||||
for(re in messageHighlightRules) {
|
||||
if((new RegExp(re, 'iu')).test(text))
|
||||
if (messageHighlightRules) {
|
||||
for (re in messageHighlightRules) {
|
||||
if ((new RegExp(re, 'iu')).test(text))
|
||||
styles += 'color:' + messageHighlightRules[re] + ';'
|
||||
}
|
||||
}
|
||||
if(styles.length > 0) styles = ' style="' + esc(styles) + '"'
|
||||
if (styles.length > 0) styles = ' style="' + esc(styles) + '"'
|
||||
return styles
|
||||
}
|
||||
|
||||
/* individual message appender */
|
||||
function gooseAppendMessage(id, native_id, author_id, type, timestamp, username, text, icon) {
|
||||
var html = ""
|
||||
if(parseInt(id) > 0 && type.length > 0) {
|
||||
if(type == "delmsg") { /* message deletion event */
|
||||
if (parseInt(id) > 0 && type.length > 0) {
|
||||
if (type == "delmsg") { /* message deletion event */
|
||||
var el = document.querySelector('[data-native-id="' + esc(native_id) + '"]')
|
||||
if(el) el.parentNode.removeChild(el)
|
||||
} else if(type == "deluser") { /* bulk deletion event by username */
|
||||
if (el) el.parentNode.removeChild(el)
|
||||
} else if (type == "deluser") { /* bulk deletion event by username */
|
||||
var els = document.querySelectorAll('[data-author-id="' + esc(author_id) + '"]')
|
||||
if(els && els.length > 0) {
|
||||
if (els && els.length > 0) {
|
||||
var i, l = els.length
|
||||
for(i=0;i<l;i++) els[i].parentNode.removeChild(els[i])
|
||||
for (i = 0; i < l; i++) els[i].parentNode.removeChild(els[i])
|
||||
}
|
||||
} else { /* message appending event */
|
||||
var classes = type.split('^'), cl = classes.length, j,
|
||||
colorpref = null, badges = [], platform, bname
|
||||
for(j=0;j<cl;j++) { /* detect color preference */
|
||||
if(classes[j].startsWith('color'))
|
||||
colorpref = null, badges = [], platform, bname
|
||||
for (j = 0; j < cl; j++) { /* detect color preference */
|
||||
if (classes[j].startsWith('color'))
|
||||
colorpref = classes[j].slice(5) /* remove color word */
|
||||
if(classes[j].startsWith('badge')) { /* remove the class but cache it */
|
||||
if (classes[j].startsWith('badge')) { /* remove the class but cache it */
|
||||
badges.push(classes[j].slice(5)) /* remove badge word */
|
||||
classes[j] = "" /* remove the class from the list */
|
||||
}
|
||||
}
|
||||
html += '<div data-msg-id="' + id + '" data-native-id="' + esc(native_id) + '" data-author-id="'
|
||||
+ esc(author_id) + '" class="' + classes.join(' ') +'">'
|
||||
+ esc(author_id) + '" class="' + classes.join(' ') + '">'
|
||||
/* handle badges and icons */
|
||||
html += '<span class=icon>'
|
||||
for(j=0,cl=badges.length;j<cl;j++) { /* iterate over badges */
|
||||
platform = badges[j].slice(0,2) /* first 2 characters denote the platform */
|
||||
for (j = 0, cl = badges.length; j < cl; j++) { /* iterate over badges */
|
||||
platform = badges[j].slice(0, 2) /* first 2 characters denote the platform */
|
||||
bname = badges[j].slice(2) /* the rest is the badge name itself */
|
||||
html += '<img class=badge src="/badge/{platform}/{badgename}">'
|
||||
.replace('{platform}', platform).replace('{badgename}', bname)
|
||||
.replace('{platform}', platform).replace('{badgename}', bname)
|
||||
}
|
||||
if(icon.length > 0) /* user icon, if present */
|
||||
if (icon.length > 0) /* user icon, if present */
|
||||
html += '<img src="' + icon + '">'
|
||||
html += '</span>'
|
||||
html += '<span class=timestamp>' + timestamp + '</span>'
|
||||
html += '<span class=username'
|
||||
if(colorpref) html += ' style="color:' + colorpref + '"'
|
||||
if (colorpref) html += ' style="color:' + colorpref + '"'
|
||||
html += '>' + username + '</span>'
|
||||
html += '<span class=msg' + overrideStyles(text) +'>' + handleEmotes(text) + '</span></div>'
|
||||
html += '<span class=msg' + overrideStyles(text) + '>' + handleEmotes(text) + '</span></div>'
|
||||
document.getElementById("msglist").innerHTML += html
|
||||
}
|
||||
}
|
||||
/* autoscroll to the end */
|
||||
window.setTimeout(function(){window.scrollTo(0, document.body.scrollHeight)}, 150)
|
||||
window.setTimeout(function () { window.scrollTo(0, document.body.scrollHeight) }, 150)
|
||||
}
|
||||
|
||||
/* entry point, set up an event source */
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
var msgSource = new EventSource('/sse')
|
||||
msgSource.onmessage = e => {
|
||||
var msg = JSON.parse(e.data)
|
||||
|
||||
Reference in New Issue
Block a user