mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-12 11:24:08 +00:00
feat: add WebView support for extension (#1359)
This commit is contained in:
92 files changed
+10248
-2676
No files matched your search
@@ -144,7 +144,11 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
|
||||
}
|
||||
|
||||
func (d *Downloader) Setup() error {
|
||||
d.gblob = protogblob.NewRegistry(filepath.Join(d.cfg.StorageDir, protogblob.Scheme))
|
||||
gblobDir := filepath.Join(d.cfg.StorageDir, "gblob")
|
||||
if err := os.RemoveAll(gblobDir); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
d.gblob = protogblob.NewRegistry(gblobDir)
|
||||
|
||||
// setup storage
|
||||
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketProtocolState, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
|
||||
@@ -498,6 +502,11 @@ func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(req.URL), protogblob.Scheme+":") {
|
||||
if err = fetcher.Resolve(req, initOpt); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return d.doCreate(fetcher, initOpt)
|
||||
}
|
||||
|
||||
@@ -802,7 +811,7 @@ func (d *Downloader) Stats(id string) (sr any, err error) {
|
||||
}
|
||||
|
||||
func (d *Downloader) doDelete(task *Task, force bool) (err error) {
|
||||
defer d.unpinGBlobTask(task)
|
||||
defer d.releaseGBlobTask(task)
|
||||
err = func() error {
|
||||
if err := d.storage.Delete(bucketTask, task.ID); err != nil {
|
||||
return err
|
||||
@@ -1073,7 +1082,7 @@ func (d *Downloader) watch(task *Task) {
|
||||
d.emit(EventKeyDone, task)
|
||||
d.emit(EventKeyFinally, task, err)
|
||||
d.notifyRunning()
|
||||
d.unpinGBlobTask(task)
|
||||
d.releaseGBlobTask(task)
|
||||
d.triggerOnDone(task)
|
||||
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
d.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
@@ -1141,9 +1150,11 @@ func (d *Downloader) watch(task *Task) {
|
||||
func (d *Downloader) doOnError(task *Task, err error) {
|
||||
d.Logger.Warn().Err(err).Msgf("task download failed, task id: %s", task.ID)
|
||||
task.updateStatus(base.DownloadStatusError)
|
||||
d.unpinGBlobTask(task)
|
||||
d.triggerOnError(task, err)
|
||||
if task.Status == base.DownloadStatusError {
|
||||
if !d.isResumableGBlobTask(task) {
|
||||
d.releaseGBlobTask(task)
|
||||
}
|
||||
d.emit(EventKeyError, task, err)
|
||||
d.emit(EventKeyFinally, task, err)
|
||||
d.notifyRunning()
|
||||
@@ -1281,7 +1292,7 @@ func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error
|
||||
}
|
||||
|
||||
func (d *Downloader) doStart(task *Task) (err error) {
|
||||
var needCreate bool
|
||||
var isCreate bool
|
||||
isReturn, err := d.statusMut(task, func() (isReturn bool, err error) {
|
||||
if task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone {
|
||||
isReturn = true
|
||||
@@ -1293,7 +1304,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID)
|
||||
return
|
||||
}
|
||||
needCreate = !task.IsCreated
|
||||
isCreate = task.Status == base.DownloadStatusReady
|
||||
task.updateStatus(base.DownloadStatusRunning)
|
||||
|
||||
return
|
||||
@@ -1311,6 +1322,11 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
defer task.lock.Unlock()
|
||||
|
||||
d.triggerOnStart(task)
|
||||
if task.fetcher != nil {
|
||||
task.fetcher.Meta().Req = task.Meta.Req
|
||||
task.fetcher.Meta().Res = task.Meta.Res
|
||||
task.fetcher.Meta().Opts = task.Meta.Opts
|
||||
}
|
||||
if task.Meta.Res == nil {
|
||||
err := task.fetcher.Resolve(task.Meta.Req, task.Meta.Opts)
|
||||
if err != nil {
|
||||
@@ -1319,7 +1335,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
task.Meta.Res = task.fetcher.Meta().Res
|
||||
}
|
||||
|
||||
if needCreate {
|
||||
if isCreate {
|
||||
if task.fetcherManager.AutoRename() {
|
||||
d.checkDuplicateLock.Lock()
|
||||
defer d.checkDuplicateLock.Unlock()
|
||||
@@ -1343,9 +1359,10 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
task.Meta.Opts.Name = newName
|
||||
}
|
||||
}
|
||||
task.IsCreated = true
|
||||
|
||||
task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles)
|
||||
}
|
||||
|
||||
task.Progress.Speed = 0
|
||||
task.timer.Start()
|
||||
if err := task.fetcher.Start(); err != nil {
|
||||
@@ -1842,6 +1859,28 @@ func (d *Downloader) unpinGBlobTask(task *Task) {
|
||||
d.gblob.Unpin(task.Meta.Req.URL)
|
||||
}
|
||||
|
||||
func (d *Downloader) isResumableGBlobTask(task *Task) bool {
|
||||
if task == nil || task.Meta == nil || task.Meta.Res == nil {
|
||||
return false
|
||||
}
|
||||
return task.Protocol == protogblob.Scheme && task.Meta.Res.Range
|
||||
}
|
||||
|
||||
func (d *Downloader) revokeGBlobTask(task *Task) {
|
||||
if d.gblob == nil || task == nil || task.Meta == nil || task.Meta.Req == nil || task.Meta.Req.URL == "" {
|
||||
return
|
||||
}
|
||||
if task.Protocol != protogblob.Scheme {
|
||||
return
|
||||
}
|
||||
_ = d.gblob.Revoke(task.Meta.Req.URL)
|
||||
}
|
||||
|
||||
func (d *Downloader) releaseGBlobTask(task *Task) {
|
||||
d.revokeGBlobTask(task)
|
||||
d.unpinGBlobTask(task)
|
||||
}
|
||||
|
||||
var defaultDownloader = NewDownloader(nil)
|
||||
|
||||
type boot struct {
|
||||
|
||||
@@ -21,12 +21,14 @@ import (
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
)
|
||||
|
||||
var testDownloadOpt = &base.Options{
|
||||
Path: test.Dir,
|
||||
Name: test.DownloadName,
|
||||
Extra: http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
func newTestDownloadOpt() *base.Options {
|
||||
return &base.Options{
|
||||
Path: test.Dir,
|
||||
Name: test.DownloadName,
|
||||
Extra: http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_Resolve(t *testing.T) {
|
||||
@@ -61,6 +63,51 @@ func TestDownloader_Resolve(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_UsesStorageDirForGBlobRegistry(t *testing.T) {
|
||||
storageDir := t.TempDir()
|
||||
downloader := NewDownloader(&DownloaderConfig{
|
||||
Storage: NewMemStorage(),
|
||||
StorageDir: storageDir,
|
||||
})
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer downloader.Clear()
|
||||
|
||||
if downloader.gblob == nil {
|
||||
t.Fatal("expected gblob registry to be initialized")
|
||||
}
|
||||
expectedDir := filepath.Join(storageDir, "gblob")
|
||||
if filepath.Clean(downloader.gblob.Dir()) != filepath.Clean(expectedDir) {
|
||||
t.Fatalf("expected gblob registry dir under storage dir, got %s want %s", downloader.gblob.Dir(), expectedDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_SetupCleansStaleGBlobDir(t *testing.T) {
|
||||
storageDir := t.TempDir()
|
||||
gblobDir := filepath.Join(storageDir, "gblob")
|
||||
if err := os.MkdirAll(gblobDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staleFile := filepath.Join(gblobDir, "stale")
|
||||
if err := os.WriteFile(staleFile, []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
downloader := NewDownloader(&DownloaderConfig{
|
||||
Storage: NewMemStorage(),
|
||||
StorageDir: storageDir,
|
||||
})
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer downloader.Clear()
|
||||
|
||||
if _, err := os.Stat(staleFile); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected stale gblob file to be removed on setup, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_Create(t *testing.T) {
|
||||
listener := test.StartTestFileServer()
|
||||
defer listener.Close()
|
||||
@@ -73,7 +120,7 @@ func TestDownloader_Create(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, testDownloadOpt)
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -113,7 +160,7 @@ func TestDownloader_CreateNotInWhite(t *testing.T) {
|
||||
}
|
||||
// With new fetcher design, white list check happens during Resolve (not Create)
|
||||
// because Resolve now requires Options which includes the download path
|
||||
_, err := downloader.Resolve(req, testDownloadOpt)
|
||||
_, err := downloader.Resolve(req, newTestDownloadOpt())
|
||||
if err == nil {
|
||||
t.Error("TestDownloader_CreateNotInWhite() expected error but got nil")
|
||||
}
|
||||
@@ -162,7 +209,7 @@ func TestDownloader_CreateDirectBatch(t *testing.T) {
|
||||
|
||||
_, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{
|
||||
Reqs: reqs,
|
||||
Opts: testDownloadOpt,
|
||||
Opts: newTestDownloadOpt(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -266,6 +313,9 @@ func TestDownloader_CreateWithProxy(t *testing.T) {
|
||||
}
|
||||
|
||||
func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func(reqProxy *base.RequestProxy) *base.RequestProxy, buildProxyConfig func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig, errHandler func(err error)) {
|
||||
listener := test.StartTestFileServer()
|
||||
defer listener.Close()
|
||||
|
||||
usr, pwd := "", ""
|
||||
if auth {
|
||||
usr, pwd = "admin", "123"
|
||||
@@ -291,7 +341,7 @@ func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func
|
||||
downloader.cfg.DownloaderStoreConfig.Proxy = globalProxyCfg
|
||||
|
||||
req := &base.Request{
|
||||
URL: test.ExternalDownloadUrl,
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
if buildReqProxy != nil {
|
||||
req.Proxy = buildReqProxy(&base.RequestProxy{
|
||||
@@ -310,13 +360,13 @@ func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func
|
||||
return
|
||||
}
|
||||
want := &base.Resource{
|
||||
Size: test.ExternalDownloadSize,
|
||||
Size: test.BuildSize,
|
||||
Range: true,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: test.ExternalDownloadName,
|
||||
Name: test.BuildName,
|
||||
Path: "",
|
||||
Size: test.ExternalDownloadSize,
|
||||
Size: test.BuildSize,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -384,7 +434,7 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, testDownloadOpt)
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -653,7 +703,7 @@ func TestDownloader_Stats(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, testDownloadOpt)
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -708,7 +758,7 @@ func TestDownloader_Delete(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
taskId, err := downloader.CreateDirect(req, testDownloadOpt)
|
||||
taskId, err := downloader.CreateDirect(req, newTestDownloadOpt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -772,7 +822,7 @@ func TestDownloader_PauseAndContinue(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, testDownloadOpt)
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -843,7 +893,7 @@ func TestDownloader_PauseAllAndContinueAll(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, testDownloadOpt)
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+107
-43
@@ -3,6 +3,8 @@ package engine
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||
@@ -14,7 +16,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/eventloop"
|
||||
gojaurl "github.com/dop251/goja_nodejs/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed polyfill/out/index.js
|
||||
@@ -26,6 +27,8 @@ type Engine struct {
|
||||
Runtime *goja.Runtime
|
||||
}
|
||||
|
||||
type JSFunction func(goja.FunctionCall) goja.Value
|
||||
|
||||
// RunString executes the script and returns the go type value
|
||||
// if script result is promise, it will be resolved
|
||||
func (e *Engine) RunString(script string) (value any, err error) {
|
||||
@@ -36,37 +39,88 @@ func (e *Engine) RunString(script string) (value any, err error) {
|
||||
|
||||
// CallFunction calls the function and returns the go type value
|
||||
// if function result is promise, it will be resolved
|
||||
func (e *Engine) CallFunction(fn goja.Callable, args ...any) (value any, err error) {
|
||||
func (e *Engine) CallFunction(fn any, args ...any) (value any, err error) {
|
||||
return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) {
|
||||
if args == nil {
|
||||
return fn(nil)
|
||||
}
|
||||
var jsArgs []goja.Value
|
||||
for _, arg := range args {
|
||||
jsArgs = append(jsArgs, runtime.ToValue(arg))
|
||||
}
|
||||
return fn(nil, jsArgs...)
|
||||
switch f := fn.(type) {
|
||||
case goja.Callable:
|
||||
if args == nil {
|
||||
return f(nil)
|
||||
}
|
||||
return f(nil, jsArgs...)
|
||||
case JSFunction:
|
||||
return callExportedFunction(func(call goja.FunctionCall) goja.Value {
|
||||
return f(call)
|
||||
}, jsArgs...)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported function type: %T", fn)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Engine) runOnLoop(fn func(runtime *goja.Runtime) (goja.Value, error)) (any, error) {
|
||||
type result struct {
|
||||
value goja.Value
|
||||
value any
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
ok := e.loop.RunOnLoop(func(runtime *goja.Runtime) {
|
||||
var once sync.Once
|
||||
sendResult := func(res result) {
|
||||
once.Do(func() {
|
||||
ch <- res
|
||||
})
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if err, ok := r.(error); ok {
|
||||
ch <- result{err: err}
|
||||
return
|
||||
switch v := r.(type) {
|
||||
case error:
|
||||
sendResult(result{err: v})
|
||||
case goja.Value:
|
||||
sendResult(result{err: exportJSError(v)})
|
||||
default:
|
||||
sendResult(result{err: fmt.Errorf("panic: %v", r)})
|
||||
}
|
||||
ch <- result{err: errors.New("panic")}
|
||||
}
|
||||
}()
|
||||
value, err := fn(runtime)
|
||||
ch <- result{value: value, err: err}
|
||||
if err != nil {
|
||||
sendResult(result{err: err})
|
||||
return
|
||||
}
|
||||
if p, ok := value.Export().(*goja.Promise); ok {
|
||||
switch p.State() {
|
||||
case goja.PromiseStateFulfilled:
|
||||
sendResult(result{value: exportJSValue(p.Result())})
|
||||
return
|
||||
case goja.PromiseStateRejected:
|
||||
sendResult(result{err: exportJSError(p.Result())})
|
||||
return
|
||||
}
|
||||
promiseObj := value.ToObject(runtime)
|
||||
thenVal := promiseObj.Get("then")
|
||||
thenFn, ok := goja.AssertFunction(thenVal)
|
||||
if !ok {
|
||||
sendResult(result{err: errors.New("promise.then is not callable")})
|
||||
return
|
||||
}
|
||||
onFulfilled := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||
sendResult(result{value: exportJSValue(call.Argument(0))})
|
||||
return goja.Undefined()
|
||||
})
|
||||
onRejected := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||
sendResult(result{err: exportJSError(call.Argument(0))})
|
||||
return goja.Undefined()
|
||||
})
|
||||
if _, err := thenFn(promiseObj, onFulfilled, onRejected); err != nil {
|
||||
sendResult(result{err: err})
|
||||
}
|
||||
return
|
||||
}
|
||||
sendResult(result{value: exportJSValue(value)})
|
||||
})
|
||||
if !ok {
|
||||
return nil, errors.New("engine loop terminated")
|
||||
@@ -75,7 +129,7 @@ func (e *Engine) runOnLoop(fn func(runtime *goja.Runtime) (goja.Value, error)) (
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
return resolveResult(res.value)
|
||||
return res.value, nil
|
||||
}
|
||||
|
||||
func (e *Engine) Close() {
|
||||
@@ -130,7 +184,7 @@ func NewEngine(cfg *Config) *Engine {
|
||||
if _, err := runtime.RunString("global.location = new URL('http://localhost');"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := stream.Enable(runtime, cfg.StreamConfig); err != nil {
|
||||
if err := stream.Enable(runtime, loop, cfg.StreamConfig); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
@@ -144,35 +198,45 @@ func Run(script string) (value any, err error) {
|
||||
return engine.RunString(script)
|
||||
}
|
||||
|
||||
// if the value is Promise, it will be resolved and return the result.
|
||||
func resolveResult(value goja.Value) (any, error) {
|
||||
export := value.Export()
|
||||
switch export.(type) {
|
||||
case *goja.Promise:
|
||||
p := export.(*goja.Promise)
|
||||
for p.State() == goja.PromiseStatePending {
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
}
|
||||
switch p.State() {
|
||||
case goja.PromiseStatePending:
|
||||
return nil, nil
|
||||
case goja.PromiseStateFulfilled:
|
||||
return p.Result().Export(), nil
|
||||
case goja.PromiseStateRejected:
|
||||
if err, ok := p.Result().Export().(error); ok {
|
||||
return nil, err
|
||||
} else {
|
||||
stack := p.Result().String()
|
||||
result := p.Result()
|
||||
if ro, ok := result.(*goja.Object); ok {
|
||||
stackVal := ro.Get("stack")
|
||||
if stackVal != nil && stackVal.String() != "" {
|
||||
stack = stackVal.String()
|
||||
}
|
||||
}
|
||||
return nil, errors.New(stack)
|
||||
}
|
||||
func exportJSValue(value goja.Value) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return value.Export()
|
||||
}
|
||||
|
||||
func exportJSError(value goja.Value) error {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return errors.New("promise rejected")
|
||||
}
|
||||
if err, ok := value.Export().(error); ok {
|
||||
return err
|
||||
}
|
||||
stack := value.String()
|
||||
if ro, ok := value.(*goja.Object); ok {
|
||||
stackVal := ro.Get("stack")
|
||||
if stackVal != nil && stackVal.String() != "" {
|
||||
stack = stackVal.String()
|
||||
}
|
||||
}
|
||||
return export, nil
|
||||
return errors.New(stack)
|
||||
}
|
||||
|
||||
func callExportedFunction(fn func(goja.FunctionCall) goja.Value, args ...goja.Value) (ret goja.Value, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
switch v := r.(type) {
|
||||
case error:
|
||||
err = v
|
||||
case goja.Value:
|
||||
err = exportJSError(v)
|
||||
default:
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return fn(goja.FunctionCall{
|
||||
This: nil,
|
||||
Arguments: args,
|
||||
}), nil
|
||||
}
|
||||
@@ -26,7 +26,11 @@ func TestPolyfill(t *testing.T) {
|
||||
doTestPolyfill(t, "MessageError")
|
||||
doTestPolyfill(t, "XMLHttpRequest")
|
||||
doTestPolyfill(t, "Blob")
|
||||
doTestPolyfill(t, "Event")
|
||||
doTestPolyfill(t, "EventTarget")
|
||||
doTestPolyfill(t, "FormData")
|
||||
doTestPolyfill(t, "AbortController")
|
||||
doTestPolyfill(t, "AbortSignal")
|
||||
doTestPolyfill(t, "TextDecoder")
|
||||
doTestPolyfill(t, "TextEncoder")
|
||||
doTestPolyfill(t, "fetch")
|
||||
@@ -36,13 +40,90 @@ func TestPolyfill(t *testing.T) {
|
||||
func TestError(t *testing.T) {
|
||||
engine := NewEngine(nil)
|
||||
_, err := engine.RunString(`
|
||||
throw new MessageError('test');
|
||||
throw new MessageError('test');
|
||||
`)
|
||||
if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); !ok {
|
||||
t.Fatalf("expect MessageError, but got %v", me)
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLCreateObjectURLRejectsUnsupportedType(t *testing.T) {
|
||||
engine := NewEngine(nil)
|
||||
defer engine.Close()
|
||||
|
||||
_, err := engine.RunString(`
|
||||
URL.createObjectURL({});
|
||||
`)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported type error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Unsupported object type for URL.createObjectURL: Object") {
|
||||
t.Fatalf("unexpected unsupported type error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallFunction_DetachedAsyncWorkDoesNotBlockReturn(t *testing.T) {
|
||||
engine := NewEngine(nil)
|
||||
defer engine.Close()
|
||||
if _, err := engine.RunString(`
|
||||
globalThis.__done = false;
|
||||
async function testDetached() {
|
||||
(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
globalThis.__done = true;
|
||||
})();
|
||||
return "ok";
|
||||
}
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fn, ok := goja.AssertFunction(engine.Runtime.Get("testDetached"))
|
||||
if !ok {
|
||||
t.Fatal("testDetached is not callable")
|
||||
}
|
||||
startedAt := time.Now()
|
||||
value, err := engine.CallFunction(fn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
elapsed := time.Since(startedAt)
|
||||
if elapsed > 200*time.Millisecond {
|
||||
t.Fatalf("detached async work blocked return for %s", elapsed)
|
||||
}
|
||||
if value != "ok" {
|
||||
t.Fatalf("unexpected return value: %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallFunction_AwaitedPromiseBlocksUntilSettled(t *testing.T) {
|
||||
engine := NewEngine(nil)
|
||||
defer engine.Close()
|
||||
if _, err := engine.RunString(`
|
||||
async function testAwaited() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
return "ok";
|
||||
}
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fn, ok := goja.AssertFunction(engine.Runtime.Get("testAwaited"))
|
||||
if !ok {
|
||||
t.Fatal("testAwaited is not callable")
|
||||
}
|
||||
startedAt := time.Now()
|
||||
value, err := engine.CallFunction(fn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
elapsed := time.Since(startedAt)
|
||||
if elapsed < 100*time.Millisecond {
|
||||
t.Fatalf("awaited promise returned too early: %s", elapsed)
|
||||
}
|
||||
if value != "ok" {
|
||||
t.Fatalf("unexpected return value: %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch(t *testing.T) {
|
||||
server := startServer()
|
||||
defer server.Close()
|
||||
@@ -64,6 +145,50 @@ async function testText(){
|
||||
return await resp.text();
|
||||
}
|
||||
|
||||
async function testResponseBodyGetReader() {
|
||||
const resp = await fetch(host+'/text', {
|
||||
method: 'POST',
|
||||
body: 'stream-body'
|
||||
});
|
||||
if (!resp.body || typeof resp.body.getReader !== 'function') {
|
||||
throw new Error('response.body.getReader is not available');
|
||||
}
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let result = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
result += decoder.decode(value, { stream: true });
|
||||
}
|
||||
result += decoder.decode();
|
||||
return result;
|
||||
}
|
||||
|
||||
async function testStreamingFetchResponseBody() {
|
||||
const startedAt = Date.now();
|
||||
const resp = await fetch(host+'/stream');
|
||||
const resolvedAt = Date.now() - startedAt;
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const first = await reader.read();
|
||||
const firstAt = Date.now() - startedAt;
|
||||
const second = await reader.read();
|
||||
const secondAt = Date.now() - startedAt;
|
||||
let result = '';
|
||||
result += decoder.decode(first.value, { stream: true });
|
||||
result += decoder.decode(second.value, { stream: true });
|
||||
result += decoder.decode();
|
||||
return {
|
||||
resolvedAt,
|
||||
firstAt,
|
||||
secondAt,
|
||||
text: result
|
||||
};
|
||||
}
|
||||
|
||||
async function testOctetStream(file){
|
||||
const resp = await fetch(host+'/octetStream',{
|
||||
method: 'POST',
|
||||
@@ -251,6 +376,32 @@ async function testFingerprintSafari(){
|
||||
t.Fatalf("testText failed, want %s, got %s", "test", result)
|
||||
}
|
||||
|
||||
result, err = callTestFun(engine, "testResponseBodyGetReader")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != "stream-body" {
|
||||
t.Fatalf("testResponseBodyGetReader failed, want %s, got %s", "stream-body", result)
|
||||
}
|
||||
|
||||
result, err = callTestFun(engine, "testStreamingFetchResponseBody")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
streaming := result.(map[string]any)
|
||||
if streaming["text"] != "chunk-1chunk-2" {
|
||||
t.Fatalf("testStreamingFetchResponseBody failed, want %s, got %v", "chunk-1chunk-2", streaming["text"])
|
||||
}
|
||||
if int(streaming["resolvedAt"].(int64)) >= 120 {
|
||||
t.Fatalf("fetch resolved too late, got %dms", streaming["resolvedAt"].(int64))
|
||||
}
|
||||
if int(streaming["firstAt"].(int64)) >= 120 {
|
||||
t.Fatalf("first chunk arrived too late, got %dms", streaming["firstAt"].(int64))
|
||||
}
|
||||
if int(streaming["secondAt"].(int64)) < 120 {
|
||||
t.Fatalf("second chunk arrived too early, got %dms", streaming["secondAt"].(int64))
|
||||
}
|
||||
|
||||
func() {
|
||||
jsFile, _, md5 := buildFile(t, engine.Runtime)
|
||||
result, err = callTestFun(engine, "testOctetStream", jsFile)
|
||||
@@ -464,6 +615,18 @@ func startServer() net.Listener {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(buf)
|
||||
})
|
||||
mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
_, _ = w.Write([]byte("chunk-1"))
|
||||
flusher.Flush()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_, _ = w.Write([]byte("chunk-2"))
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("chunk-1chunk-2"))
|
||||
})
|
||||
mux.HandleFunc("/octetStream", func(w http.ResponseWriter, r *http.Request) {
|
||||
md5 := calcMd5(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
protogblob "github.com/GopeedLab/gopeed/internal/protocol/gblob"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/xhr"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/eventloop"
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
//go:embed stream.js
|
||||
@@ -12,36 +28,57 @@ var script string
|
||||
|
||||
type Config struct {
|
||||
CreateBlobObjectURL func(data []byte, contentType string) (string, error)
|
||||
CreateWritableStreamObjectURL func() (string, error)
|
||||
CreateWritableStreamObjectURL func(opts *WritableStreamObjectURLOptions) (string, error)
|
||||
RegisterWritableStreamResume func(url string, reopen func(offset int64) error) error
|
||||
WriteWritableStreamObjectURL func(url string, data any) error
|
||||
CloseWritableStreamObjectURL func(url string) error
|
||||
AbortWritableStreamObjectURL func(url string, reason string) error
|
||||
RevokeObjectURL func(url string) error
|
||||
ProxyHandler func(r *http.Request) (*url.URL, error)
|
||||
}
|
||||
|
||||
func Enable(runtime *goja.Runtime, cfg *Config) error {
|
||||
type WritableStreamObjectURLOptions struct {
|
||||
Reopenable bool
|
||||
}
|
||||
|
||||
func isIgnorableGBlobError(err error) bool {
|
||||
return errors.Is(err, protogblob.ErrSourceRevoked) ||
|
||||
errors.Is(err, protogblob.ErrSourceNotFound) ||
|
||||
errors.Is(err, protogblob.ErrSourceClosed) ||
|
||||
errors.Is(err, protogblob.ErrSourceAborted)
|
||||
}
|
||||
|
||||
func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error {
|
||||
if cfg == nil {
|
||||
cfg = &Config{}
|
||||
}
|
||||
if err := runtime.Set("__gopeed_create_blob_object_url", func(data []byte, contentType string) string {
|
||||
if cfg.CreateBlobObjectURL == nil {
|
||||
panic(fmt.Errorf("gblob blob object url handler not configured"))
|
||||
panic(runtime.NewGoError(fmt.Errorf("gblob blob object url handler not configured")))
|
||||
}
|
||||
url, err := cfg.CreateBlobObjectURL(data, contentType)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
return url
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_create_writable_stream_object_url", func() string {
|
||||
if err := runtime.Set("__gopeed_create_writable_stream_object_url", func(reopenable bool) string {
|
||||
if cfg.CreateWritableStreamObjectURL == nil {
|
||||
panic(fmt.Errorf("gblob writable stream object url handler not configured"))
|
||||
panic(runtime.NewGoError(fmt.Errorf("gblob writable stream object url handler not configured")))
|
||||
}
|
||||
url, err := cfg.CreateWritableStreamObjectURL()
|
||||
opts := &WritableStreamObjectURLOptions{Reopenable: reopenable}
|
||||
url, err := cfg.CreateWritableStreamObjectURL(opts)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
if opts.Reopenable && cfg.RegisterWritableStreamResume != nil {
|
||||
if err := cfg.RegisterWritableStreamResume(url, func(offset int64) error {
|
||||
return openWritableStreamObjectURL(loop, url, offset)
|
||||
}); err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
}
|
||||
return url
|
||||
}); err != nil {
|
||||
@@ -49,44 +86,413 @@ func Enable(runtime *goja.Runtime, cfg *Config) error {
|
||||
}
|
||||
if err := runtime.Set("__gopeed_write_writable_stream_object_url", func(url string, data any) {
|
||||
if cfg.WriteWritableStreamObjectURL == nil {
|
||||
panic(fmt.Errorf("gblob writable stream write handler not configured"))
|
||||
panic(runtime.NewGoError(fmt.Errorf("gblob writable stream write handler not configured")))
|
||||
}
|
||||
if err := cfg.WriteWritableStreamObjectURL(url, data); err != nil {
|
||||
panic(err)
|
||||
if isIgnorableGBlobError(err) {
|
||||
return
|
||||
}
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_close_writable_stream_object_url", func(url string) {
|
||||
if cfg.CloseWritableStreamObjectURL == nil {
|
||||
panic(fmt.Errorf("gblob writable stream close handler not configured"))
|
||||
panic(runtime.NewGoError(fmt.Errorf("gblob writable stream close handler not configured")))
|
||||
}
|
||||
if err := cfg.CloseWritableStreamObjectURL(url); err != nil {
|
||||
panic(err)
|
||||
if isIgnorableGBlobError(err) {
|
||||
return
|
||||
}
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_abort_writable_stream_object_url", func(url string, reason string) {
|
||||
if cfg.AbortWritableStreamObjectURL == nil {
|
||||
panic(fmt.Errorf("gblob writable stream abort handler not configured"))
|
||||
panic(runtime.NewGoError(fmt.Errorf("gblob writable stream abort handler not configured")))
|
||||
}
|
||||
if err := cfg.AbortWritableStreamObjectURL(url, reason); err != nil {
|
||||
panic(err)
|
||||
if isIgnorableGBlobError(err) {
|
||||
return
|
||||
}
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_revoke_object_url", func(url string) {
|
||||
if cfg.RevokeObjectURL == nil {
|
||||
panic(fmt.Errorf("gblob revoke handler not configured"))
|
||||
panic(runtime.NewGoError(fmt.Errorf("gblob revoke handler not configured")))
|
||||
}
|
||||
if err := cfg.RevokeObjectURL(url); err != nil {
|
||||
panic(err)
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
fetchRegistry := newFetchRegistry()
|
||||
if err := runtime.Set("__gopeed_fetch_open", func(call goja.FunctionCall) goja.Value {
|
||||
reqMeta, err := exportFetchRequest(runtime, call.Argument(0))
|
||||
if err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
meta, err := fetchRegistry.Open(runtime, cfg.ProxyHandler, reqMeta)
|
||||
if err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
return runtime.ToValue(meta)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_fetch_read", func(call goja.FunctionCall) goja.Value {
|
||||
id := call.Argument(0).String()
|
||||
chunkSize := int(call.Argument(1).ToInteger())
|
||||
chunk, done, err := fetchRegistry.Read(id, chunkSize)
|
||||
if err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
if done {
|
||||
return goja.Null()
|
||||
}
|
||||
return runtime.ToValue(runtime.NewArrayBuffer(chunk))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_fetch_close", func(id string) {
|
||||
fetchRegistry.Close(id)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_fetch_abort", func(id string, reason string) {
|
||||
fetchRegistry.Abort(id, reason)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := runtime.RunString(script)
|
||||
return err
|
||||
}
|
||||
|
||||
func openWritableStreamObjectURL(loop *eventloop.EventLoop, url string, offset int64) error {
|
||||
type result struct {
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
ok := loop.RunOnLoop(func(runtime *goja.Runtime) {
|
||||
send := func(err error) {
|
||||
select {
|
||||
case ch <- result{err: err}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
switch v := r.(type) {
|
||||
case error:
|
||||
send(v)
|
||||
case goja.Value:
|
||||
send(exportJSError(v))
|
||||
default:
|
||||
send(fmt.Errorf("panic: %v", r))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
fnVal := runtime.Get("__gopeed_open_writable_stream_object_url")
|
||||
fn, ok := goja.AssertFunction(fnVal)
|
||||
if !ok {
|
||||
send(fmt.Errorf("gblob writable stream open helper is not callable"))
|
||||
return
|
||||
}
|
||||
value, err := fn(nil, runtime.ToValue(url), runtime.ToValue(offset))
|
||||
if err != nil {
|
||||
send(err)
|
||||
return
|
||||
}
|
||||
if promise, ok := value.Export().(*goja.Promise); ok {
|
||||
switch promise.State() {
|
||||
case goja.PromiseStateFulfilled:
|
||||
send(nil)
|
||||
return
|
||||
case goja.PromiseStateRejected:
|
||||
send(exportJSError(promise.Result()))
|
||||
return
|
||||
default:
|
||||
send(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
send(nil)
|
||||
})
|
||||
if !ok {
|
||||
return errors.New("engine loop terminated")
|
||||
}
|
||||
return (<-ch).err
|
||||
}
|
||||
|
||||
func exportJSError(value goja.Value) error {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return errors.New("promise rejected")
|
||||
}
|
||||
if err, ok := value.Export().(error); ok {
|
||||
return err
|
||||
}
|
||||
stack := value.String()
|
||||
if ro, ok := value.(*goja.Object); ok {
|
||||
stackVal := ro.Get("stack")
|
||||
if stackVal != nil && stackVal.String() != "" {
|
||||
stack = stackVal.String()
|
||||
}
|
||||
}
|
||||
return errors.New(stack)
|
||||
}
|
||||
|
||||
type fetchRegistry struct {
|
||||
mu sync.Mutex
|
||||
streams map[string]*fetchStream
|
||||
}
|
||||
|
||||
type fetchStream struct {
|
||||
body io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type fetchRequest struct {
|
||||
URL string
|
||||
Method string
|
||||
Headers [][2]string
|
||||
Body any
|
||||
Redirect string
|
||||
Credentials string
|
||||
}
|
||||
|
||||
type fetchOpenMeta struct {
|
||||
ID string `json:"id"`
|
||||
Status int `json:"status"`
|
||||
StatusText string `json:"statusText"`
|
||||
URL string `json:"url"`
|
||||
Headers [][2]string `json:"headers"`
|
||||
}
|
||||
|
||||
func newFetchRegistry() *fetchRegistry {
|
||||
return &fetchRegistry{
|
||||
streams: make(map[string]*fetchStream),
|
||||
}
|
||||
}
|
||||
|
||||
func exportFetchRequest(runtime *goja.Runtime, value goja.Value) (*fetchRequest, error) {
|
||||
obj := value.ToObject(runtime)
|
||||
if obj == nil {
|
||||
return nil, fmt.Errorf("invalid fetch request")
|
||||
}
|
||||
meta := &fetchRequest{
|
||||
URL: obj.Get("url").String(),
|
||||
Method: obj.Get("method").String(),
|
||||
Redirect: obj.Get("redirect").String(),
|
||||
Credentials: obj.Get("credentials").String(),
|
||||
}
|
||||
if meta.Method == "" {
|
||||
meta.Method = http.MethodGet
|
||||
}
|
||||
if headersVal := obj.Get("headers"); headersVal != nil && !goja.IsUndefined(headersVal) && !goja.IsNull(headersVal) {
|
||||
if exported, ok := headersVal.Export().([]any); ok {
|
||||
for _, item := range exported {
|
||||
pair, ok := item.([]any)
|
||||
if !ok || len(pair) != 2 {
|
||||
continue
|
||||
}
|
||||
meta.Headers = append(meta.Headers, [2]string{fmt.Sprint(pair[0]), fmt.Sprint(pair[1])})
|
||||
}
|
||||
}
|
||||
}
|
||||
bodyVal := obj.Get("body")
|
||||
if bodyVal != nil && !goja.IsUndefined(bodyVal) && !goja.IsNull(bodyVal) {
|
||||
meta.Body = bodyVal.Export()
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) Open(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL, error), reqMeta *fetchRequest) (*fetchOpenMeta, error) {
|
||||
client := req.C()
|
||||
if proxyHandler != nil {
|
||||
client.SetProxy(proxyHandler)
|
||||
}
|
||||
setFetchFingerprint(client, util.SafeGet[string](runtime, xhr.FingerprintMagicKey))
|
||||
contentType, body, err := buildFetchBody(reqMeta.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
reqBuilder := client.R()
|
||||
reqBuilder.SetContext(ctx)
|
||||
reqBuilder.DisableAutoReadResponse()
|
||||
for _, header := range reqMeta.Headers {
|
||||
reqBuilder.SetHeader(header[0], header[1])
|
||||
}
|
||||
if body != nil && reqMeta.Method != http.MethodGet && reqMeta.Method != http.MethodHead {
|
||||
reqBuilder.SetBody(body)
|
||||
if contentType != "" && !hasHeader(reqMeta.Headers, "Content-Type") {
|
||||
reqBuilder.SetHeader("Content-Type", contentType)
|
||||
}
|
||||
}
|
||||
client.SetRedirectPolicy(func(req *http.Request, via []*http.Request) error {
|
||||
switch reqMeta.Redirect {
|
||||
case "manual":
|
||||
return http.ErrUseLastResponse
|
||||
case "error":
|
||||
return fmt.Errorf("redirect failed")
|
||||
default:
|
||||
if len(via) > 20 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
})
|
||||
resp, err := reqBuilder.Send(reqMeta.Method, reqMeta.URL)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errorsAsTimeout(err, &ne) {
|
||||
return nil, fmt.Errorf("Network request timed out")
|
||||
}
|
||||
return nil, fmt.Errorf("Network request failed: %w", err)
|
||||
}
|
||||
id := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
meta := &fetchOpenMeta{
|
||||
ID: id,
|
||||
Status: resp.StatusCode,
|
||||
StatusText: resp.Status,
|
||||
URL: reqMeta.URL,
|
||||
}
|
||||
if resp.Response != nil && resp.Response.Request != nil && resp.Response.Request.URL != nil {
|
||||
responseURL := *resp.Response.Request.URL
|
||||
responseURL.Fragment = ""
|
||||
meta.URL = responseURL.String()
|
||||
}
|
||||
for key, values := range resp.Header {
|
||||
meta.Headers = append(meta.Headers, [2]string{key, strings.Join(values, ", ")})
|
||||
}
|
||||
bodyCloser := io.NopCloser(strings.NewReader(""))
|
||||
if resp.Response != nil && resp.Response.Body != nil {
|
||||
bodyCloser = resp.Response.Body
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.streams[id] = &fetchStream{body: bodyCloser, cancel: cancel}
|
||||
r.mu.Unlock()
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) Read(id string, chunkSize int) ([]byte, bool, error) {
|
||||
stream := r.get(id)
|
||||
if stream == nil {
|
||||
return nil, true, nil
|
||||
}
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 64 * 1024
|
||||
}
|
||||
buf := make([]byte, chunkSize)
|
||||
n, err := stream.body.Read(buf)
|
||||
if n > 0 {
|
||||
return buf[:n], false, nil
|
||||
}
|
||||
if err == io.EOF {
|
||||
r.Close(id)
|
||||
return nil, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
r.Close(id)
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) Close(id string) {
|
||||
r.mu.Lock()
|
||||
stream := r.streams[id]
|
||||
delete(r.streams, id)
|
||||
r.mu.Unlock()
|
||||
if stream != nil {
|
||||
stream.closeOnce.Do(func() {
|
||||
if stream.cancel != nil {
|
||||
stream.cancel()
|
||||
}
|
||||
_ = stream.body.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) Abort(id string, _ string) {
|
||||
r.Close(id)
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) get(id string) *fetchStream {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.streams[id]
|
||||
}
|
||||
|
||||
func buildFetchBody(body any) (string, any, error) {
|
||||
switch v := body.(type) {
|
||||
case nil:
|
||||
return "", nil, nil
|
||||
case string:
|
||||
return "text/plain;charset=UTF-8", v, nil
|
||||
case []byte:
|
||||
return "application/octet-stream", v, nil
|
||||
case goja.ArrayBuffer:
|
||||
return "application/octet-stream", v.Bytes(), nil
|
||||
case *file.File:
|
||||
return "application/octet-stream", v.Reader, nil
|
||||
case *formdata.FormData:
|
||||
pr, pw := io.Pipe()
|
||||
mw := xhr.NewMultipart(pw)
|
||||
for _, e := range v.Entries() {
|
||||
arr := e.([]any)
|
||||
key := arr[0].(string)
|
||||
val := arr[1]
|
||||
switch vv := val.(type) {
|
||||
case string:
|
||||
mw.WriteField(key, vv)
|
||||
case *file.File:
|
||||
mw.WriteFile(key, vv)
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
defer mw.Close()
|
||||
mw.Send()
|
||||
}()
|
||||
return mw.FormDataContentType(), pr, nil
|
||||
default:
|
||||
if typed, ok := v.(interface{ Bytes() []byte }); ok {
|
||||
return "application/octet-stream", typed.Bytes(), nil
|
||||
}
|
||||
return "", fmt.Sprint(v), nil
|
||||
}
|
||||
}
|
||||
|
||||
func hasHeader(headers [][2]string, key string) bool {
|
||||
for _, header := range headers {
|
||||
if strings.EqualFold(header[0], key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setFetchFingerprint(client *req.Client, fingerprint string) {
|
||||
switch fingerprint {
|
||||
case "chrome":
|
||||
client.ImpersonateChrome()
|
||||
case "firefox":
|
||||
client.ImpersonateFirefox()
|
||||
case "safari":
|
||||
client.ImpersonateSafari()
|
||||
}
|
||||
}
|
||||
|
||||
func errorsAsTimeout(err error, target *net.Error) bool {
|
||||
return errors.As(err, target) && (*target).Timeout()
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
const closeWritableObjectURL = globalThis.__gopeed_close_writable_stream_object_url;
|
||||
const abortWritableObjectURL = globalThis.__gopeed_abort_writable_stream_object_url;
|
||||
const revokeObjectURL = globalThis.__gopeed_revoke_object_url;
|
||||
const fetchOpen = globalThis.__gopeed_fetch_open;
|
||||
const fetchRead = globalThis.__gopeed_fetch_read;
|
||||
const fetchClose = globalThis.__gopeed_fetch_close;
|
||||
const fetchAbort = globalThis.__gopeed_fetch_abort;
|
||||
|
||||
if (typeof globalThis.ReadableStream === "undefined") {
|
||||
class ReadableStreamDefaultController {
|
||||
@@ -34,6 +38,13 @@
|
||||
return this._stream._read();
|
||||
}
|
||||
|
||||
cancel(reason) {
|
||||
if (!this._stream) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this._stream.cancel(reason);
|
||||
}
|
||||
|
||||
releaseLock() {
|
||||
if (this._stream) {
|
||||
this._stream._reader = null;
|
||||
@@ -45,15 +56,17 @@
|
||||
|
||||
class ReadableStream {
|
||||
constructor(source = {}) {
|
||||
this._source = source;
|
||||
this._queue = [];
|
||||
this._waiters = [];
|
||||
this._closed = false;
|
||||
this._errored = null;
|
||||
this._reader = null;
|
||||
this._pulling = false;
|
||||
this.locked = false;
|
||||
const controller = new ReadableStreamDefaultController(this);
|
||||
this._controller = new ReadableStreamDefaultController(this);
|
||||
if (typeof source.start === "function") {
|
||||
source.start(controller);
|
||||
source.start(this._controller);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +100,14 @@
|
||||
if (this._errored) {
|
||||
return Promise.reject(this._errored);
|
||||
}
|
||||
this._markBodyUsed();
|
||||
if (this._queue.length > 0) {
|
||||
return Promise.resolve({ done: false, value: this._queue.shift() });
|
||||
}
|
||||
if (this._closed) {
|
||||
return Promise.resolve({ done: true, value: undefined });
|
||||
}
|
||||
this._maybePull();
|
||||
if (this._queue.length > 0) {
|
||||
return Promise.resolve({ done: false, value: this._queue.shift() });
|
||||
}
|
||||
@@ -95,9 +116,39 @@
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
this._waiters.push({ resolve, reject });
|
||||
this._maybePull();
|
||||
});
|
||||
}
|
||||
|
||||
_maybePull() {
|
||||
if (this._closed || this._errored || this._pulling) {
|
||||
return;
|
||||
}
|
||||
if (this._queue.length > 0) {
|
||||
return;
|
||||
}
|
||||
if (!this._source || typeof this._source.pull !== "function") {
|
||||
return;
|
||||
}
|
||||
this._pulling = true;
|
||||
Promise.resolve(this._source.pull(this._controller))
|
||||
.catch((err) => {
|
||||
this._error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
this._pulling = false;
|
||||
if (!this._closed && !this._errored && this._queue.length === 0 && this._waiters.length > 0) {
|
||||
this._maybePull();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_markBodyUsed() {
|
||||
if (typeof this.__gopeedMarkBodyUsed === "function") {
|
||||
this.__gopeedMarkBodyUsed();
|
||||
}
|
||||
}
|
||||
|
||||
getReader() {
|
||||
if (this.locked) {
|
||||
throw new TypeError("ReadableStream is locked");
|
||||
@@ -107,6 +158,17 @@
|
||||
return this._reader;
|
||||
}
|
||||
|
||||
cancel(reason) {
|
||||
this._markBodyUsed();
|
||||
if (this._source && typeof this._source.cancel === "function") {
|
||||
return Promise.resolve(this._source.cancel(reason)).then(() => {
|
||||
this._close();
|
||||
});
|
||||
}
|
||||
this._close();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async pipeTo(dest) {
|
||||
const reader = this.getReader();
|
||||
const writer = dest.getWriter();
|
||||
@@ -293,44 +355,436 @@
|
||||
};
|
||||
}
|
||||
|
||||
function toUint8Array(chunk) {
|
||||
if (chunk == null) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
if (chunk instanceof Uint8Array) {
|
||||
return chunk;
|
||||
}
|
||||
if (typeof chunk === "string") {
|
||||
return new TextEncoder().encode(chunk);
|
||||
}
|
||||
if (chunk instanceof ArrayBuffer) {
|
||||
return new Uint8Array(chunk);
|
||||
}
|
||||
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView && ArrayBuffer.isView(chunk)) {
|
||||
return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
}
|
||||
if (typeof Blob !== "undefined" && chunk instanceof Blob) {
|
||||
if (chunk._buffer instanceof Uint8Array) {
|
||||
return chunk._buffer;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
function createBodyReadableStream(owner) {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
Promise.resolve().then(async () => {
|
||||
owner.bodyUsed = true;
|
||||
if (owner._noBody) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (owner._bodyArrayBuffer) {
|
||||
controller.enqueue(toUint8Array(owner._bodyArrayBuffer));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (owner._bodyBlob) {
|
||||
const data = await owner._bodyBlob.arrayBuffer();
|
||||
controller.enqueue(new Uint8Array(data));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (owner._bodyText != null) {
|
||||
controller.enqueue(toUint8Array(owner._bodyText));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (owner._bodyInit != null) {
|
||||
controller.enqueue(toUint8Array(owner._bodyInit));
|
||||
}
|
||||
controller.close();
|
||||
}).catch((err) => {
|
||||
controller.error(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function readAllFromStream(stream, asText) {
|
||||
if (!stream) {
|
||||
return asText ? "" : new Uint8Array(0);
|
||||
}
|
||||
const reader = stream.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
const chunk = toUint8Array(value);
|
||||
chunks.push(chunk);
|
||||
total += chunk.byteLength;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
if (asText) {
|
||||
return new TextDecoder().decode(merged);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function createStreamBackedBlob(stream, contentType, size) {
|
||||
const blob = new Blob([], { type: contentType || "" });
|
||||
if (Number.isFinite(size) && size >= 0) {
|
||||
try {
|
||||
Object.defineProperty(blob, "size", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: size,
|
||||
});
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
blob.__gopeedStreamBackedBlob = true;
|
||||
blob.__gopeedBodyStream = stream;
|
||||
blob.stream = function () {
|
||||
const bodyStream = this.__gopeedBodyStream;
|
||||
this.__gopeedBodyStream = null;
|
||||
if (bodyStream instanceof ReadableStream) {
|
||||
return bodyStream;
|
||||
}
|
||||
return new Blob([], { type: this.type || "" }).stream();
|
||||
};
|
||||
blob.arrayBuffer = async function () {
|
||||
const bytes = await readAllFromStream(this.stream(), false);
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
};
|
||||
blob.text = async function () {
|
||||
return readAllFromStream(this.stream(), true);
|
||||
};
|
||||
return blob;
|
||||
}
|
||||
|
||||
function attachResponseStreaming(response, stream) {
|
||||
response.__gopeedBodyStream = stream;
|
||||
response.__gopeedBodyConsumed = false;
|
||||
const ensureUnused = () => {
|
||||
if (response.__gopeedBodyConsumed) {
|
||||
throw new TypeError("Already read");
|
||||
}
|
||||
};
|
||||
const markBodyUsed = () => {
|
||||
ensureUnused();
|
||||
response.__gopeedBodyConsumed = true;
|
||||
response.bodyUsed = true;
|
||||
};
|
||||
if (stream) {
|
||||
stream.__gopeedMarkBodyUsed = () => {
|
||||
if (!response.__gopeedBodyConsumed) {
|
||||
response.__gopeedBodyConsumed = true;
|
||||
response.bodyUsed = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
response.text = async function () {
|
||||
ensureUnused();
|
||||
markBodyUsed();
|
||||
return readAllFromStream(stream, true);
|
||||
};
|
||||
response.arrayBuffer = async function () {
|
||||
ensureUnused();
|
||||
markBodyUsed();
|
||||
const bytes = await readAllFromStream(stream, false);
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
};
|
||||
response.blob = async function () {
|
||||
ensureUnused();
|
||||
markBodyUsed();
|
||||
const contentType = this.headers && this.headers.get ? (this.headers.get("content-type") || "") : "";
|
||||
const contentLength = this.headers && this.headers.get ? parseInt(this.headers.get("content-length") || "", 10) : NaN;
|
||||
return createStreamBackedBlob(stream, contentType, contentLength);
|
||||
};
|
||||
response.json = async function () {
|
||||
const text = await this.text();
|
||||
return JSON.parse(text);
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
if (typeof globalThis.Response === "function") {
|
||||
const responseProto = globalThis.Response.prototype;
|
||||
const bodyDescriptor = Object.getOwnPropertyDescriptor(responseProto, "body");
|
||||
if (!bodyDescriptor || typeof bodyDescriptor.get !== "function") {
|
||||
Object.defineProperty(responseProto, "body", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
if (this.__gopeedBodyStream) {
|
||||
return this.__gopeedBodyStream;
|
||||
}
|
||||
if (!this.__gopeedBodyStream) {
|
||||
this.__gopeedBodyStream = createBodyReadableStream(this);
|
||||
}
|
||||
return this.__gopeedBodyStream;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const originalCreateObjectURL = typeof URL.createObjectURL === "function"
|
||||
? URL.createObjectURL.bind(URL)
|
||||
: null;
|
||||
const originalRevokeObjectURL = typeof URL.revokeObjectURL === "function"
|
||||
? URL.revokeObjectURL.bind(URL)
|
||||
: null;
|
||||
const resumableReadableObjectURLs = new Map();
|
||||
const activeReadableObjectURLs = new Map();
|
||||
const readableObjectURLYieldBytes = 256 * 1024;
|
||||
|
||||
function isIgnorableGBlobObjectURLError(error) {
|
||||
const message = error == null ? "" : String(error && error.message ? error.message : error);
|
||||
return message.indexOf("gblob source revoked") >= 0 ||
|
||||
message.indexOf("gblob source not found") >= 0 ||
|
||||
message.indexOf("gblob source closed") >= 0 ||
|
||||
message.indexOf("gblob source aborted") >= 0;
|
||||
}
|
||||
|
||||
function getValueTypeName(value) {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
if (value === undefined) {
|
||||
return "undefined";
|
||||
}
|
||||
if (value && value.constructor && typeof value.constructor.name === "string" && value.constructor.name) {
|
||||
return value.constructor.name;
|
||||
}
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function toReadableStreamReader(value, sourceLabel) {
|
||||
if (value instanceof ReadableStream) {
|
||||
return value.getReader();
|
||||
}
|
||||
throw new TypeError(sourceLabel + " must return a ReadableStream, got " + getValueTypeName(value));
|
||||
}
|
||||
|
||||
function describeObjectURLValue(value) {
|
||||
if (value && value.__gopeedStreamBackedBlob) {
|
||||
const stream = value.__gopeedBodyStream;
|
||||
value.__gopeedBodyStream = null;
|
||||
if (stream instanceof ReadableStream) {
|
||||
return {
|
||||
kind: "readable",
|
||||
initialReadable: stream,
|
||||
openReadable: null,
|
||||
sourceLabel: "URL.createObjectURL Response blob stream",
|
||||
};
|
||||
}
|
||||
}
|
||||
if (value instanceof Blob) {
|
||||
return {
|
||||
kind: "blob",
|
||||
value,
|
||||
};
|
||||
}
|
||||
if (value instanceof ReadableStream) {
|
||||
return {
|
||||
kind: "readable",
|
||||
initialReadable: value,
|
||||
openReadable: null,
|
||||
sourceLabel: "URL.createObjectURL ReadableStream",
|
||||
};
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
return {
|
||||
kind: "opener",
|
||||
initialReadable: null,
|
||||
openReadable: value,
|
||||
sourceLabel: "URL.createObjectURL opener function",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "other",
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
function startReadableObjectURL(url, state) {
|
||||
activeReadableObjectURLs.set(url, state);
|
||||
setTimeout(() => {
|
||||
void pumpReadableObjectURL(url, state);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function releaseReader(reader, reason) {
|
||||
if (!reader) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (reason !== undefined && typeof reader.cancel === "function") {
|
||||
const result = reader.cancel(reason);
|
||||
if (result && typeof result.catch === "function") {
|
||||
result.catch(function () {});
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
if (typeof reader.releaseLock === "function") {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function releaseActiveReadable(url, reason) {
|
||||
const active = activeReadableObjectURLs.get(url);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
activeReadableObjectURLs.delete(url);
|
||||
active.cancelled = true;
|
||||
releaseReader(active.reader, reason);
|
||||
}
|
||||
|
||||
async function pumpReadableObjectURL(url, state) {
|
||||
let reader = null;
|
||||
try {
|
||||
let source;
|
||||
if (state.initialReadable) {
|
||||
source = state.initialReadable;
|
||||
state.initialReadable = null;
|
||||
} else {
|
||||
if (typeof state.openReadable !== "function") {
|
||||
throw new Error("gblob readable stream is not reopenable");
|
||||
}
|
||||
source = await state.openReadable(state.offset);
|
||||
}
|
||||
reader = toReadableStreamReader(source, state.sourceLabel);
|
||||
if (activeReadableObjectURLs.get(url) !== state || state.cancelled) {
|
||||
releaseReader(reader, "stale gblob producer");
|
||||
reader = null;
|
||||
return;
|
||||
}
|
||||
state.reader = reader;
|
||||
let bytesSinceYield = 0;
|
||||
while (!state.cancelled) {
|
||||
const current = activeReadableObjectURLs.get(url);
|
||||
if (current !== state) {
|
||||
releaseReader(reader, "stale gblob producer");
|
||||
reader = null;
|
||||
return;
|
||||
}
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
if (activeReadableObjectURLs.get(url) === state) {
|
||||
activeReadableObjectURLs.delete(url);
|
||||
await closeWritableObjectURL(url);
|
||||
}
|
||||
releaseReader(reader);
|
||||
reader = null;
|
||||
return;
|
||||
}
|
||||
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
|
||||
if (activeReadableObjectURLs.get(url) !== state || state.cancelled) {
|
||||
releaseReader(reader, "stale gblob producer");
|
||||
reader = null;
|
||||
return;
|
||||
}
|
||||
await writeWritableObjectURL(url, chunk);
|
||||
bytesSinceYield += chunk.byteLength;
|
||||
if (bytesSinceYield >= readableObjectURLYieldBytes) {
|
||||
bytesSinceYield = 0;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
releaseReader(reader, "gblob producer cancelled");
|
||||
reader = null;
|
||||
} catch (error) {
|
||||
if (isIgnorableGBlobObjectURLError(error)) {
|
||||
if (activeReadableObjectURLs.get(url) === state) {
|
||||
activeReadableObjectURLs.delete(url);
|
||||
}
|
||||
if (reader) {
|
||||
releaseReader(reader, "gblob source closed");
|
||||
reader = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (activeReadableObjectURLs.get(url) === state) {
|
||||
activeReadableObjectURLs.delete(url);
|
||||
try {
|
||||
await abortWritableObjectURL(url, error == null ? "" : String(error && error.message ? error.message : error));
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
if (reader) {
|
||||
releaseReader(reader, error);
|
||||
reader = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.__gopeed_open_writable_stream_object_url = function (url, offset) {
|
||||
const openReadable = resumableReadableObjectURLs.get(url);
|
||||
if (typeof openReadable !== "function") {
|
||||
throw new Error("gblob resumable opener not found");
|
||||
}
|
||||
releaseActiveReadable(url, "gblob source reopened");
|
||||
startReadableObjectURL(url, {
|
||||
initialReadable: null,
|
||||
openReadable,
|
||||
sourceLabel: "URL.createObjectURL opener function",
|
||||
offset: Number(offset) || 0,
|
||||
reader: null,
|
||||
cancelled: false,
|
||||
});
|
||||
};
|
||||
|
||||
URL.createObjectURL = function (value) {
|
||||
if (value instanceof Blob) {
|
||||
return createBlobObjectURL(value._buffer, value.type || "");
|
||||
const described = describeObjectURLValue(value);
|
||||
if (described.kind === "blob") {
|
||||
return createBlobObjectURL(described.value._buffer, described.value.type || "");
|
||||
}
|
||||
if (value instanceof WritableStream) {
|
||||
if (value.__gopeedObjectURL) {
|
||||
return value.__gopeedObjectURL;
|
||||
}
|
||||
const url = createWritableObjectURL();
|
||||
value._addObserver({
|
||||
write(chunk) {
|
||||
return Promise.resolve(writeWritableObjectURL(url, chunk));
|
||||
},
|
||||
close() {
|
||||
return Promise.resolve(closeWritableObjectURL(url));
|
||||
},
|
||||
abort(reason) {
|
||||
return Promise.resolve(abortWritableObjectURL(url, reason == null ? "" : String(reason)));
|
||||
}
|
||||
if (described.kind === "readable") {
|
||||
const url = createWritableObjectURL(false);
|
||||
startReadableObjectURL(url, {
|
||||
initialReadable: described.initialReadable,
|
||||
openReadable: null,
|
||||
sourceLabel: described.sourceLabel,
|
||||
offset: 0,
|
||||
reader: null,
|
||||
cancelled: false,
|
||||
});
|
||||
value.__gopeedObjectURL = url;
|
||||
return url;
|
||||
}
|
||||
if (originalCreateObjectURL) {
|
||||
return originalCreateObjectURL(value);
|
||||
if (described.kind === "opener") {
|
||||
const url = createWritableObjectURL(true);
|
||||
resumableReadableObjectURLs.set(url, described.openReadable);
|
||||
return url;
|
||||
}
|
||||
throw new TypeError("Unsupported object type");
|
||||
throw new TypeError("Unsupported object type for URL.createObjectURL: " + getValueTypeName(described.value) + ". Expected Blob, ReadableStream, or opener function");
|
||||
};
|
||||
|
||||
URL.revokeObjectURL = function (url) {
|
||||
if (typeof url === "string" && url.indexOf("gblob:") === 0) {
|
||||
resumableReadableObjectURLs.delete(url);
|
||||
releaseActiveReadable(url, "gblob source revoked");
|
||||
revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
@@ -338,4 +792,83 @@
|
||||
originalRevokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
const originalFetch = typeof globalThis.fetch === "function"
|
||||
? globalThis.fetch.bind(globalThis)
|
||||
: null;
|
||||
|
||||
if (typeof fetchOpen === "function") {
|
||||
globalThis.fetch = async function (input, init) {
|
||||
const request = new Request(input, init);
|
||||
if (originalFetch && (request.method === "HEAD" || request.redirect === "manual" || request.redirect === "error")) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
let body = null;
|
||||
if (request._bodyFormData) {
|
||||
body = request._bodyFormData;
|
||||
} else if (request._bodyArrayBuffer) {
|
||||
body = request._bodyArrayBuffer;
|
||||
} else if (request._bodyBlob) {
|
||||
body = await request._bodyBlob.arrayBuffer();
|
||||
} else if (request._bodyInit != null && typeof request._bodyInit === "object") {
|
||||
body = request._bodyInit;
|
||||
} else if (request._bodyText != null) {
|
||||
body = request._bodyText;
|
||||
} else if (request._bodyInit != null) {
|
||||
body = request._bodyInit;
|
||||
}
|
||||
const headers = [];
|
||||
request.headers.forEach((value, key) => {
|
||||
headers.push([key, value]);
|
||||
});
|
||||
let meta;
|
||||
try {
|
||||
meta = fetchOpen({
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
redirect: request.redirect,
|
||||
credentials: request.credentials
|
||||
});
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new TypeError(String(error));
|
||||
}
|
||||
const stream = new ReadableStream({
|
||||
pull(controller) {
|
||||
let chunk;
|
||||
try {
|
||||
chunk = fetchRead(meta.id, 64 * 1024);
|
||||
} catch (error) {
|
||||
fetchClose(meta.id);
|
||||
controller.error(error instanceof Error ? error : new TypeError(String(error)));
|
||||
return;
|
||||
}
|
||||
if (chunk == null) {
|
||||
fetchClose(meta.id);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
||||
if (bytes.byteLength === 0) {
|
||||
fetchClose(meta.id);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(bytes);
|
||||
},
|
||||
cancel(reason) {
|
||||
fetchAbort(meta.id, reason == null ? "" : String(reason));
|
||||
}
|
||||
});
|
||||
const response = new Response(null, {
|
||||
status: meta.status,
|
||||
statusText: meta.statusText,
|
||||
headers: meta.headers,
|
||||
url: meta.url
|
||||
});
|
||||
return attachResponseStreaming(response, stream);
|
||||
};
|
||||
globalThis.fetch.__gopeedOriginalFetch = originalFetch;
|
||||
}
|
||||
})();
|
||||
@@ -1,6 +1,8 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/eventloop"
|
||||
)
|
||||
@@ -25,7 +27,12 @@ func (vm *Vm) Get(name string) (value any) {
|
||||
func (vm *Vm) RunString(script string) (value any, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = r.(error)
|
||||
switch v := r.(type) {
|
||||
case error:
|
||||
err = v
|
||||
default:
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
(()=>{var e={725:function(e,t,r){var o,n,i;i="undefined"!=typeof self&&self||"undefined"!=typeof window&&window||void 0!==r.g&&r.g||this,o=function(e){"use strict";var t=i.BlobBuilder||i.WebKitBlobBuilder||i.MSBlobBuilder||i.MozBlobBuilder,r=i.URL||i.webkitURL||function(e,t){return(t=document.createElement("a")).href=e,t},o=i.Blob,n=r.createObjectURL,a=r.revokeObjectURL,s=i.Symbol&&i.Symbol.toStringTag,u=!1,f=!1,l=t&&t.prototype.append&&t.prototype.getBlob;try{u=2===new Blob(["ä"]).size,f=2===new Blob([new Uint8Array([1,2])]).size}catch(e){}function c(e){return e.map((function(e){if(e.buffer instanceof ArrayBuffer){var t=e.buffer;if(e.byteLength!==t.byteLength){var r=new Uint8Array(e.byteLength);r.set(new Uint8Array(t,e.byteOffset,e.byteLength)),t=r.buffer}return t}return e}))}function h(e,r){r=r||{};var o=new t;return c(e).forEach((function(e){o.append(e)})),r.type?o.getBlob(r.type):o.getBlob()}function d(e,t){return new o(c(e),t||{})}i.Blob&&(h.prototype=Blob.prototype,d.prototype=Blob.prototype);var p="function"==typeof TextEncoder?TextEncoder.prototype.encode.bind(new TextEncoder):function(e){for(var t=0,r=e.length,o=i.Uint8Array||Array,n=0,a=Math.max(32,r+(r>>1)+7),s=new o(a>>3<<3);t<r;){var u=e.charCodeAt(t++);if(u>=55296&&u<=56319){if(t<r){var f=e.charCodeAt(t);56320==(64512&f)&&(++t,u=((1023&u)<<10)+(1023&f)+65536)}if(u>=55296&&u<=56319)continue}if(n+4>s.length){a+=8,a=(a*=1+t/e.length*2)>>3<<3;var l=new Uint8Array(a);l.set(s),s=l}if(0!=(4294967168&u)){if(0==(4294965248&u))s[n++]=u>>6&31|192;else if(0==(4294901760&u))s[n++]=u>>12&15|224,s[n++]=u>>6&63|128;else{if(0!=(4292870144&u))continue;s[n++]=u>>18&7|240,s[n++]=u>>12&63|128,s[n++]=u>>6&63|128}s[n++]=63&u|128}else s[n++]=u}return s.slice(0,n)},y="function"==typeof TextDecoder?TextDecoder.prototype.decode.bind(new TextDecoder):function(e){for(var t=e.length,r=[],o=0;o<t;){var n,i,a,s,u=e[o],f=null,l=u>239?4:u>223?3:u>191?2:1;if(o+l<=t)switch(l){case 1:u<128&&(f=u);break;case 2:128==(192&(n=e[o+1]))&&(s=(31&u)<<6|63&n)>127&&(f=s);break;case 3:n=e[o+1],i=e[o+2],128==(192&n)&&128==(192&i)&&(s=(15&u)<<12|(63&n)<<6|63&i)>2047&&(s<55296||s>57343)&&(f=s);break;case 4:n=e[o+1],i=e[o+2],a=e[o+3],128==(192&n)&&128==(192&i)&&128==(192&a)&&(s=(15&u)<<18|(63&n)<<12|(63&i)<<6|63&a)>65535&&s<1114112&&(f=s)}null===f?(f=65533,l=1):f>65535&&(f-=65536,r.push(f>>>10&1023|55296),f=56320|1023&f),r.push(f),o+=l}for(var c=r.length,h="",d=0;d<c;)h+=String.fromCharCode.apply(String,r.slice(d,d+=4096));return h};function b(){var t=!!i.ActiveXObject||"-ms-scroll-limit"in document.documentElement.style&&"-ms-ime-align"in document.documentElement.style,r=i.XMLHttpRequest&&i.XMLHttpRequest.prototype.send;t&&r&&(XMLHttpRequest.prototype.send=function(e){e instanceof Blob?(this.setRequestHeader("Content-Type",e.type),r.call(this,e)):r.call(this,e)});try{new File([],""),e.File=i.File,e.FileReader=i.FileReader}catch(t){try{e.File=new Function('class File extends Blob {constructor(chunks, name, opts) {opts = opts || {};super(chunks, opts || {});this.name = name.replace(/\\//g, ":");this.lastModifiedDate = opts.lastModified ? new Date(opts.lastModified) : new Date();this.lastModified = +this.lastModifiedDate;}};return new File([], ""), File')()}catch(t){e.File=function(e,t,r){var o=new Blob(e,r),n=r&&void 0!==r.lastModified?new Date(r.lastModified):new Date;return o.name=t.replace(/\//g,":"),o.lastModifiedDate=n,o.lastModified=+n,o.toString=function(){return"[object File]"},s&&(o[s]="File"),o}}}}u?(b(),e.Blob=f?i.Blob:d):l?(b(),e.Blob=h):function(){function t(e){for(var t=new Array(e.byteLength),r=new Uint8Array(e),o=t.length;o--;)t[o]=r[o];return t}function o(e){for(var t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",r=[],o=0;o<e.length;o+=3){var n=e[o],i=o+1<e.length,a=i?e[o+1]:0,s=o+2<e.length,u=s?e[o+2]:0,f=n>>2,l=(3&n)<<4|a>>4,c=(15&a)<<2|u>>6,h=63&u;s||(h=64,i||(c=64)),r.push(t[f],t[l],t[c],t[h])}return r.join("")}var s=Object.create||function(e){function t(){}return t.prototype=e,new t};function u(e){return Object.prototype.toString.call(e).slice(8,-1)}function f(e,t){return"object"==typeof e&&Object.prototype.isPrototypeOf.call(e.prototype,t)}var l=["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","ArrayBuffer"];function c(e){return t=l,r=u(e),-1!==t.indexOf(r)||f(i.ArrayBuffer,e);var t,r}function h(e,r){r=null==r?{}:r;for(var o=0,n=(e=e?e.slice():[]).length;o<n;o++){var a=e[o];a instanceof h?e[o]=a._buffer:"string"==typeof a?e[o]=p(a):"DataView"===u(s=a)||f(i.DataView,s)?e[o]=t(a.buffer):c(a)?e[o]=t(a):e[o]=p(String(a))}var s;this._buffer=i.Uint8Array?function(e){for(var t=0,r=e.length;r--;)t+=e[r].length;for(var o=new Uint8Array(t),n=0,i=0;i<e.length;i++){var a=e[i];o.set(a,n),n+=a.byteLength||a.length}return o}(e):[].concat.apply([],e),this.size=this._buffer.length,this.type=r.type||"",/[^\u0020-\u007E]/.test(this.type)?this.type="":this.type=this.type.toLowerCase()}funcLine truncated
|
||||
(()=>{var e={847(e,t,r){var n,o;n=function(){"use strict";function e(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r<t;r++)n[r]=e[r];return n}function t(e,t,r){return t=l(t),function(e,t){if(t&&("object"==typeof t||"function"==typeof t))return t;if(void 0!==t)throw new TypeError("Derived constructors may only return object or undefined");return function(e){if(void 0===e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return e}(e)}(e,c()?Reflect.construct(t,r||[],l(e).constructor):t.apply(e,r))}function n(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function o(e,t){for(var r=0;r<t.length;r++){var n=t[r];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,f(n.key),n)}}function i(e,t,r){return t&&o(e.prototype,t),r&&o(e,r),Object.defineProperty(e,"prototype",{writable:!1}),e}function a(t,r){var n="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!n){if(Array.isArray(t)||(n=function(t,r){if(t){if("string"==typeof t)return e(t,r);var n={}.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?e(t,r):void 0}}(t))||r&&t&&"number"==typeof t.length){n&&(t=n);var o=0,i=function(){};return{s:i,n:function(){return o>=t.length?{done:!0}:{done:!1,value:t[o++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,s=!0,l=!1;return{s:function(){n=n.call(t)},n:function(){var e=n.next();return s=e.done,e},e:function(e){l=!0,a=e},f:function(){try{s||null==n.return||n.return()}finally{if(l)throw a}}}}function s(){return s="undefined"!=typeof Reflect&&Reflect.get?Reflect.get.bind():function(e,t,r){var n=function(e,t){for(;!{}.hasOwnProperty.call(e,t)&&null!==(e=l(e)););return e}(e,t);if(n){var o=Object.getOwnPropertyDescriptor(n,t);return o.get?o.get.call(arguments.length<3?e:r):o.value}},s.apply(null,arguments)}function l(e){return l=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},l(e)}function c(){try{var e=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],function(){}))}catch(e){}return(c=function(){return!!e})()}function u(e,t){return u=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},u(e,t)}function f(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var n=r.call(e,t||"default");if("object"!=typeof n)return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}!function(e){e.AbortSignal,e.AbortController}("undefined"!=typeof self?self:r.g);var p=function(){return i(function e(){n(this,e),Object.defineProperty(this,"listeners",{value:{},writable:!0,configurable:!0})},[{key:"addEventListener",value:function(e,t,r){e in this.listeners||(this.listeners[e]=[]),this.listeners[e].push({callback:t,options:r})}},{key:"removeEventListener",value:function(e,t){if(e in this.listeners)for(var r=this.listeners[e],n=0,o=r.length;n<o;n++)if(r[n].callback===t)return void r.splice(n,1)}},{key:"dispatchEvent",value:function(e){var t=this;if(e.type in this.listeners){for(var r=this.listeners[e.type].slice(),n=function(){var n=r[o];try{n.callback.call(t,e)}catch(e){Promise.resolve().then(function(){throw e})}n.options&&n.options.once&&t.removeEventListener(e.type,n.callback)},o=0,i=r.length;o<i;o++)n();return!e.defaultPrevented}}}])}(),d=function(e){function r(){var e;return n(this,r),(e=t(this,r)).listeners||p.call(e),Object.defineProperty(e,"aborted",{value:!1,writable:!0,configurable:!0}),Object.defineProperty(e,"onabort",{value:null,writable:!0,configurable:!0}),Object.defineProperty(e,"reason",{value:void 0,writable:!0,configurable:!0}),e}return function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function");e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,writable:!0,configurable:!0}}),Object.defineProperty(e,"prototype",{writable:!1}),t&&u(e,t)}(r,e),i(r,[{key:"toString",value:function(){return"[object AbortSignal]"}},{key:"dispatchEvent",value:function(e){var t,n,o,i,a;"abort"===e.type&&(this.aborted=!0,"function"==typeof this.onabort&&this.onabort.call(this,e)),(t=r,n="dispatchEvent",o=this,a=s(l(1&(i=3)?t.prototype:t),n,o),2&i&&"function"==typeof a?function(e){return a.apply(o,e)}:a)([e])}},{key:"throwIfAborted",value:function(){var e=this.aborted,t=this.reason;if(e)throw void 0===t?"Aborted":t}}],[{key:"timeout",value:function(e){var t=new h;return setTimeout(function(){return t.abort(new DOMException("This signal is timeout in ".concat(e,"ms"),"TimeoutError"))},e),t.signal}},{keLine truncated
|
||||
+893
-2128
File diff suppressed because it is too large.
Load diff
@@ -18,7 +18,9 @@
|
||||
"webpack-cli": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"blob-polyfill": "^7.0.20220408",
|
||||
"event-target-shim": "^6.0.2",
|
||||
"fastestsmallesttextencoderdecoder": "^1.0.22",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import "../event/index.js";
|
||||
import "abortcontroller-polyfill/dist/abortcontroller-polyfill-only.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Event, EventTarget } from "event-target-shim";
|
||||
|
||||
if (typeof globalThis.Event !== "function") {
|
||||
globalThis.Event = Event;
|
||||
}
|
||||
|
||||
if (typeof globalThis.EventTarget !== "function") {
|
||||
globalThis.EventTarget = EventTarget;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import "./event/index.js"
|
||||
import "./abort/index.js"
|
||||
import "./blob/index.js"
|
||||
import "./crypto/index.js"
|
||||
// polyfill TextEncoder
|
||||
import 'fastestsmallesttextencoderdecoder';
|
||||
import "./fetch/index.js"
|
||||
import "./fetch/index.js"
|
||||
@@ -0,0 +1,20 @@
|
||||
package webview
|
||||
|
||||
type Provider interface {
|
||||
IsAvailable() bool
|
||||
Open(opts OpenOptions) (Page, error)
|
||||
}
|
||||
|
||||
type unavailableProvider struct{}
|
||||
|
||||
func NewUnavailableProvider() Provider {
|
||||
return unavailableProvider{}
|
||||
}
|
||||
|
||||
func (unavailableProvider) IsAvailable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (unavailableProvider) Open(OpenOptions) (Page, error) {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package webview
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const RPCEndpointPath = "/webview"
|
||||
|
||||
const (
|
||||
MethodIsAvailable = "webview.isAvailable"
|
||||
MethodPageOpen = "page.open"
|
||||
MethodPageAddInitScript = "page.addInitScript"
|
||||
MethodPageGoto = "page.goto"
|
||||
MethodPageExecute = "page.execute"
|
||||
MethodPageGetCookies = "page.getCookies"
|
||||
MethodPageSetCookie = "page.setCookie"
|
||||
MethodPageDeleteCookie = "page.deleteCookie"
|
||||
MethodPageClearCookies = "page.clearCookies"
|
||||
MethodPageClose = "page.close"
|
||||
)
|
||||
|
||||
const (
|
||||
ErrorCodeInvalidRequest = "INVALID_REQUEST"
|
||||
ErrorCodeUnknownMethod = "UNKNOWN_METHOD"
|
||||
ErrorCodeUnavailable = "UNAVAILABLE"
|
||||
ErrorCodeBrowserNotFound = "BROWSER_NOT_FOUND"
|
||||
ErrorCodePageNotFound = "PAGE_NOT_FOUND"
|
||||
ErrorCodeNavigationFailed = "NAVIGATION_FAILED"
|
||||
ErrorCodeEvaluationFailed = "EVALUATION_FAILED"
|
||||
ErrorCodeTimeout = "TIMEOUT"
|
||||
ErrorCodeInternal = "INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
type RPCConfig struct {
|
||||
Network string `json:"network"`
|
||||
Address string `json:"address"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
func (c RPCConfig) Enabled() bool {
|
||||
return c.Network != "" && c.Address != ""
|
||||
}
|
||||
|
||||
type RPCRequest struct {
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params"`
|
||||
}
|
||||
|
||||
type RPCResponse struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *RPCError `json:"error"`
|
||||
}
|
||||
|
||||
type RPCError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *RPCError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
type IsAvailableParams struct{}
|
||||
|
||||
type IsAvailableResult struct {
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
type PageOpenParams struct {
|
||||
Headless bool `json:"headless,omitempty"`
|
||||
Debug bool `json:"debug,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
}
|
||||
|
||||
func NewPageOpenParams(opts OpenOptions) PageOpenParams {
|
||||
return PageOpenParams{
|
||||
Headless: opts.Headless,
|
||||
Debug: opts.Debug,
|
||||
Title: opts.Title,
|
||||
Width: opts.Width,
|
||||
Height: opts.Height,
|
||||
UserAgent: opts.UserAgent,
|
||||
}
|
||||
}
|
||||
|
||||
type PageOpenResult struct {
|
||||
PageID string `json:"pageId"`
|
||||
}
|
||||
|
||||
type PageAddInitScriptParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
Script string `json:"script"`
|
||||
}
|
||||
|
||||
type PageGotoParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
URL string `json:"url"`
|
||||
TimeoutMS int64 `json:"timeoutMs,omitempty"`
|
||||
WaitUntil string `json:"waitUntil,omitempty"`
|
||||
}
|
||||
|
||||
func NewPageGotoParams(pageID string, url string, opts GotoOptions) PageGotoParams {
|
||||
return PageGotoParams{
|
||||
PageID: pageID,
|
||||
URL: url,
|
||||
TimeoutMS: opts.TimeoutMS,
|
||||
WaitUntil: opts.WaitUntil,
|
||||
}
|
||||
}
|
||||
|
||||
type PageExecuteParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
Expression string `json:"expression"`
|
||||
Args []any `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
type PageGetCookiesParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
}
|
||||
|
||||
type PageSetCookieParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
Cookie Cookie `json:"cookie"`
|
||||
}
|
||||
|
||||
type PageDeleteCookieParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
Cookie Cookie `json:"cookie"`
|
||||
}
|
||||
|
||||
type PageClearCookiesParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
}
|
||||
|
||||
type PageCloseParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
}
|
||||
|
||||
type EmptyResult struct{}
|
||||
@@ -0,0 +1,765 @@
|
||||
package webview
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
var ErrUnavailable = errors.New("gopeed runtime webview is unavailable")
|
||||
|
||||
type Opener interface {
|
||||
Open(opts OpenOptions) (Page, error)
|
||||
}
|
||||
|
||||
type Page interface {
|
||||
AddInitScript(script string) error
|
||||
Goto(url string, opts GotoOptions) error
|
||||
Execute(expression string, args ...any) (any, error)
|
||||
GetCookies() ([]Cookie, error)
|
||||
SetCookie(cookie Cookie) error
|
||||
DeleteCookie(cookie Cookie) error
|
||||
ClearCookies() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type OpenOptions struct {
|
||||
Headless bool
|
||||
Debug bool
|
||||
Title string
|
||||
Width int
|
||||
Height int
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type GotoOptions struct {
|
||||
TimeoutMS int64
|
||||
WaitUntil string
|
||||
}
|
||||
|
||||
type ClickOptions struct {
|
||||
DelayMS int64
|
||||
}
|
||||
|
||||
type TypeOptions struct {
|
||||
DelayMS int64
|
||||
}
|
||||
|
||||
type WaitOptions struct {
|
||||
TimeoutMS int64
|
||||
PollIntervalMS int64
|
||||
}
|
||||
|
||||
type WaitForSelectorOptions struct {
|
||||
TimeoutMS int64
|
||||
PollIntervalMS int64
|
||||
Visible bool
|
||||
Hidden bool
|
||||
}
|
||||
|
||||
type Cookie struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Expires time.Time `json:"expires,omitempty,omitzero"`
|
||||
Secure bool `json:"secure,omitempty"`
|
||||
HTTPOnly bool `json:"httpOnly,omitempty"`
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
opener Opener
|
||||
available bool
|
||||
|
||||
mu sync.Mutex
|
||||
pageSeq uint64
|
||||
pages map[string]Page
|
||||
}
|
||||
|
||||
func NewRuntime(opener Opener, available bool) *Runtime {
|
||||
if opener == nil {
|
||||
available = false
|
||||
}
|
||||
return &Runtime{
|
||||
opener: opener,
|
||||
available: available,
|
||||
pages: make(map[string]Page),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runtime) IsAvailable() bool {
|
||||
return r != nil && r.available && r.opener != nil
|
||||
}
|
||||
|
||||
func (r *Runtime) Open(opts ...map[string]any) (*PageHandle, error) {
|
||||
if !r.IsAvailable() {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
page, err := r.opener.Open(parseOpenOptions(firstMap(opts)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.pageSeq++
|
||||
id := fmt.Sprintf("page-%d", r.pageSeq)
|
||||
r.pages[id] = page
|
||||
return &PageHandle{runtime: r, id: id}, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) Close() error {
|
||||
r.mu.Lock()
|
||||
pageIDs := make([]string, 0, len(r.pages))
|
||||
for id := range r.pages {
|
||||
pageIDs = append(pageIDs, id)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
for _, id := range pageIDs {
|
||||
if err := r.closePage(id); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
type PageHandle struct {
|
||||
runtime *Runtime
|
||||
id string
|
||||
}
|
||||
|
||||
func (p *PageHandle) AddInitScript(script string) error {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return page.AddInitScript(script)
|
||||
}
|
||||
|
||||
func (p *PageHandle) Goto(url string, opts ...map[string]any) error {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gotoOpts, err := parseGotoOptions(firstMap(opts))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return page.Goto(url, gotoOpts)
|
||||
}
|
||||
|
||||
func (p *PageHandle) Execute(scriptOrFn any, args ...any) (any, error) {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expression, err := normalizeExecutable(scriptOrFn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return page.Execute(expression, args...)
|
||||
}
|
||||
|
||||
func (p *PageHandle) Focus(selector string) error {
|
||||
_, err := p.Execute(`(selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
throw new Error("element not found: " + selector);
|
||||
}
|
||||
if (typeof element.focus !== "function") {
|
||||
throw new Error("element is not focusable: " + selector);
|
||||
}
|
||||
element.focus();
|
||||
return true;
|
||||
}`, selector)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *PageHandle) Click(selector string, opts ...map[string]any) error {
|
||||
clickOpts := parseClickOptions(firstMap(opts))
|
||||
_, err := p.Execute(`(selector, delayMS) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
throw new Error("element not found: " + selector);
|
||||
}
|
||||
if (typeof element.scrollIntoView === "function") {
|
||||
element.scrollIntoView({ block: "center", inline: "center" });
|
||||
}
|
||||
const performClick = () => {
|
||||
if (typeof element.click === "function") {
|
||||
element.click();
|
||||
return;
|
||||
}
|
||||
element.dispatchEvent(new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
}));
|
||||
};
|
||||
if (delayMS > 0) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
performClick();
|
||||
resolve(true);
|
||||
}, delayMS);
|
||||
});
|
||||
}
|
||||
performClick();
|
||||
return true;
|
||||
}`, selector, clickOpts.DelayMS)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *PageHandle) Type(selector string, text string, opts ...map[string]any) error {
|
||||
typeOpts := parseTypeOptions(firstMap(opts))
|
||||
_, err := p.Execute(`(selector, text, delayMS) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
throw new Error("element not found: " + selector);
|
||||
}
|
||||
if (typeof element.focus === "function") {
|
||||
element.focus();
|
||||
}
|
||||
const emitInput = () => {
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
const insertIntoInput = (chunk) => {
|
||||
if (element.disabled) {
|
||||
throw new Error("element is disabled: " + selector);
|
||||
}
|
||||
if (element.readOnly) {
|
||||
throw new Error("element is readonly: " + selector);
|
||||
}
|
||||
const value = typeof element.value === "string" ? element.value : "";
|
||||
const hasSelection = typeof element.selectionStart === "number" && typeof element.selectionEnd === "number";
|
||||
const start = hasSelection ? element.selectionStart : value.length;
|
||||
const end = hasSelection ? element.selectionEnd : value.length;
|
||||
element.value = value.slice(0, start) + chunk + value.slice(end);
|
||||
const caret = start + chunk.length;
|
||||
if (typeof element.setSelectionRange === "function") {
|
||||
element.setSelectionRange(caret, caret);
|
||||
}
|
||||
emitInput();
|
||||
};
|
||||
const insertIntoEditable = (chunk) => {
|
||||
if (element.isContentEditable) {
|
||||
element.textContent = (element.textContent || "") + chunk;
|
||||
emitInput();
|
||||
return;
|
||||
}
|
||||
insertIntoInput(chunk);
|
||||
};
|
||||
const insertChunk = (chunk) => {
|
||||
const tagName = (element.tagName || "").toUpperCase();
|
||||
if (tagName === "INPUT" || tagName === "TEXTAREA" || typeof element.value === "string") {
|
||||
insertIntoInput(chunk);
|
||||
return;
|
||||
}
|
||||
if (element.isContentEditable) {
|
||||
insertIntoEditable(chunk);
|
||||
return;
|
||||
}
|
||||
throw new Error("element is not typable: " + selector);
|
||||
};
|
||||
const chars = Array.from(String(text));
|
||||
if (delayMS > 0) {
|
||||
return new Promise((resolve) => {
|
||||
let index = 0;
|
||||
const step = () => {
|
||||
if (index >= chars.length) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
insertChunk(chars[index]);
|
||||
index += 1;
|
||||
setTimeout(step, delayMS);
|
||||
};
|
||||
step();
|
||||
});
|
||||
}
|
||||
for (const char of chars) {
|
||||
insertChunk(char);
|
||||
}
|
||||
return true;
|
||||
}`, selector, text, typeOpts.DelayMS)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *PageHandle) WaitForSelector(selector string, opts ...map[string]any) (bool, error) {
|
||||
waitOpts := parseWaitForSelectorOptions(firstMap(opts))
|
||||
_, matched, err := p.poll(WaitOptions{
|
||||
TimeoutMS: waitOpts.TimeoutMS,
|
||||
PollIntervalMS: waitOpts.PollIntervalMS,
|
||||
}, func() (any, bool, error) {
|
||||
value, err := p.Execute(`(selector, visible, hidden) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (hidden) {
|
||||
if (!element) return true;
|
||||
const style = window.getComputedStyle(element);
|
||||
if (!style) return !!element.hidden;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return element.hidden || style.display === "none" || style.visibility === "hidden" || rect.width === 0 || rect.height === 0;
|
||||
}
|
||||
if (!element) return false;
|
||||
if (!visible) return true;
|
||||
const style = window.getComputedStyle(element);
|
||||
if (!style) return true;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return !element.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
}`, selector, waitOpts.Visible, waitOpts.Hidden)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return true, truthy(value), nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (p *PageHandle) WaitForFunction(scriptOrFn any, args ...any) (any, error) {
|
||||
expression, err := normalizeExecutable(scriptOrFn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
waitOpts := WaitOptions{}
|
||||
callArgs := args
|
||||
if len(args) > 0 {
|
||||
if raw, ok := args[0].(map[string]any); ok {
|
||||
waitOpts = parseWaitOptions(raw)
|
||||
callArgs = args[1:]
|
||||
}
|
||||
}
|
||||
|
||||
value, matched, err := p.poll(waitOpts, func() (any, bool, error) {
|
||||
result, err := p.Execute(`(expression, args) => {
|
||||
const target = (0, eval)(expression);
|
||||
return Promise.resolve(typeof target === "function" ? target(...args) : target).then((value) => ({
|
||||
matched: !!value,
|
||||
value: value ?? null,
|
||||
}));
|
||||
}`, expression, callArgs)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
payload, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("unexpected waitForFunction payload: %T", result)
|
||||
}
|
||||
return payload["value"], truthy(payload["matched"]), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !matched {
|
||||
return nil, nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (p *PageHandle) GetCookies() ([]Cookie, error) {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return page.GetCookies()
|
||||
}
|
||||
|
||||
func (p *PageHandle) SetCookie(raw map[string]any) error {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return page.SetCookie(parseCookie(raw))
|
||||
}
|
||||
|
||||
func (p *PageHandle) DeleteCookie(raw map[string]any) error {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return page.DeleteCookie(parseCookie(raw))
|
||||
}
|
||||
|
||||
func (p *PageHandle) ClearCookies() error {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return page.ClearCookies()
|
||||
}
|
||||
|
||||
func (p *PageHandle) URL() (string, error) {
|
||||
value, err := p.Execute(`() => location.href`)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseString(value), nil
|
||||
}
|
||||
|
||||
func (p *PageHandle) Content() (string, error) {
|
||||
value, err := p.Execute(`() => document.documentElement ? document.documentElement.outerHTML : ""`)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseString(value), nil
|
||||
}
|
||||
|
||||
func (p *PageHandle) Close() error {
|
||||
if p == nil || p.runtime == nil {
|
||||
return nil
|
||||
}
|
||||
return p.runtime.closePage(p.id)
|
||||
}
|
||||
|
||||
func (p *PageHandle) page() (Page, error) {
|
||||
if p == nil || p.runtime == nil {
|
||||
return nil, fmt.Errorf("webview page handle is not initialized")
|
||||
}
|
||||
return p.runtime.getPage(p.id)
|
||||
}
|
||||
|
||||
func (p *PageHandle) poll(opts WaitOptions, fn func() (any, bool, error)) (any, bool, error) {
|
||||
timeout := time.Duration(defaultWaitTimeoutMS(opts.TimeoutMS)) * time.Millisecond
|
||||
pollInterval := time.Duration(defaultPollIntervalMS(opts.PollIntervalMS)) * time.Millisecond
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
value, matched, err := fn()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if matched {
|
||||
return value, true, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, false, nil
|
||||
}
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runtime) getPage(id string) (Page, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
page, ok := r.pages[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("page not found")
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) closePage(id string) error {
|
||||
r.mu.Lock()
|
||||
page, ok := r.pages[id]
|
||||
if !ok {
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
delete(r.pages, id)
|
||||
r.mu.Unlock()
|
||||
return page.Close()
|
||||
}
|
||||
|
||||
func parseOpenOptions(raw map[string]any) OpenOptions {
|
||||
return OpenOptions{
|
||||
Headless: parseBool(raw["headless"]),
|
||||
Debug: parseBool(raw["debug"]),
|
||||
Title: parseString(raw["title"]),
|
||||
Width: parseInt(raw["width"]),
|
||||
Height: parseInt(raw["height"]),
|
||||
UserAgent: parseString(raw["userAgent"]),
|
||||
}
|
||||
}
|
||||
|
||||
func parseGotoOptions(raw map[string]any) (GotoOptions, error) {
|
||||
waitUntil, err := normalizeWaitUntil(parseString(raw["waitUntil"]))
|
||||
if err != nil {
|
||||
return GotoOptions{}, err
|
||||
}
|
||||
return GotoOptions{
|
||||
TimeoutMS: parseInt64(raw["timeoutMs"]),
|
||||
WaitUntil: waitUntil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeWaitUntil(raw string) (string, error) {
|
||||
if raw == "" {
|
||||
return "load", nil
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "load":
|
||||
return "load", nil
|
||||
case "domcontentloaded":
|
||||
return "domcontentloaded", nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid waitUntil: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func parseClickOptions(raw map[string]any) ClickOptions {
|
||||
return ClickOptions{DelayMS: parseInt64(raw["delay"])}
|
||||
}
|
||||
|
||||
func parseTypeOptions(raw map[string]any) TypeOptions {
|
||||
return TypeOptions{DelayMS: parseInt64(raw["delay"])}
|
||||
}
|
||||
|
||||
func parseWaitOptions(raw map[string]any) WaitOptions {
|
||||
return WaitOptions{
|
||||
TimeoutMS: parseInt64(raw["timeoutMs"]),
|
||||
PollIntervalMS: parseInt64(raw["pollIntervalMs"]),
|
||||
}
|
||||
}
|
||||
|
||||
func parseWaitForSelectorOptions(raw map[string]any) WaitForSelectorOptions {
|
||||
return WaitForSelectorOptions{
|
||||
TimeoutMS: parseInt64(raw["timeoutMs"]),
|
||||
PollIntervalMS: parseInt64(raw["pollIntervalMs"]),
|
||||
Visible: parseBool(raw["visible"]),
|
||||
Hidden: parseBool(raw["hidden"]),
|
||||
}
|
||||
}
|
||||
|
||||
func parseCookie(raw map[string]any) Cookie {
|
||||
cookie := Cookie{
|
||||
Name: parseString(raw["name"]),
|
||||
Value: parseString(raw["value"]),
|
||||
Domain: parseString(raw["domain"]),
|
||||
Path: parseString(raw["path"]),
|
||||
Secure: parseBool(raw["secure"]),
|
||||
HTTPOnly: parseBool(raw["httpOnly"]),
|
||||
}
|
||||
if rawExpires, ok := raw["expires"]; ok {
|
||||
cookie.Expires = parseTime(rawExpires)
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
func defaultWaitTimeoutMS(value int64) int64 {
|
||||
if value <= 0 {
|
||||
return 10000
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func defaultPollIntervalMS(value int64) int64 {
|
||||
if value <= 0 {
|
||||
return 100
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeExecutable(scriptOrFn any) (string, error) {
|
||||
switch value := scriptOrFn.(type) {
|
||||
case string:
|
||||
return value, nil
|
||||
case *goja.Object:
|
||||
return NormalizeExecutableValue(value)
|
||||
default:
|
||||
return "", fmt.Errorf("execute expects a string or function, got %T", scriptOrFn)
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeExecutableValue(value goja.Value) (string, error) {
|
||||
if value == nil {
|
||||
return "", fmt.Errorf("execute expects a string or function")
|
||||
}
|
||||
switch raw := value.Export().(type) {
|
||||
case string:
|
||||
return raw, nil
|
||||
}
|
||||
obj, ok := value.(*goja.Object)
|
||||
if ok {
|
||||
if _, ok := goja.AssertFunction(obj); ok {
|
||||
source, err := functionSource(obj)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalizeFunctionSource(source), nil
|
||||
}
|
||||
}
|
||||
source := strings.TrimSpace(value.String())
|
||||
if looksLikeFunctionSource(source) {
|
||||
return normalizeFunctionSource(source), nil
|
||||
}
|
||||
return "", fmt.Errorf("execute expects a string or function, got %T (%q)", value, source)
|
||||
}
|
||||
|
||||
func normalizeFunctionSource(source string) string {
|
||||
repaired := repairArrowObjectLiteralSource(source)
|
||||
return "(" + repaired + ")"
|
||||
}
|
||||
|
||||
func functionSource(fn *goja.Object) (string, error) {
|
||||
toStringValue := fn.Get("toString")
|
||||
toString, ok := goja.AssertFunction(toStringValue)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("function.toString is not callable")
|
||||
}
|
||||
value, err := toString(fn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value.String(), nil
|
||||
}
|
||||
|
||||
func repairArrowObjectLiteralSource(source string) string {
|
||||
trimmed := strings.TrimSpace(source)
|
||||
if !strings.Contains(trimmed, "=>") || !strings.Contains(trimmed, "({") {
|
||||
return source
|
||||
}
|
||||
if !strings.HasSuffix(trimmed, "}") {
|
||||
return source
|
||||
}
|
||||
if strings.Count(trimmed, "(")-strings.Count(trimmed, ")") != 1 {
|
||||
return source
|
||||
}
|
||||
if strings.Count(trimmed, "{") != strings.Count(trimmed, "}") {
|
||||
return source
|
||||
}
|
||||
// TODO: Remove this narrow repair once goja fixes toString() for concise arrow
|
||||
// functions that directly return object literals, then upstream the fix as a PR.
|
||||
return source + ")"
|
||||
}
|
||||
|
||||
func looksLikeFunctionSource(source string) bool {
|
||||
if source == "" || source == "[object Object]" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(source, "=>") ||
|
||||
strings.HasPrefix(source, "function") ||
|
||||
strings.HasPrefix(source, "async function") ||
|
||||
strings.HasPrefix(source, "async (") ||
|
||||
strings.HasPrefix(source, "async(")
|
||||
}
|
||||
|
||||
func truthy(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v != ""
|
||||
case int:
|
||||
return v != 0
|
||||
case int8:
|
||||
return v != 0
|
||||
case int16:
|
||||
return v != 0
|
||||
case int32:
|
||||
return v != 0
|
||||
case int64:
|
||||
return v != 0
|
||||
case uint:
|
||||
return v != 0
|
||||
case uint8:
|
||||
return v != 0
|
||||
case uint16:
|
||||
return v != 0
|
||||
case uint32:
|
||||
return v != 0
|
||||
case uint64:
|
||||
return v != 0
|
||||
case float32:
|
||||
return v != 0
|
||||
case float64:
|
||||
return v != 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func firstMap(values []map[string]any) map[string]any {
|
||||
if len(values) == 0 || values[0] == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func parseString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
func parseBool(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
parsed, _ := strconv.ParseBool(v)
|
||||
return parsed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt(value any) int {
|
||||
return int(parseInt64(value))
|
||||
}
|
||||
|
||||
func parseInt64(value any) int64 {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return int64(v)
|
||||
case int8:
|
||||
return int64(v)
|
||||
case int16:
|
||||
return int64(v)
|
||||
case int32:
|
||||
return int64(v)
|
||||
case int64:
|
||||
return v
|
||||
case uint:
|
||||
return int64(v)
|
||||
case uint8:
|
||||
return int64(v)
|
||||
case uint16:
|
||||
return int64(v)
|
||||
case uint32:
|
||||
return int64(v)
|
||||
case uint64:
|
||||
return int64(v)
|
||||
case float32:
|
||||
return int64(v)
|
||||
case float64:
|
||||
return int64(v)
|
||||
case string:
|
||||
parsed, _ := strconv.ParseInt(v, 10, 64)
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseTime(value any) time.Time {
|
||||
switch v := value.(type) {
|
||||
case time.Time:
|
||||
return v
|
||||
case int64:
|
||||
return time.UnixMilli(v)
|
||||
case int:
|
||||
return time.UnixMilli(int64(v))
|
||||
case float64:
|
||||
return time.UnixMilli(int64(v))
|
||||
case string:
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, v); err == nil {
|
||||
return parsed
|
||||
}
|
||||
if ms, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
return time.UnixMilli(ms)
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package webview
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestRuntimeOpenAndExecuteFunctionSource(t *testing.T) {
|
||||
opener := &fakeOpener{
|
||||
page: &fakePage{executeValue: "ok"},
|
||||
}
|
||||
runtime := NewRuntime(opener, true)
|
||||
page, err := runtime.Open(map[string]any{
|
||||
"headless": true,
|
||||
"debug": true,
|
||||
"title": "Gopeed",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"userAgent": "UA",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := goja.New()
|
||||
fnValue, err := vm.RunString(`(a, b) => a + b`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := page.Execute(fnValue, int64(1), int64(2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != "ok" {
|
||||
t.Fatalf("unexpected execute result: %#v", value)
|
||||
}
|
||||
if opener.opts.Title != "Gopeed" || opener.opts.UserAgent != "UA" {
|
||||
t.Fatalf("unexpected open options: %+v", opener.opts)
|
||||
}
|
||||
if opener.page.lastExecuteSource != "((a, b) => a + b)" {
|
||||
t.Fatalf("unexpected function source: %q", opener.page.lastExecuteSource)
|
||||
}
|
||||
if len(opener.page.lastExecuteArgs) != 2 || opener.page.lastExecuteArgs[0] != int64(1) || opener.page.lastExecuteArgs[1] != int64(2) {
|
||||
t.Fatalf("unexpected execute args: %#v", opener.page.lastExecuteArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeExecuteRepairsObjectLiteralArrowFunction(t *testing.T) {
|
||||
opener := &fakeOpener{
|
||||
page: &fakePage{executeValue: map[string]any{"ok": true}},
|
||||
}
|
||||
runtime := NewRuntime(opener, true)
|
||||
page, err := runtime.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := goja.New()
|
||||
fnValue, err := vm.RunString(`() => ({ ok: true })`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := page.Execute(fnValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opener.page.lastExecuteSource != "(() => ({ ok: true }))" {
|
||||
t.Fatalf("expected repaired function source, got %q", opener.page.lastExecuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeExecuteRepairsMultilineObjectLiteralArrowFunction(t *testing.T) {
|
||||
opener := &fakeOpener{
|
||||
page: &fakePage{executeValue: map[string]any{"ok": true}},
|
||||
}
|
||||
runtime := NewRuntime(opener, true)
|
||||
page, err := runtime.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := goja.New()
|
||||
fnValue, err := vm.RunString(`() => ({
|
||||
title: document.title || "",
|
||||
url: String(location.href || ""),
|
||||
userAgent: navigator.userAgent,
|
||||
readyState: document.readyState,
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := page.Execute(fnValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := `(() => ({
|
||||
title: document.title || "",
|
||||
url: String(location.href || ""),
|
||||
userAgent: navigator.userAgent,
|
||||
readyState: document.readyState,
|
||||
}))`
|
||||
if opener.page.lastExecuteSource != expected {
|
||||
t.Fatalf("expected repaired multiline function source, got %q", opener.page.lastExecuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeHelpers(t *testing.T) {
|
||||
opener := &fakeOpener{
|
||||
page: &fakePage{
|
||||
executeQueue: []any{
|
||||
false,
|
||||
true,
|
||||
map[string]any{"matched": false, "value": nil},
|
||||
map[string]any{"matched": true, "value": "ready"},
|
||||
"https://example.com",
|
||||
"<html></html>",
|
||||
},
|
||||
cookies: []Cookie{
|
||||
{Name: "a", Value: "1", Domain: "example.com", Path: "/"},
|
||||
{Name: "b", Value: "two", Domain: "example.com", Path: "/"},
|
||||
},
|
||||
},
|
||||
}
|
||||
runtime := NewRuntime(opener, true)
|
||||
page, err := runtime.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := page.AddInitScript("window.__TEST__ = true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opener.page.lastInitScript != "window.__TEST__ = true" {
|
||||
t.Fatalf("unexpected init script: %q", opener.page.lastInitScript)
|
||||
}
|
||||
if err := page.Goto("https://example.com", map[string]any{"waitUntil": "domcontentloaded"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opener.page.lastGotoURL != "https://example.com" {
|
||||
t.Fatalf("unexpected goto url: %q", opener.page.lastGotoURL)
|
||||
}
|
||||
if opener.page.lastGotoOpts.WaitUntil != "domcontentloaded" {
|
||||
t.Fatalf("unexpected goto waitUntil: %q", opener.page.lastGotoOpts.WaitUntil)
|
||||
}
|
||||
found, err := page.WaitForSelector("#app", map[string]any{"timeoutMs": 50, "pollIntervalMs": 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected selector to be found")
|
||||
}
|
||||
value, err := page.WaitForFunction(`() => window.__READY__`, map[string]any{"timeoutMs": 50, "pollIntervalMs": 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != "ready" {
|
||||
t.Fatalf("unexpected waitForFunction value: %#v", value)
|
||||
}
|
||||
cookies, err := page.GetCookies()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cookies) != 2 || cookies[0].Name != "a" || cookies[0].Value != "1" || cookies[1].Name != "b" || cookies[1].Value != "two" {
|
||||
t.Fatalf("unexpected cookies: %#v", cookies)
|
||||
}
|
||||
if err := page.SetCookie(map[string]any{
|
||||
"name": "session",
|
||||
"value": "abc",
|
||||
"domain": "example.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opener.page.lastSetCookie.Name != "session" || !opener.page.lastSetCookie.Secure || !opener.page.lastSetCookie.HTTPOnly {
|
||||
t.Fatalf("unexpected set cookie: %#v", opener.page.lastSetCookie)
|
||||
}
|
||||
if err := page.DeleteCookie(map[string]any{
|
||||
"name": "session",
|
||||
"domain": "example.com",
|
||||
"path": "/",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opener.page.lastDeleteCookie.Name != "session" || opener.page.lastDeleteCookie.Domain != "example.com" {
|
||||
t.Fatalf("unexpected delete cookie: %#v", opener.page.lastDeleteCookie)
|
||||
}
|
||||
if err := page.ClearCookies(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !opener.page.cookiesCleared {
|
||||
t.Fatal("expected cookies to be cleared")
|
||||
}
|
||||
url, err := page.URL()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if url != "https://example.com" {
|
||||
t.Fatalf("unexpected url: %q", url)
|
||||
}
|
||||
content, err := page.Content()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "<html></html>" {
|
||||
t.Fatalf("unexpected content: %q", content)
|
||||
}
|
||||
if err := page.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !opener.page.closed {
|
||||
t.Fatal("expected page to be closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeInteractionHelpers(t *testing.T) {
|
||||
opener := &fakeOpener{
|
||||
page: &fakePage{executeValue: true},
|
||||
}
|
||||
runtime := NewRuntime(opener, true)
|
||||
page, err := runtime.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := page.Focus("#search"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(opener.page.lastExecuteArgs) != 1 || opener.page.lastExecuteArgs[0] != "#search" {
|
||||
t.Fatalf("unexpected focus args: %#v", opener.page.lastExecuteArgs)
|
||||
}
|
||||
if !strings.Contains(opener.page.lastExecuteSource, "element.focus()") {
|
||||
t.Fatalf("unexpected focus source: %q", opener.page.lastExecuteSource)
|
||||
}
|
||||
|
||||
if err := page.Click("#submit", map[string]any{"delay": 12}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(opener.page.lastExecuteArgs) != 2 || opener.page.lastExecuteArgs[0] != "#submit" || opener.page.lastExecuteArgs[1] != int64(12) {
|
||||
t.Fatalf("unexpected click args: %#v", opener.page.lastExecuteArgs)
|
||||
}
|
||||
if !strings.Contains(opener.page.lastExecuteSource, "performClick") {
|
||||
t.Fatalf("unexpected click source: %q", opener.page.lastExecuteSource)
|
||||
}
|
||||
|
||||
if err := page.Type("#search", "Gopeed", map[string]any{"delay": 5}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(opener.page.lastExecuteArgs) != 3 || opener.page.lastExecuteArgs[0] != "#search" || opener.page.lastExecuteArgs[1] != "Gopeed" || opener.page.lastExecuteArgs[2] != int64(5) {
|
||||
t.Fatalf("unexpected type args: %#v", opener.page.lastExecuteArgs)
|
||||
}
|
||||
if !strings.Contains(opener.page.lastExecuteSource, "element is not typable") {
|
||||
t.Fatalf("unexpected type source: %q", opener.page.lastExecuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeGotoRejectsInvalidWaitUntil(t *testing.T) {
|
||||
opener := &fakeOpener{
|
||||
page: &fakePage{},
|
||||
}
|
||||
runtime := NewRuntime(opener, true)
|
||||
page, err := runtime.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = page.Goto("https://example.com", map[string]any{"waitUntil": "networkidle"})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid waitUntil") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeOpener struct {
|
||||
opts OpenOptions
|
||||
page *fakePage
|
||||
}
|
||||
|
||||
func (o *fakeOpener) Open(opts OpenOptions) (Page, error) {
|
||||
o.opts = opts
|
||||
return o.page, nil
|
||||
}
|
||||
|
||||
type fakePage struct {
|
||||
lastInitScript string
|
||||
lastGotoURL string
|
||||
lastGotoOpts GotoOptions
|
||||
lastExecuteSource string
|
||||
lastExecuteArgs []any
|
||||
executeValue any
|
||||
executeQueue []any
|
||||
cookies []Cookie
|
||||
lastSetCookie Cookie
|
||||
lastDeleteCookie Cookie
|
||||
cookiesCleared bool
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (p *fakePage) AddInitScript(script string) error {
|
||||
p.lastInitScript = script
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePage) Goto(url string, opts GotoOptions) error {
|
||||
p.lastGotoURL = url
|
||||
p.lastGotoOpts = opts
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePage) Execute(expression string, args ...any) (any, error) {
|
||||
p.lastExecuteSource = expression
|
||||
p.lastExecuteArgs = args
|
||||
if len(p.executeQueue) > 0 {
|
||||
value := p.executeQueue[0]
|
||||
p.executeQueue = p.executeQueue[1:]
|
||||
return value, nil
|
||||
}
|
||||
return p.executeValue, nil
|
||||
}
|
||||
|
||||
func (p *fakePage) GetCookies() ([]Cookie, error) {
|
||||
return append([]Cookie(nil), p.cookies...), nil
|
||||
}
|
||||
|
||||
func (p *fakePage) SetCookie(cookie Cookie) error {
|
||||
p.lastSetCookie = cookie
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePage) DeleteCookie(cookie Cookie) error {
|
||||
p.lastDeleteCookie = cookie
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePage) ClearCookies() error {
|
||||
p.cookiesCleared = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePage) Close() error {
|
||||
p.closed = true
|
||||
return nil
|
||||
}
|
||||
+36
-12
@@ -13,8 +13,10 @@ import (
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||
gojautil "github.com/GopeedLab/gopeed/pkg/download/engine/util"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/go-git/go-git/v5"
|
||||
@@ -269,6 +271,7 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, er
|
||||
}
|
||||
ensureResourceRequestRawURLs(req, ctx.Res)
|
||||
ctx.Res.CalcSize(nil)
|
||||
d.applyGBlobResourceMetadata(ctx.Res)
|
||||
}
|
||||
res = ctx.Res
|
||||
},
|
||||
@@ -276,6 +279,19 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, er
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Downloader) applyGBlobResourceMetadata(res *base.Resource) {
|
||||
if d.gblob == nil || res == nil {
|
||||
return
|
||||
}
|
||||
for _, file := range res.Files {
|
||||
if file == nil || file.Req == nil {
|
||||
continue
|
||||
}
|
||||
_ = d.gblob.SetSize(file.Req.URL, file.Size)
|
||||
_ = d.gblob.SetRange(file.Req.URL, res.Range)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Downloader) triggerOnStart(task *Task) {
|
||||
doTrigger(d,
|
||||
EventOnStart,
|
||||
@@ -363,7 +379,10 @@ func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, c
|
||||
}
|
||||
engine, session := d.newExtensionEngine()
|
||||
defer session.CloseIfIdle()
|
||||
err = engine.Runtime.Set("gopeed", gopeed)
|
||||
gopeed.Runtime = &InstanceRuntime{
|
||||
WebView: d.newExtensionWebViewRuntime(session),
|
||||
}
|
||||
err = injectGopeed(engine.Runtime, gopeed)
|
||||
if err != nil {
|
||||
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] engine inject failed", ext.buildIdentity())
|
||||
return
|
||||
@@ -603,32 +622,37 @@ type Option struct {
|
||||
|
||||
// Instance inject to js context when extension script is activated
|
||||
type Instance struct {
|
||||
Events InstanceEvents `json:"events"`
|
||||
Info *ExtensionInfo `json:"info"`
|
||||
Logger *InstanceLogger `json:"logger"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
Storage *ContextStorage `json:"storage"`
|
||||
Events InstanceEvents `json:"events"`
|
||||
Info *ExtensionInfo `json:"info"`
|
||||
Logger *InstanceLogger `json:"logger"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
Storage *ContextStorage `json:"storage"`
|
||||
Runtime *InstanceRuntime `json:"runtime"`
|
||||
}
|
||||
|
||||
type InstanceEvents map[ActivationEvent]goja.Callable
|
||||
type InstanceRuntime struct {
|
||||
WebView *enginewebview.Runtime `json:"webview"`
|
||||
}
|
||||
|
||||
func (h InstanceEvents) register(name ActivationEvent, fn goja.Callable) {
|
||||
type InstanceEvents map[ActivationEvent]engine.JSFunction
|
||||
|
||||
func (h InstanceEvents) register(name ActivationEvent, fn engine.JSFunction) {
|
||||
h[name] = fn
|
||||
}
|
||||
|
||||
func (h InstanceEvents) OnResolve(fn goja.Callable) {
|
||||
func (h InstanceEvents) OnResolve(fn engine.JSFunction) {
|
||||
h.register(EventOnResolve, fn)
|
||||
}
|
||||
|
||||
func (h InstanceEvents) OnStart(fn goja.Callable) {
|
||||
func (h InstanceEvents) OnStart(fn engine.JSFunction) {
|
||||
h.register(EventOnStart, fn)
|
||||
}
|
||||
|
||||
func (h InstanceEvents) OnError(fn goja.Callable) {
|
||||
func (h InstanceEvents) OnError(fn engine.JSFunction) {
|
||||
h.register(EventOnError, fn)
|
||||
}
|
||||
|
||||
func (h InstanceEvents) OnDone(fn goja.Callable) {
|
||||
func (h InstanceEvents) OnDone(fn engine.JSFunction) {
|
||||
h.register(EventOnDone, fn)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type extensionEngineRunner interface {
|
||||
Close()
|
||||
RunString(script string) (any, error)
|
||||
}
|
||||
|
||||
type ExtensionEngine struct {
|
||||
engine extensionEngineRunner
|
||||
session *engineSession
|
||||
}
|
||||
|
||||
func (d *Downloader) NewExtensionEngine(ext *Extension, settings map[string]any) (*ExtensionEngine, error) {
|
||||
if ext == nil {
|
||||
return nil, fmt.Errorf("extension is nil")
|
||||
}
|
||||
engine, session := d.newExtensionEngine()
|
||||
gopeed := &Instance{
|
||||
Events: make(InstanceEvents),
|
||||
Info: NewExtensionInfo(ext),
|
||||
Logger: newInstanceLogger(ext, d.ExtensionLogger),
|
||||
Settings: settings,
|
||||
Storage: &ContextStorage{
|
||||
storage: d.storage,
|
||||
identity: ext.buildIdentity(),
|
||||
},
|
||||
Runtime: &InstanceRuntime{
|
||||
WebView: d.newExtensionWebViewRuntime(session),
|
||||
},
|
||||
}
|
||||
if err := injectGopeed(engine.Runtime, gopeed); err != nil {
|
||||
session.CloseIfIdle()
|
||||
return nil, err
|
||||
}
|
||||
return &ExtensionEngine{
|
||||
engine: engine,
|
||||
session: session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionEngine) Eval(script string) (any, error) {
|
||||
if r == nil || r.engine == nil {
|
||||
return nil, fmt.Errorf("extension engine not initialized")
|
||||
}
|
||||
return r.engine.RunString(script)
|
||||
}
|
||||
|
||||
func (r *ExtensionEngine) EvalFile(path string) (any, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("script path is empty")
|
||||
}
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Eval(string(buf))
|
||||
}
|
||||
|
||||
func (r *ExtensionEngine) Close() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
if r.session != nil {
|
||||
r.session.CloseIfIdle()
|
||||
} else if r.engine != nil {
|
||||
r.engine.Close()
|
||||
}
|
||||
r.engine = nil
|
||||
r.session = nil
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/protocol/gblob"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
@@ -15,6 +17,7 @@ type engineSession struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
closed bool
|
||||
close []func()
|
||||
}
|
||||
|
||||
func newEngineSession(e *engine.Engine) *engineSession {
|
||||
@@ -47,6 +50,7 @@ func (s *engineSession) Release() {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if shouldClose {
|
||||
s.runClosers()
|
||||
if s.engine != nil {
|
||||
go s.engine.Close()
|
||||
}
|
||||
@@ -61,12 +65,37 @@ func (s *engineSession) CloseIfIdle() {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if shouldClose {
|
||||
s.runClosers()
|
||||
if s.engine != nil {
|
||||
s.engine.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *engineSession) OnClose(fn func()) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
fn()
|
||||
return
|
||||
}
|
||||
s.close = append(s.close, fn)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *engineSession) runClosers() {
|
||||
s.mu.Lock()
|
||||
closeFns := s.close
|
||||
s.close = nil
|
||||
s.mu.Unlock()
|
||||
for _, fn := range closeFns {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStreamChunk(data any) ([]byte, error) {
|
||||
switch v := data.(type) {
|
||||
case nil:
|
||||
@@ -88,8 +117,18 @@ func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) {
|
||||
CreateBlobObjectURL: func(data []byte, contentType string) (string, error) {
|
||||
return d.gblob.CreateBlob(data, contentType)
|
||||
},
|
||||
CreateWritableStreamObjectURL: func() (string, error) {
|
||||
return d.gblob.CreateWritableStream(session)
|
||||
CreateWritableStreamObjectURL: func(opts *stream.WritableStreamObjectURLOptions) (string, error) {
|
||||
reopenable := false
|
||||
if opts != nil {
|
||||
reopenable = opts.Reopenable
|
||||
}
|
||||
return d.gblob.CreateWritableStream(&gblob.CreateWritableStreamOptions{
|
||||
Session: session,
|
||||
Reopenable: reopenable,
|
||||
})
|
||||
},
|
||||
RegisterWritableStreamResume: func(url string, reopen func(offset int64) error) error {
|
||||
return d.gblob.SetResumeOpener(url, reopen)
|
||||
},
|
||||
WriteWritableStreamObjectURL: func(url string, data any) error {
|
||||
chunk, err := normalizeStreamChunk(data)
|
||||
@@ -107,6 +146,7 @@ func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) {
|
||||
RevokeObjectURL: func(url string) error {
|
||||
return d.gblob.Revoke(url)
|
||||
},
|
||||
ProxyHandler: d.cfg.Proxy.ToHandler(),
|
||||
}
|
||||
e := engine.NewEngine(&engine.Config{
|
||||
ProxyConfig: d.cfg.Proxy,
|
||||
@@ -115,3 +155,19 @@ func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) {
|
||||
session.SetEngine(e)
|
||||
return e, session
|
||||
}
|
||||
|
||||
func (d *Downloader) newExtensionWebViewRuntime(session *engineSession) *enginewebview.Runtime {
|
||||
var (
|
||||
opener enginewebview.Opener
|
||||
available bool
|
||||
)
|
||||
if provider := d.cfg.WebViewProvider; provider != nil && provider.IsAvailable() {
|
||||
opener = provider
|
||||
available = true
|
||||
}
|
||||
runtime := enginewebview.NewRuntime(opener, available)
|
||||
session.OnClose(func() {
|
||||
_ = runtime.Close()
|
||||
})
|
||||
return runtime
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func injectGopeed(vm *goja.Runtime, gopeed *Instance) error {
|
||||
gopeedObject := vm.NewObject()
|
||||
if gopeed == nil {
|
||||
return vm.Set("gopeed", gopeedObject)
|
||||
}
|
||||
if err := gopeedObject.Set("events", newJSEventsRuntime(vm, gopeed.Events)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gopeedObject.Set("info", gopeed.Info); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gopeedObject.Set("logger", gopeed.Logger); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gopeedObject.Set("settings", gopeed.Settings); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gopeedObject.Set("storage", gopeed.Storage); err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeObject := vm.NewObject()
|
||||
if gopeed.Runtime != nil && gopeed.Runtime.WebView != nil {
|
||||
if err := runtimeObject.Set("webview", newJSWebViewRuntime(vm, gopeed.Runtime.WebView)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := gopeedObject.Set("runtime", runtimeObject); err != nil {
|
||||
return err
|
||||
}
|
||||
return vm.Set("gopeed", gopeedObject)
|
||||
}
|
||||
|
||||
func newJSEventsRuntime(vm *goja.Runtime, events InstanceEvents) *goja.Object {
|
||||
obj := vm.NewObject()
|
||||
register := func(event ActivationEvent, call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 {
|
||||
panic(vm.ToValue(fmt.Errorf("missing handler")))
|
||||
}
|
||||
fnValue := call.Argument(0)
|
||||
exported, ok := fnValue.Export().(func(goja.FunctionCall) goja.Value)
|
||||
if !ok {
|
||||
panic(vm.ToValue(fmt.Errorf("handler must be a function")))
|
||||
}
|
||||
events.register(event, engine.JSFunction(exported))
|
||||
return goja.Undefined()
|
||||
}
|
||||
_ = obj.Set("onResolve", func(call goja.FunctionCall) goja.Value {
|
||||
return register(EventOnResolve, call)
|
||||
})
|
||||
_ = obj.Set("onStart", func(call goja.FunctionCall) goja.Value {
|
||||
return register(EventOnStart, call)
|
||||
})
|
||||
_ = obj.Set("onError", func(call goja.FunctionCall) goja.Value {
|
||||
return register(EventOnError, call)
|
||||
})
|
||||
_ = obj.Set("onDone", func(call goja.FunctionCall) goja.Value {
|
||||
return register(EventOnDone, call)
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
func newJSWebViewRuntime(vm *goja.Runtime, runtime *enginewebview.Runtime) *goja.Object {
|
||||
obj := vm.NewObject()
|
||||
_ = obj.Set("isAvailable", func(goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(runtime.IsAvailable())
|
||||
})
|
||||
_ = obj.Set("open", func(call goja.FunctionCall) goja.Value {
|
||||
page, err := runtime.Open(optionalMap(call.Argument(0)))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return newJSWebViewPage(vm, page)
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
func newJSWebViewPage(vm *goja.Runtime, page *enginewebview.PageHandle) *goja.Object {
|
||||
obj := vm.NewObject()
|
||||
_ = obj.Set("addInitScript", func(call goja.FunctionCall) goja.Value {
|
||||
script, err := requireStringArg(call, 0, "script")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
if err := page.AddInitScript(script); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("goto", func(call goja.FunctionCall) goja.Value {
|
||||
url, err := requireStringArg(call, 0, "url")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
if err := page.Goto(
|
||||
url,
|
||||
optionalMap(call.Argument(1)),
|
||||
); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("execute", func(call goja.FunctionCall) goja.Value {
|
||||
expression, err := enginewebview.NormalizeExecutableValue(call.Argument(0))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
result, err := page.Execute(expression, exportArgs(call.Arguments[1:])...)
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
_ = obj.Set("focus", func(call goja.FunctionCall) goja.Value {
|
||||
selector, err := requireStringArg(call, 0, "selector")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
if err := page.Focus(selector); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("click", func(call goja.FunctionCall) goja.Value {
|
||||
selector, err := requireStringArg(call, 0, "selector")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
if err := page.Click(selector, optionalMap(call.Argument(1))); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("type", func(call goja.FunctionCall) goja.Value {
|
||||
selector, err := requireStringArg(call, 0, "selector")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
text, err := requireStringArg(call, 1, "text")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
if err := page.Type(selector, text, optionalMap(call.Argument(2))); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("waitForSelector", func(call goja.FunctionCall) goja.Value {
|
||||
selector, err := requireStringArg(call, 0, "selector")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
result, err := page.WaitForSelector(
|
||||
selector,
|
||||
optionalMap(call.Argument(1)),
|
||||
)
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
_ = obj.Set("waitForFunction", func(call goja.FunctionCall) goja.Value {
|
||||
expression, err := enginewebview.NormalizeExecutableValue(call.Argument(0))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
args := exportArgs(call.Arguments[1:])
|
||||
result, err := page.WaitForFunction(expression, args...)
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
_ = obj.Set("getCookies", func(goja.FunctionCall) goja.Value {
|
||||
result, err := page.GetCookies()
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
_ = obj.Set("setCookie", func(call goja.FunctionCall) goja.Value {
|
||||
if err := page.SetCookie(optionalMap(call.Argument(0))); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("deleteCookie", func(call goja.FunctionCall) goja.Value {
|
||||
if err := page.DeleteCookie(optionalMap(call.Argument(0))); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("clearCookies", func(goja.FunctionCall) goja.Value {
|
||||
if err := page.ClearCookies(); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("url", func(goja.FunctionCall) goja.Value {
|
||||
result, err := page.URL()
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
_ = obj.Set("content", func(goja.FunctionCall) goja.Value {
|
||||
result, err := page.Content()
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
_ = obj.Set("close", func(goja.FunctionCall) goja.Value {
|
||||
if err := page.Close(); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
func optionalMap(value goja.Value) map[string]any {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return nil
|
||||
}
|
||||
if exported, ok := value.Export().(map[string]any); ok {
|
||||
return exported
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportArgs(values []goja.Value) []any {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
args := make([]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
args = append(args, value.Export())
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func requireStringArg(call goja.FunctionCall, index int, name string) (string, error) {
|
||||
value := call.Argument(index)
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return "", fmt.Errorf(`missing or invalid "%s"`, name)
|
||||
}
|
||||
return value.String(), nil
|
||||
}
|
||||
+1406
-84
File diff suppressed because it is too large.
Load diff
@@ -1,87 +0,0 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func NewExtMockDownloader() (*Downloader, func(), error) {
|
||||
d := NewDownloader(&DownloaderConfig{
|
||||
Storage: NewMemStorage(),
|
||||
})
|
||||
if err := d.Setup(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = d.Clear()
|
||||
}
|
||||
return d, cleanup, nil
|
||||
}
|
||||
|
||||
type ExtMockRuntime struct {
|
||||
engine engineWrapper
|
||||
session *engineSession
|
||||
}
|
||||
|
||||
type engineWrapper interface {
|
||||
Close()
|
||||
RunString(script string) (any, error)
|
||||
}
|
||||
|
||||
func (d *Downloader) NewExtMockRuntime() (*ExtMockRuntime, error) {
|
||||
engine, session := d.newExtensionEngine()
|
||||
ext := &Extension{
|
||||
Name: "extmock",
|
||||
Author: "gopeed",
|
||||
Title: "Gopeed ExtMock Runtime",
|
||||
Version: "0.0.0",
|
||||
DevMode: true,
|
||||
}
|
||||
gopeed := &Instance{
|
||||
Events: make(InstanceEvents),
|
||||
Info: NewExtensionInfo(ext),
|
||||
Logger: newInstanceLogger(ext, d.ExtensionLogger),
|
||||
Settings: map[string]any{},
|
||||
Storage: &ContextStorage{
|
||||
storage: d.storage,
|
||||
identity: ext.buildIdentity(),
|
||||
},
|
||||
}
|
||||
if err := engine.Runtime.Set("gopeed", gopeed); err != nil {
|
||||
engine.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &ExtMockRuntime{
|
||||
engine: engine,
|
||||
session: session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *ExtMockRuntime) Eval(script string) (any, error) {
|
||||
if r == nil || r.engine == nil {
|
||||
return nil, fmt.Errorf("extmock runtime not initialized")
|
||||
}
|
||||
return r.engine.RunString(script)
|
||||
}
|
||||
|
||||
func (r *ExtMockRuntime) EvalFile(path string) (any, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("script path is empty")
|
||||
}
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Eval(string(buf))
|
||||
}
|
||||
|
||||
func (r *ExtMockRuntime) Close() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
if r.engine != nil {
|
||||
r.engine.Close()
|
||||
}
|
||||
r.engine = nil
|
||||
r.session = nil
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/GopeedLab/gopeed/internal/protocol/gblob"
|
||||
"github.com/GopeedLab/gopeed/internal/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
)
|
||||
@@ -28,7 +29,6 @@ type Task struct {
|
||||
Status base.Status `json:"status"`
|
||||
Uploading bool `json:"uploading"`
|
||||
Progress *Progress `json:"progress"`
|
||||
IsCreated bool `json:"isCreated"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
@@ -51,7 +51,6 @@ func NewTask() *Task {
|
||||
Status: base.DownloadStatusReady,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
IsCreated: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +153,7 @@ type DownloaderConfig struct {
|
||||
Storage Storage
|
||||
StorageDir string
|
||||
WhiteDownloadDirs []string
|
||||
WebViewProvider enginewebview.Provider
|
||||
|
||||
ProductionMode bool
|
||||
|
||||
|
||||
+133
-12
@@ -1,4 +1,6 @@
|
||||
gopeed.events.onResolve(async function (ctx) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
if (ctx.req.url.endsWith("/blob")) {
|
||||
const blob = new Blob(["hello world"], {type: "text/plain"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -17,17 +19,132 @@ gopeed.events.onResolve(async function (ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transform = new TransformStream();
|
||||
const writer = transform.writable.getWriter();
|
||||
const url = URL.createObjectURL(transform.writable);
|
||||
if (ctx.req.url.includes("/http-stream")) {
|
||||
const source = new URL(ctx.req.url);
|
||||
const target = source.searchParams.get("target");
|
||||
const pair = source.searchParams.get("pair") === "1";
|
||||
const resumable = ctx.req.url.includes("/http-stream-range");
|
||||
const fileName = source.searchParams.get("name") || "http-stream.bin";
|
||||
const headResponse = await fetch(target, {method: "HEAD"});
|
||||
const headLength = parseInt(headResponse.headers.get("content-length") || "", 10);
|
||||
|
||||
const createOpen = () => async (offset) => {
|
||||
const headers = {};
|
||||
if (offset > 0) {
|
||||
headers.Range = `bytes=${offset}-`;
|
||||
}
|
||||
const response = await fetch(target, {headers});
|
||||
if (!response.body) {
|
||||
throw new Error("empty response body");
|
||||
}
|
||||
return response.body;
|
||||
};
|
||||
|
||||
ctx.res = {
|
||||
name: "gblob-http-stream",
|
||||
range: resumable,
|
||||
files: pair ? [
|
||||
{
|
||||
name: "pair-video.bin",
|
||||
size: Number.isFinite(headLength) ? headLength : 0,
|
||||
req: {
|
||||
url: URL.createObjectURL(createOpen()),
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "pair-audio.bin",
|
||||
size: Number.isFinite(headLength) ? headLength : 0,
|
||||
req: {
|
||||
url: URL.createObjectURL(createOpen()),
|
||||
}
|
||||
}
|
||||
] : [
|
||||
{
|
||||
name: fileName,
|
||||
size: Number.isFinite(headLength) ? headLength : 0,
|
||||
req: {
|
||||
url: URL.createObjectURL(createOpen()),
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.req.url.includes("/http-blob")) {
|
||||
const source = new URL(ctx.req.url);
|
||||
const target = source.searchParams.get("target");
|
||||
const fileName = source.searchParams.get("name") || "http-blob.bin";
|
||||
const headResponse = await fetch(target, {method: "HEAD"});
|
||||
const headLength = parseInt(headResponse.headers.get("content-length") || "", 10);
|
||||
const response = await fetch(target);
|
||||
const blob = await response.blob();
|
||||
|
||||
ctx.res = {
|
||||
name: "gblob-http-blob",
|
||||
files: [
|
||||
{
|
||||
name: fileName,
|
||||
size: Number.isFinite(headLength) ? headLength : 0,
|
||||
req: {
|
||||
url: URL.createObjectURL(blob),
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.req.url.endsWith("/stream-range")) {
|
||||
const payload = encoder.encode("line 1\nline 2\nline 3\n");
|
||||
const firstChunkLength = encoder.encode("line 1\n").byteLength;
|
||||
let firstAttempt = true;
|
||||
|
||||
const open = async (offset) => new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
if (offset === 0 && firstAttempt) {
|
||||
firstAttempt = false;
|
||||
controller.enqueue(payload.slice(0, firstChunkLength));
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
controller.error(new Error("resume required"));
|
||||
return;
|
||||
}
|
||||
controller.enqueue(payload.slice(offset));
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(open);
|
||||
|
||||
ctx.res = {
|
||||
name: "gblob-stream-range",
|
||||
range: true,
|
||||
files: [
|
||||
{
|
||||
name: "stream-range.txt",
|
||||
size: payload.byteLength,
|
||||
req: {
|
||||
url,
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.req.url.endsWith("/stream-unknown")) {
|
||||
(async () => {
|
||||
await writer.write(new TextEncoder().encode("line 1\n"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
await writer.write(new TextEncoder().encode("line 2\n"));
|
||||
await writer.close();
|
||||
})();
|
||||
const url = URL.createObjectURL(new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue(encoder.encode("line 1\n"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
controller.enqueue(encoder.encode("line 2\n"));
|
||||
controller.close();
|
||||
},
|
||||
}));
|
||||
|
||||
ctx.res = {
|
||||
name: "gblob-stream-unknown",
|
||||
@@ -43,9 +160,13 @@ gopeed.events.onResolve(async function (ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.write(new TextEncoder().encode("line 1\n"));
|
||||
await writer.write(new TextEncoder().encode("line 2\n"));
|
||||
await writer.close();
|
||||
const url = URL.createObjectURL(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode("line 1\n"));
|
||||
controller.enqueue(encoder.encode("line 2\n"));
|
||||
controller.close();
|
||||
},
|
||||
}));
|
||||
|
||||
ctx.res = {
|
||||
name: "gblob-stream",
|
||||
|
||||
+7
-9
@@ -3,15 +3,13 @@ gopeed.events.onResolve(async function (ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transform = new TransformStream();
|
||||
const writer = transform.writable.getWriter();
|
||||
const url = URL.createObjectURL(transform.writable);
|
||||
|
||||
(async () => {
|
||||
await writer.write(new TextEncoder().encode("stale\n"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await writer.abort("expired");
|
||||
})();
|
||||
const url = URL.createObjectURL(new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("stale\n"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
controller.error(new Error("expired"));
|
||||
},
|
||||
}));
|
||||
|
||||
ctx.res = {
|
||||
name: "gblob-recover",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
function createRangeGBlobUrl(target) {
|
||||
return URL.createObjectURL(async (offset) => {
|
||||
const headers = {};
|
||||
if (offset > 0) {
|
||||
headers.Range = `bytes=${offset}-`;
|
||||
}
|
||||
const response = await fetch(target, { headers });
|
||||
if (!response.body) {
|
||||
throw new Error('empty response body');
|
||||
}
|
||||
return response.body;
|
||||
});
|
||||
}
|
||||
|
||||
gopeed.events.onResolve(async function (ctx) {
|
||||
if (!ctx.req.url.includes('/restart-range')) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.res = {
|
||||
name: 'gblob-restart',
|
||||
range: true,
|
||||
files: [
|
||||
{
|
||||
name: 'restart.bin',
|
||||
size: 262144,
|
||||
req: {
|
||||
url: createRangeGBlobUrl(ctx.req.rawUrl || ctx.req.url),
|
||||
rawUrl: ctx.req.rawUrl || ctx.req.url,
|
||||
labels: {
|
||||
mode: 'restart',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
gopeed.events.onError(async function (ctx) {
|
||||
const req = ctx.task?.meta?.req;
|
||||
if (!req || !req.rawUrl || !req.url || !req.url.startsWith('gblob:')) {
|
||||
return;
|
||||
}
|
||||
req.labels = req.labels || {};
|
||||
if (req.labels.rebuilt === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
req.url = createRangeGBlobUrl(req.rawUrl);
|
||||
req.labels.started = 'true';
|
||||
req.labels.rebuilt = 'true';
|
||||
ctx.task.continue();
|
||||
} catch (error) {
|
||||
req.labels.rebuildError = String(error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "gblob-restart",
|
||||
"title": "gopeed extension gblob restart resume test",
|
||||
"version": "0.0.1",
|
||||
"scripts": [
|
||||
{
|
||||
"event": "onResolve",
|
||||
"match": {
|
||||
"urls": [
|
||||
"*://*/restart-range*"
|
||||
]
|
||||
},
|
||||
"entry": "index.js"
|
||||
},
|
||||
{
|
||||
"event": "onError",
|
||||
"match": {
|
||||
"labels": [
|
||||
"mode"
|
||||
],
|
||||
"urls": [
|
||||
"*://*/restart-range*"
|
||||
]
|
||||
},
|
||||
"entry": "index.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
File mode changed.
File mode changed.
File mode changed.
File mode changed.
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
import (
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
@@ -21,8 +22,10 @@ type StartConfig struct {
|
||||
WhiteDownloadDirs []string `json:"whiteDownloadDirs"`
|
||||
ApiToken string `json:"apiToken"`
|
||||
DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
|
||||
WebViewRPCConfig *enginewebview.RPCConfig `json:"webViewRpcConfig,omitempty"`
|
||||
|
||||
ProductionMode bool
|
||||
ProductionMode bool
|
||||
WebViewProvider enginewebview.Provider `json:"-"`
|
||||
|
||||
WebEnable bool
|
||||
WebFS fs.FS
|
||||
|
||||
@@ -89,6 +89,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
ProductionMode: startCfg.ProductionMode,
|
||||
RefreshInterval: startCfg.RefreshInterval,
|
||||
WhiteDownloadDirs: startCfg.WhiteDownloadDirs,
|
||||
WebViewProvider: startCfg.WebViewProvider,
|
||||
}
|
||||
if startCfg.Storage == model.StorageBolt {
|
||||
downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
@@ -288,6 +289,10 @@ func TestPatchTaskNotFound(t *testing.T) {
|
||||
|
||||
func TestPauseAllAndContinueALLTasks(t *testing.T) {
|
||||
doTest(func() {
|
||||
slowListener := test.StartTestLowSpeedServer(5 * time.Nanosecond)
|
||||
defer slowListener.Close()
|
||||
taskReq.URL = "http://" + slowListener.Addr().String() + "/" + test.BuildName
|
||||
|
||||
cfg, err := Downloader.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -875,6 +880,55 @@ func TestAuthorization(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerPropagatesWebViewProvider(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := listener.Addr().String()
|
||||
listener.Close()
|
||||
|
||||
cfg := &model.StartConfig{
|
||||
Network: "tcp",
|
||||
Address: addr,
|
||||
Storage: model.StorageMem,
|
||||
WebViewProvider: fakeRestWebViewProvider{available: true},
|
||||
}
|
||||
|
||||
server, serverListener, err := BuildServer(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer serverListener.Close()
|
||||
defer server.Close()
|
||||
defer func() {
|
||||
if Downloader != nil {
|
||||
Downloader.Clear()
|
||||
Downloader = nil
|
||||
}
|
||||
}()
|
||||
|
||||
runtime, err := Downloader.NewExtensionEngine(&download.Extension{
|
||||
Name: "test-runtime",
|
||||
Author: "gopeed",
|
||||
Title: "Gopeed Test Script Runtime",
|
||||
Version: "0.0.0",
|
||||
DevMode: true,
|
||||
}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
|
||||
value, err := runtime.Eval("gopeed.runtime.webview.isAvailable()")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != true {
|
||||
t.Fatalf("expected provider to reach downloader runtime, got %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func doTest(handler func()) {
|
||||
doTest0(nil, handler)
|
||||
}
|
||||
@@ -919,6 +973,52 @@ func doStart(cfg *model.StartConfig) net.Listener {
|
||||
return test.StartTestFileServer()
|
||||
}
|
||||
|
||||
type fakeRestWebViewProvider struct {
|
||||
available bool
|
||||
}
|
||||
|
||||
func (p fakeRestWebViewProvider) IsAvailable() bool {
|
||||
return p.available
|
||||
}
|
||||
|
||||
func (p fakeRestWebViewProvider) Open(enginewebview.OpenOptions) (enginewebview.Page, error) {
|
||||
return fakeRestPage{}, nil
|
||||
}
|
||||
|
||||
type fakeRestPage struct{}
|
||||
|
||||
func (fakeRestPage) AddInitScript(string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) Goto(string, enginewebview.GotoOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) Execute(string, ...any) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) GetCookies() ([]enginewebview.Cookie, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) SetCookie(enginewebview.Cookie) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) DeleteCookie(enginewebview.Cookie) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) ClearCookies() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func doHttpRequest0(method string, path string, headers map[string]string, body any) (int, []byte) {
|
||||
r1, _, r3 := doHttpRequest1(method, path, headers, body)
|
||||
return r1, r3
|
||||
|
||||
Reference in new issue
Block a user