mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 08:10:55 +00:00
609 lines
14 KiB
Go
609 lines
14 KiB
Go
//go:build cgo
|
|
|
|
package goprovider
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
runtimepkg "runtime"
|
|
|
|
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
|
webview "github.com/GopeedLab/webview_go"
|
|
)
|
|
|
|
type Provider struct{}
|
|
|
|
func New() enginewebview.Provider {
|
|
return &Provider{}
|
|
}
|
|
|
|
func (p *Provider) IsAvailable() bool {
|
|
return webview.IsAvailable()
|
|
}
|
|
|
|
func (p *Provider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
|
|
pw := newPageWrapper(opts)
|
|
if err := pw.start(); err != nil {
|
|
return nil, err
|
|
}
|
|
return pw, nil
|
|
}
|
|
|
|
type pageWrapper struct {
|
|
opts enginewebview.OpenOptions
|
|
|
|
callbackName string
|
|
readyID string
|
|
|
|
view webview.WebView
|
|
|
|
mu sync.Mutex
|
|
pending map[string]chan evalResult
|
|
url string
|
|
closed bool
|
|
|
|
ready chan error
|
|
done chan struct{}
|
|
loads chan string
|
|
}
|
|
|
|
type evalMessage struct {
|
|
ID string `json:"id"`
|
|
Ready bool `json:"ready,omitempty"`
|
|
Load bool `json:"load,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
OK bool `json:"ok,omitempty"`
|
|
Value any `json:"value,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type evalResult struct {
|
|
value any
|
|
err error
|
|
}
|
|
|
|
type nativeCookieJSON struct {
|
|
Name string `json:"name"`
|
|
Value string `json:"value"`
|
|
Domain string `json:"domain"`
|
|
Path string `json:"path"`
|
|
Expires string `json:"expires"`
|
|
Secure bool `json:"secure"`
|
|
HTTPOnly bool `json:"httpOnly"`
|
|
}
|
|
|
|
var providerSeq atomic.Uint64
|
|
|
|
func newPageWrapper(opts enginewebview.OpenOptions) *pageWrapper {
|
|
seq := providerSeq.Add(1)
|
|
return &pageWrapper{
|
|
opts: opts,
|
|
callbackName: fmt.Sprintf("__gopeedWebViewCallback_%d", seq),
|
|
readyID: fmt.Sprintf("__ready__%d", seq),
|
|
pending: make(map[string]chan evalResult),
|
|
ready: make(chan error, 1),
|
|
done: make(chan struct{}),
|
|
loads: make(chan string, 8),
|
|
}
|
|
}
|
|
|
|
func (p *pageWrapper) start() error {
|
|
startedAt := time.Now()
|
|
run := func() {
|
|
runtimepkg.LockOSThread()
|
|
defer runtimepkg.UnlockOSThread()
|
|
|
|
var w webview.WebView
|
|
if p.opts.Headless {
|
|
w = webview.NewHeadless(p.opts.Debug)
|
|
} else {
|
|
w = webview.New(p.opts.Debug)
|
|
}
|
|
p.view = w
|
|
w.SetTitle(firstNonEmpty(p.opts.Title, "Gopeed WebView"))
|
|
w.SetSize(defaultWindowDimension(p.opts.Width, 1280), defaultWindowDimension(p.opts.Height, 800), webview.HintNone)
|
|
|
|
if p.opts.UserAgent != "" {
|
|
type userAgentSetter interface {
|
|
SetUserAgent(userAgent string)
|
|
}
|
|
if setter, ok := w.(userAgentSetter); ok {
|
|
setter.SetUserAgent(p.opts.UserAgent)
|
|
}
|
|
}
|
|
w.Init(buildLoadNotifyScript(p.callbackName))
|
|
|
|
if err := w.Bind(p.callbackName, func(payload string) error {
|
|
return p.handleCallback(payload)
|
|
}); err != nil {
|
|
p.ready <- err
|
|
return
|
|
}
|
|
|
|
applyWindowOptions(w, p.opts)
|
|
w.SetHtml(buildBootstrapHTML(p.callbackName, p.readyID))
|
|
w.Run()
|
|
w.Destroy()
|
|
close(p.done)
|
|
}
|
|
|
|
if !postMainThreadTask(run) {
|
|
go run()
|
|
}
|
|
|
|
select {
|
|
case err := <-p.ready:
|
|
p.tracef("open %dms", time.Since(startedAt).Milliseconds())
|
|
return err
|
|
case <-time.After(10 * time.Second):
|
|
p.tracef("open timeout %dms", time.Since(startedAt).Milliseconds())
|
|
return fmt.Errorf("webview startup timeout")
|
|
}
|
|
}
|
|
|
|
func (p *pageWrapper) AddInitScript(script string) error {
|
|
return p.dispatch(func(w webview.WebView) error {
|
|
w.Init(script)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (p *pageWrapper) Goto(url string, opts enginewebview.GotoOptions) error {
|
|
startedAt := time.Now()
|
|
p.drainLoads()
|
|
if err := p.dispatch(func(w webview.WebView) error {
|
|
p.mu.Lock()
|
|
p.url = url
|
|
p.mu.Unlock()
|
|
w.Navigate(url)
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
timeout := 30 * time.Second
|
|
if opts.TimeoutMS > 0 {
|
|
timeout = time.Duration(opts.TimeoutMS) * time.Millisecond
|
|
}
|
|
waitUntil := opts.WaitUntil
|
|
if waitUntil == "" {
|
|
waitUntil = "load"
|
|
}
|
|
err := p.waitForNavigation(url, timeout, waitUntil)
|
|
if err != nil {
|
|
p.tracef("goto failed %dms url=%s err=%v", time.Since(startedAt).Milliseconds(), url, err)
|
|
return err
|
|
}
|
|
p.tracef("goto %dms url=%s", time.Since(startedAt).Milliseconds(), url)
|
|
return nil
|
|
}
|
|
|
|
func (p *pageWrapper) Execute(expression string, args ...any) (any, error) {
|
|
startedAt := time.Now()
|
|
if args == nil {
|
|
args = []any{}
|
|
}
|
|
argsJSON, err := json.Marshal(args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
reqID := fmt.Sprintf("req-%d", providerSeq.Add(1))
|
|
ch := make(chan evalResult, 1)
|
|
|
|
p.mu.Lock()
|
|
if p.closed {
|
|
p.mu.Unlock()
|
|
return nil, fmt.Errorf("webview page is closed")
|
|
}
|
|
p.pending[reqID] = ch
|
|
p.mu.Unlock()
|
|
|
|
script := buildExecuteScript(p.callbackName, reqID, expression, string(argsJSON))
|
|
if err := p.dispatch(func(w webview.WebView) error {
|
|
w.Eval(script)
|
|
return nil
|
|
}); err != nil {
|
|
p.mu.Lock()
|
|
delete(p.pending, reqID)
|
|
p.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
|
|
result := <-ch
|
|
if result.err != nil {
|
|
p.tracef("execute failed %dms err=%v", time.Since(startedAt).Milliseconds(), result.err)
|
|
return result.value, result.err
|
|
}
|
|
p.tracef("execute %dms", time.Since(startedAt).Milliseconds())
|
|
return result.value, nil
|
|
}
|
|
|
|
func (p *pageWrapper) GetCookies() ([]enginewebview.Cookie, error) {
|
|
cookies, err := p.nativeCookies()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]enginewebview.Cookie, 0, len(cookies))
|
|
for _, cookie := range cookies {
|
|
item := enginewebview.Cookie{
|
|
Name: cookie.Name,
|
|
Value: cookie.Value,
|
|
Domain: cookie.Domain,
|
|
Path: cookie.Path,
|
|
Secure: cookie.Secure,
|
|
HTTPOnly: cookie.HTTPOnly,
|
|
}
|
|
if cookie.Expires != "" {
|
|
if parsed, err := time.Parse(time.RFC3339Nano, cookie.Expires); err == nil {
|
|
item.Expires = parsed
|
|
}
|
|
}
|
|
result = append(result, item)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (p *pageWrapper) SetCookie(cookie enginewebview.Cookie) error {
|
|
type cookieSetter interface {
|
|
SetCookie(cookie webview.Cookie) error
|
|
}
|
|
return p.dispatch(func(w webview.WebView) error {
|
|
setter, ok := w.(cookieSetter)
|
|
if !ok {
|
|
return fmt.Errorf("native cookie access is unavailable")
|
|
}
|
|
return setter.SetCookie(webview.Cookie{
|
|
Name: cookie.Name,
|
|
Value: cookie.Value,
|
|
Domain: cookie.Domain,
|
|
Path: cookie.Path,
|
|
Expires: cookie.Expires,
|
|
Secure: cookie.Secure,
|
|
HTTPOnly: cookie.HTTPOnly,
|
|
})
|
|
})
|
|
}
|
|
|
|
func (p *pageWrapper) DeleteCookie(cookie enginewebview.Cookie) error {
|
|
type cookieDeleter interface {
|
|
DeleteCookie(name string, domain string, path string) error
|
|
}
|
|
return p.dispatch(func(w webview.WebView) error {
|
|
deleter, ok := w.(cookieDeleter)
|
|
if !ok {
|
|
return fmt.Errorf("native cookie access is unavailable")
|
|
}
|
|
return deleter.DeleteCookie(cookie.Name, cookie.Domain, cookie.Path)
|
|
})
|
|
}
|
|
|
|
func (p *pageWrapper) ClearCookies() error {
|
|
type cookieClearer interface {
|
|
ClearCookies() error
|
|
}
|
|
return p.dispatch(func(w webview.WebView) error {
|
|
clearer, ok := w.(cookieClearer)
|
|
if !ok {
|
|
return fmt.Errorf("native cookie access is unavailable")
|
|
}
|
|
return clearer.ClearCookies()
|
|
})
|
|
}
|
|
|
|
func (p *pageWrapper) Close() error {
|
|
p.mu.Lock()
|
|
if p.closed {
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
p.closed = true
|
|
w := p.view
|
|
wait := p.done
|
|
p.mu.Unlock()
|
|
|
|
if w != nil {
|
|
// PostQuitMessage (used by the Windows WebView2 terminate_impl)
|
|
// only takes effect on the thread that owns the message loop.
|
|
// Dispatch the termination to the webview's UI thread so that
|
|
// the quit message lands on the correct queue.
|
|
terminateDone := make(chan struct{})
|
|
w.Dispatch(func() {
|
|
w.Terminate()
|
|
close(terminateDone)
|
|
})
|
|
<-terminateDone
|
|
}
|
|
<-wait
|
|
return nil
|
|
}
|
|
|
|
func (p *pageWrapper) tracef(format string, args ...any) {
|
|
if !strings.Contains(p.opts.Title, "youtube-sabr") {
|
|
return
|
|
}
|
|
fmt.Printf("goprovider "+format+"\n", args...)
|
|
}
|
|
|
|
func (p *pageWrapper) waitForNavigation(targetURL string, timeout time.Duration, waitUntil string) error {
|
|
deadline := time.Now().Add(timeout)
|
|
ticker := time.NewTicker(100 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-p.loads:
|
|
if waitUntil == "load" {
|
|
return nil
|
|
}
|
|
case <-ticker.C:
|
|
state, err := p.navigationState()
|
|
if err == nil && state.ready(targetURL, waitUntil) {
|
|
return nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("webview navigation timeout")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type navigationState struct {
|
|
URL string `json:"url"`
|
|
ReadyState string `json:"readyState"`
|
|
}
|
|
|
|
func (s navigationState) ready(targetURL string, waitUntil string) bool {
|
|
if s.URL == "" || s.URL == "about:blank" {
|
|
return false
|
|
}
|
|
switch waitUntil {
|
|
case "domcontentloaded":
|
|
if s.ReadyState == "loading" || s.ReadyState == "" {
|
|
return false
|
|
}
|
|
default:
|
|
if s.ReadyState != "complete" {
|
|
return false
|
|
}
|
|
}
|
|
if targetURL == "" {
|
|
return true
|
|
}
|
|
if s.URL == targetURL {
|
|
return true
|
|
}
|
|
target, err := parseURL(targetURL)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
current, err := parseURL(s.URL)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
if current.Scheme == target.Scheme && current.Host == target.Host && current.Path == target.Path {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseURL(raw string) (*url.URL, error) {
|
|
return url.Parse(raw)
|
|
}
|
|
|
|
func (p *pageWrapper) navigationState() (navigationState, error) {
|
|
value, err := p.Execute(`() => ({
|
|
url: String(location.href || ""),
|
|
readyState: document.readyState || "",
|
|
})`)
|
|
if err != nil {
|
|
return navigationState{}, err
|
|
}
|
|
stateMap, ok := value.(map[string]any)
|
|
if !ok {
|
|
return navigationState{}, fmt.Errorf("invalid navigation state")
|
|
}
|
|
state := navigationState{}
|
|
if urlValue, ok := stateMap["url"].(string); ok {
|
|
state.URL = urlValue
|
|
}
|
|
if readyValue, ok := stateMap["readyState"].(string); ok {
|
|
state.ReadyState = readyValue
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
func (p *pageWrapper) dispatch(fn func(w webview.WebView) error) error {
|
|
p.mu.Lock()
|
|
if p.closed {
|
|
p.mu.Unlock()
|
|
return fmt.Errorf("webview page is closed")
|
|
}
|
|
w := p.view
|
|
p.mu.Unlock()
|
|
if w == nil {
|
|
return fmt.Errorf("webview is not initialized")
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
w.Dispatch(func() {
|
|
done <- fn(w)
|
|
})
|
|
return <-done
|
|
}
|
|
|
|
func (p *pageWrapper) nativeCookies() ([]nativeCookieJSON, error) {
|
|
type cookieGetter interface {
|
|
GetCookies(url string) ([]webview.Cookie, error)
|
|
}
|
|
|
|
var cookies []webview.Cookie
|
|
err := p.dispatch(func(w webview.WebView) error {
|
|
getter, ok := w.(cookieGetter)
|
|
if !ok {
|
|
return fmt.Errorf("native cookie access is unavailable")
|
|
}
|
|
p.mu.Lock()
|
|
url := p.url
|
|
p.mu.Unlock()
|
|
if url == "" {
|
|
url = "about:blank"
|
|
}
|
|
var err error
|
|
cookies, err = getter.GetCookies(url)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]nativeCookieJSON, 0, len(cookies))
|
|
for _, cookie := range cookies {
|
|
item := nativeCookieJSON{
|
|
Name: cookie.Name,
|
|
Value: cookie.Value,
|
|
Domain: cookie.Domain,
|
|
Path: cookie.Path,
|
|
Secure: cookie.Secure,
|
|
HTTPOnly: cookie.HTTPOnly,
|
|
}
|
|
if !cookie.Expires.IsZero() {
|
|
item.Expires = cookie.Expires.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
result = append(result, item)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (p *pageWrapper) handleCallback(payload string) error {
|
|
var msg evalMessage
|
|
if err := json.Unmarshal([]byte(payload), &msg); err != nil {
|
|
return err
|
|
}
|
|
if msg.Ready && msg.ID == p.readyID {
|
|
select {
|
|
case p.ready <- nil:
|
|
default:
|
|
}
|
|
return nil
|
|
}
|
|
if msg.Load {
|
|
select {
|
|
case p.loads <- msg.URL:
|
|
default:
|
|
}
|
|
return nil
|
|
}
|
|
|
|
p.mu.Lock()
|
|
ch := p.pending[msg.ID]
|
|
delete(p.pending, msg.ID)
|
|
p.mu.Unlock()
|
|
if ch == nil {
|
|
return nil
|
|
}
|
|
|
|
if !msg.OK {
|
|
ch <- evalResult{err: errors.New(strings.TrimSpace(msg.Error))}
|
|
return nil
|
|
}
|
|
ch <- evalResult{value: msg.Value}
|
|
return nil
|
|
}
|
|
|
|
func (p *pageWrapper) drainLoads() {
|
|
for {
|
|
select {
|
|
case <-p.loads:
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func buildBootstrapHTML(callbackName string, readyID string) string {
|
|
return fmt.Sprintf(`<!doctype html>
|
|
<html>
|
|
<head><meta charset="utf-8"></head>
|
|
<body>
|
|
<script>
|
|
window.addEventListener("load", () => {
|
|
const callback = globalThis[%q];
|
|
if (typeof callback === "function") {
|
|
callback(JSON.stringify({ id: %q, ready: true }));
|
|
}
|
|
}, { once: true });
|
|
</script>
|
|
</body>
|
|
</html>`, callbackName, readyID)
|
|
}
|
|
|
|
func buildExecuteScript(callbackName string, reqID string, expression string, argsJSON string) string {
|
|
callbackJSON, _ := json.Marshal(callbackName)
|
|
reqJSON, _ := json.Marshal(reqID)
|
|
exprJSON, _ := json.Marshal(expression)
|
|
argsLiteral := argsJSON
|
|
if argsLiteral == "" || argsLiteral == "null" {
|
|
argsLiteral = "[]"
|
|
}
|
|
return fmt.Sprintf(`(() => {
|
|
const __cb = globalThis[%s];
|
|
const __id = %s;
|
|
const __expression = %s;
|
|
const __args = %s;
|
|
Promise.resolve()
|
|
.then(async () => {
|
|
const __target = (0, eval)(__expression);
|
|
const __value = await (typeof __target === "function" ? __target(...__args) : __target);
|
|
return { id: __id, ok: true, value: __value ?? null };
|
|
})
|
|
.catch((error) => ({
|
|
id: __id,
|
|
ok: false,
|
|
error: [
|
|
error && error.name ? error.name : "Error",
|
|
error && error.message ? error.message : String(error),
|
|
error && error.stack ? error.stack : "",
|
|
].filter(Boolean).join(": ").replace(": @", "\n@"),
|
|
}))
|
|
.then((message) => __cb(JSON.stringify(message)));
|
|
})();`, string(callbackJSON), string(reqJSON), string(exprJSON), argsLiteral)
|
|
}
|
|
|
|
func buildLoadNotifyScript(callbackName string) string {
|
|
callbackJSON, _ := json.Marshal(callbackName)
|
|
return fmt.Sprintf(`(() => {
|
|
window.addEventListener("load", () => {
|
|
const __cb = globalThis[%s];
|
|
if (typeof __cb !== "function") {
|
|
return;
|
|
}
|
|
__cb(JSON.stringify({
|
|
load: true,
|
|
url: String(location.href || ""),
|
|
}));
|
|
}, { once: true });
|
|
})();`, string(callbackJSON))
|
|
}
|
|
|
|
func defaultWindowDimension(value int, fallback int) int {
|
|
if value > 0 {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|