feat: add WebView support for extension (#1359)

This commit is contained in:
Levi authored and GitHub committed 2026-04-24 22:16:33 +08:00
1 parent a205c0b8d7
commit def54f8998
92 files changed
+10248 -2676

No files matched your search

+47 -8
View File
@@ -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 {
+68 -18
View File
@@ -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
View File
@@ -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
}
+164 -1
View File
@@ -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)
+422 -16
View File
@@ -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()
}
+556 -23
View File
@@ -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;
}
})();
+8 -1
View File
@@ -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
View File
@@ -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
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;
}
+3 -1
View File
@@ -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"
+20
View File
@@ -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
}
+143
View File
@@ -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{}
+765
View File
@@ -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{}
}
+338
View File
@@ -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
View File
@@ -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)
}
+75
View File
@@ -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
}
+58 -2
View File
@@ -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
}
+258
View File
@@ -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
}
File diff suppressed because it is too large. Load diff
-87
View File
@@ -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
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+58
View File
@@ -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"
}
]
}
Vendored Regular → Executable
View File
File mode changed.
Vendored Regular → Executable
View File
File mode changed.
Vendored Regular → Executable
View File
File mode changed.
Vendored Regular → Executable
View File
File mode changed.
+4 -1
View File
@@ -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
+1
View File
@@ -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)
+100
View File
@@ -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