mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-15 21:03:41 +00:00
feat: add WebView support for extension (#1359)
This commit is contained in:
92 files changed
+10248
-2676
No files matched your search
@@ -15,7 +15,8 @@ The `gblob` protocol provides an internal object-URL style transport so extensio
|
||||
The extension-facing model is intentionally close to the browser:
|
||||
|
||||
- `URL.createObjectURL(new Blob(...))`
|
||||
- `URL.createObjectURL(writableStream)`
|
||||
- `URL.createObjectURL(readableStream)`
|
||||
- `URL.createObjectURL(openReadable)`
|
||||
- `URL.revokeObjectURL(url)`
|
||||
|
||||
The returned URL looks like:
|
||||
@@ -29,7 +30,7 @@ gblob:<id>
|
||||
## Design Goals
|
||||
|
||||
- make extension-generated data download as a normal task
|
||||
- support both eager `Blob` data and incremental `WritableStream` data
|
||||
- support eager `Blob` data, one-shot `ReadableStream` data, and resumable opener functions
|
||||
- avoid large in-memory buffering by spooling to disk
|
||||
- keep pause/continue behavior intuitive
|
||||
- allow an extension to recreate a new `gblob` URL after source expiration
|
||||
@@ -43,7 +44,7 @@ The implementation has two layers in this directory:
|
||||
|
||||
The end-to-end flow is:
|
||||
|
||||
1. JavaScript creates a `Blob` or `WritableStream` object URL.
|
||||
1. JavaScript creates a `Blob`, `ReadableStream`, or opener-function object URL.
|
||||
2. Go registers a `gblob` source and assigns it a unique `gblob:<id>` URL.
|
||||
3. Source bytes are written into a local spool file.
|
||||
4. The downloader resolves the `gblob` URL through the `gblob` fetcher.
|
||||
@@ -67,7 +68,8 @@ Each source tracks:
|
||||
Key behaviors:
|
||||
|
||||
- `Blob` sources are written once and immediately closed.
|
||||
- `WritableStream` sources stay open until the writer closes or aborts.
|
||||
- `ReadableStream` sources are bridged into the internal writable-source pipeline.
|
||||
- opener-function sources are opened lazily and can be reopened from an offset.
|
||||
- readers wait on a per-source notification channel when no new bytes are available yet.
|
||||
- revocation only prevents future use; cleanup happens when the source is no longer pinned.
|
||||
|
||||
@@ -82,9 +84,9 @@ The fetcher treats `gblob` as a local file-backed download source.
|
||||
Behavior differences by source type:
|
||||
|
||||
- `Blob`: resolved size is the current written size
|
||||
- `WritableStream`: resolved size is reported as `0` until the writer closes
|
||||
- `ReadableStream` / opener function: resolved size is whatever the extension declared; if unknown, it stays `0` until download completion updates the final size
|
||||
|
||||
`Range` is always `false`.
|
||||
`Range` is `true` only when the extension provides a resumable opener and marks the resource as ranged.
|
||||
|
||||
### Start
|
||||
|
||||
@@ -123,7 +125,12 @@ For the same `gblob` id:
|
||||
|
||||
- the partially downloaded output file is kept
|
||||
- the fetcher resumes from the current file size
|
||||
- `WritableStream` producers may continue writing while the task is paused
|
||||
- opener-function sources can be reopened from the current offset
|
||||
|
||||
For non-reopenable one-shot `ReadableStream` sources:
|
||||
|
||||
- a normal in-process pause/continue only works while the same in-memory source is still alive
|
||||
- they are not suitable for cross-session or reconstructable range resume
|
||||
|
||||
### Process restart
|
||||
|
||||
@@ -172,19 +179,19 @@ gopeed.events.onResolve(async function (ctx) {
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Create a streaming object URL
|
||||
### 2. Create a one-shot `ReadableStream` object URL
|
||||
|
||||
```js
|
||||
gopeed.events.onResolve(async function (ctx) {
|
||||
const stream = new TransformStream();
|
||||
const writer = stream.writable.getWriter();
|
||||
const url = URL.createObjectURL(stream.writable);
|
||||
|
||||
(async () => {
|
||||
await writer.write(new TextEncoder().encode("line 1\n"));
|
||||
await writer.write(new TextEncoder().encode("line 2\n"));
|
||||
await writer.close();
|
||||
})();
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode("line 1\n"));
|
||||
controller.enqueue(encoder.encode("line 2\n"));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
const url = URL.createObjectURL(stream);
|
||||
|
||||
ctx.res = {
|
||||
name: "stream-example",
|
||||
@@ -198,14 +205,53 @@ gopeed.events.onResolve(async function (ctx) {
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Recover in `onError`
|
||||
### 3. Create a resumable opener object URL
|
||||
|
||||
```js
|
||||
gopeed.events.onResolve(async function (ctx) {
|
||||
const target = "https://example.com/file.bin";
|
||||
|
||||
const openReadable = async (offset) => {
|
||||
const headers = {};
|
||||
if (offset > 0) {
|
||||
headers.Range = `bytes=${offset}-`;
|
||||
}
|
||||
const response = await fetch(target, { headers });
|
||||
if (response.status !== 200 || response.status !== 206) {
|
||||
throw new Error(`unexpected response status: ${response.status}, expected ${expectedStatus}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("empty response body");
|
||||
}
|
||||
return response.body;
|
||||
};
|
||||
|
||||
ctx.res = {
|
||||
name: "range-example",
|
||||
range: true,
|
||||
files: [
|
||||
{
|
||||
name: "file.bin",
|
||||
req: { url: URL.createObjectURL(openReadable) }
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
Important:
|
||||
|
||||
- opener functions must return a `ReadableStream`
|
||||
- do not return `response.body.getReader()`
|
||||
- use opener functions for resumable / range-aware sources
|
||||
- validate HTTP status codes explicitly:
|
||||
first request usually expects `200`, resumed range requests usually expect `206`
|
||||
|
||||
### 4. Recover in `onError`
|
||||
|
||||
```js
|
||||
gopeed.events.onError(async function (ctx) {
|
||||
const req = ctx.task.meta.req;
|
||||
if (!req.rawUrl) return;
|
||||
if (!req.url.startsWith("gblob:")) return;
|
||||
|
||||
req.url = URL.createObjectURL(new Blob(["recovered\n"]));
|
||||
ctx.task.continue();
|
||||
});
|
||||
@@ -215,11 +261,11 @@ This creates a new `gblob` id, so the downloader restarts from byte `0`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- `gblob` is not a network protocol and does not support range requests.
|
||||
- `gblob` is not a network protocol. Range support only exists when the extension recreates bytes through a resumable opener.
|
||||
- unfinished `gblob` sources do not survive process restarts.
|
||||
- recovery after restart depends on extension logic recreating a new source.
|
||||
- the current restart behavior is full restart on source-id change, not byte-range recovery across recreated sources.
|
||||
- `WritableStream` tasks with unknown total size report size `0` until the writer closes.
|
||||
- one-shot `ReadableStream` tasks with unknown total size report size `0` until completion updates the final size.
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -231,3 +277,9 @@ The most important rule is simple:
|
||||
|
||||
- same `gblob` id means resume
|
||||
- different `gblob` id means restart
|
||||
|
||||
And for extension authors:
|
||||
|
||||
- use `ReadableStream` for one-shot streaming content
|
||||
- use opener functions for resumable content
|
||||
- opener functions must return `ReadableStream`, not a reader
|
||||
@@ -62,15 +62,21 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
if name == "" {
|
||||
name = src.ID
|
||||
}
|
||||
size := src.Snapshot().Written
|
||||
snapshot := src.Snapshot()
|
||||
size := snapshot.Written
|
||||
if snapshot.DeclaredSize > 0 {
|
||||
size = snapshot.DeclaredSize
|
||||
}
|
||||
if src.Type == SourceTypeWritableStream {
|
||||
// Writable streams may continue producing bytes after Resolve,
|
||||
// so treat the total size as unknown until the writer closes.
|
||||
size = 0
|
||||
if snapshot.DeclaredSize == 0 {
|
||||
size = 0
|
||||
}
|
||||
}
|
||||
f.meta.Res = &base.Resource{
|
||||
Size: size,
|
||||
Range: false,
|
||||
Range: snapshot.Range,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: name,
|
||||
@@ -102,45 +108,90 @@ func (f *Fetcher) Start() error {
|
||||
f.cancel = cancel
|
||||
f.runDone = runDone
|
||||
f.mu.Unlock()
|
||||
cleanupStartErr := func(err error) error {
|
||||
f.mu.Lock()
|
||||
if f.runDone == runDone {
|
||||
f.runDone = nil
|
||||
f.cancel = nil
|
||||
}
|
||||
f.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
path := f.meta.SingleFilepath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
target, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
offset := int64(0)
|
||||
if f.lastSourceID != "" && f.lastSourceID != currentSourceID {
|
||||
allowResumeAcrossSource := f.meta.Res != nil && f.meta.Res.Range
|
||||
if f.lastSourceID != "" && f.lastSourceID != currentSourceID && !allowResumeAcrossSource {
|
||||
if err := target.Truncate(0); err != nil {
|
||||
target.Close()
|
||||
return err
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
if _, err := target.Seek(0, io.SeekStart); err != nil {
|
||||
target.Close()
|
||||
return err
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
} else {
|
||||
stat, err := target.Stat()
|
||||
if err != nil {
|
||||
target.Close()
|
||||
return err
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
offset = stat.Size()
|
||||
if _, err := target.Seek(offset, io.SeekStart); err != nil {
|
||||
target.Close()
|
||||
return err
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
}
|
||||
f.lastSourceID = currentSourceID
|
||||
f.downloaded.Store(offset)
|
||||
|
||||
go f.copyLoop(ctx, target, offset)
|
||||
sourceOffset := offset
|
||||
src, err := f.registry.Get(f.meta.Req.URL)
|
||||
if err != nil {
|
||||
target.Close()
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
snapshot := src.Snapshot()
|
||||
if snapshot.Written == 0 && !snapshot.Started {
|
||||
if f.meta.Res != nil && f.meta.Res.Range && offset > 0 {
|
||||
if err := f.registry.Reopen(f.meta.Req.URL, offset); err != nil {
|
||||
target.Close()
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
sourceOffset = 0
|
||||
} else if err := f.registry.StartSource(f.meta.Req.URL); err != nil {
|
||||
target.Close()
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
src, err = f.registry.Get(f.meta.Req.URL)
|
||||
if err != nil {
|
||||
target.Close()
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
snapshot = src.Snapshot()
|
||||
}
|
||||
if f.meta.Res != nil && f.meta.Res.Range && (snapshot.State == SourceStateAborted || snapshot.State == SourceStateFailed) {
|
||||
if err := f.registry.Reopen(f.meta.Req.URL, offset); err != nil {
|
||||
target.Close()
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
sourceOffset = 0
|
||||
}
|
||||
|
||||
go f.copyLoop(ctx, target, offset, sourceOffset)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) {
|
||||
func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, targetOffset, sourceOffset int64) {
|
||||
var finalErr error
|
||||
|
||||
defer target.Close()
|
||||
defer func() {
|
||||
f.mu.Lock()
|
||||
@@ -161,85 +212,75 @@ func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) {
|
||||
if paused {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- finalErr:
|
||||
default:
|
||||
}
|
||||
}()
|
||||
|
||||
src, err := f.registry.Get(f.meta.Req.URL)
|
||||
if err != nil {
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
finalErr = err
|
||||
return
|
||||
}
|
||||
sourceFile, err := os.Open(src.Path)
|
||||
if err != nil {
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
finalErr = err
|
||||
return
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
if err := f.registry.WaitForReadable(ctx, f.meta.Req.URL, offset); err != nil {
|
||||
if err := f.registry.WaitForReadable(ctx, f.meta.Req.URL, sourceOffset); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
finalErr = err
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, serr := f.registry.Get(f.meta.Req.URL)
|
||||
if serr != nil {
|
||||
select {
|
||||
case f.doneCh <- serr:
|
||||
default:
|
||||
}
|
||||
finalErr = serr
|
||||
return
|
||||
}
|
||||
current := snapshot.Snapshot()
|
||||
for offset < current.Written {
|
||||
remain := current.Written - offset
|
||||
for sourceOffset < current.Written {
|
||||
remain := current.Written - sourceOffset
|
||||
readSize := int64(len(buf))
|
||||
if remain < readSize {
|
||||
readSize = remain
|
||||
}
|
||||
n, err := sourceFile.ReadAt(buf[:readSize], offset)
|
||||
n, err := sourceFile.ReadAt(buf[:readSize], sourceOffset)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
finalErr = err
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
if _, err := target.Write(buf[:n]); err != nil {
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
finalErr = err
|
||||
return
|
||||
}
|
||||
offset += int64(n)
|
||||
f.downloaded.Store(offset)
|
||||
sourceOffset += int64(n)
|
||||
targetOffset += int64(n)
|
||||
f.downloaded.Store(targetOffset)
|
||||
}
|
||||
|
||||
if current.State == SourceStateClosed && offset >= current.Written {
|
||||
if current.State == SourceStateClosed && sourceOffset >= current.Written {
|
||||
if f.meta.Res != nil {
|
||||
f.meta.Res.Size = current.Written
|
||||
f.meta.Res.Files[0].Size = current.Written
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- nil:
|
||||
default:
|
||||
if f.meta.Res.Size > 0 && targetOffset < f.meta.Res.Size {
|
||||
finalErr = io.ErrUnexpectedEOF
|
||||
return
|
||||
}
|
||||
if f.meta.Res.Size == 0 {
|
||||
f.meta.Res.Size = targetOffset
|
||||
}
|
||||
f.meta.Res.Files[0].Size = f.meta.Res.Size
|
||||
}
|
||||
finalErr = nil
|
||||
return
|
||||
}
|
||||
if current.State == SourceStateAborted || current.State == SourceStateFailed {
|
||||
@@ -247,10 +288,7 @@ func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) {
|
||||
if err == nil {
|
||||
err = ErrSourceAborted
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
finalErr = err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package gblob
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
internalfetcher "github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestFetcherClosedSourceBeforeExpectedSizeReturnsError(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
url, err := registry.CreateWritableStream(&CreateWritableStreamOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Write(url, []byte("abc")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.CloseSource(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
filePath := filepath.Join(downloadDir, "partial.bin")
|
||||
f := &Fetcher{
|
||||
registry: registry,
|
||||
meta: &internalfetcher.FetcherMeta{
|
||||
Req: &base.Request{URL: url},
|
||||
Res: &base.Resource{
|
||||
Size: 10,
|
||||
Range: true,
|
||||
Files: []*base.FileInfo{{Name: "partial.bin", Req: &base.Request{URL: url}}},
|
||||
},
|
||||
Opts: &base.Options{Path: downloadDir, Name: "partial.bin"},
|
||||
},
|
||||
}
|
||||
f.Setup(controller.NewController())
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-f.doneCh:
|
||||
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("expected unexpected EOF, got %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for fetcher completion")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "abc" {
|
||||
t.Fatalf("unexpected partial file content: %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherClosedUnknownSizeSourceCompletesSuccessfully(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
url, err := registry.CreateWritableStream(&CreateWritableStreamOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Write(url, []byte("abcdef")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.CloseSource(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
filePath := filepath.Join(downloadDir, "stream.bin")
|
||||
f := &Fetcher{
|
||||
registry: registry,
|
||||
meta: &internalfetcher.FetcherMeta{
|
||||
Req: &base.Request{URL: url},
|
||||
Res: &base.Resource{
|
||||
Size: 0,
|
||||
Range: false,
|
||||
Files: []*base.FileInfo{{Name: "stream.bin", Req: &base.Request{URL: url}}},
|
||||
},
|
||||
Opts: &base.Options{Path: downloadDir, Name: "stream.bin"},
|
||||
},
|
||||
}
|
||||
f.Setup(controller.NewController())
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-f.doneCh:
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil completion error, got %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for fetcher completion")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "abcdef" {
|
||||
t.Fatalf("unexpected stream file content: %q", string(data))
|
||||
}
|
||||
if got := f.meta.Res.Size; got != int64(len(data)) {
|
||||
t.Fatalf("unexpected resolved size: got %d want %d", got, len(data))
|
||||
}
|
||||
if got := f.meta.Res.Files[0].Size; got != int64(len(data)) {
|
||||
t.Fatalf("unexpected resolved file size: got %d want %d", got, len(data))
|
||||
}
|
||||
}
|
||||
@@ -42,11 +42,21 @@ type SessionRef interface {
|
||||
Release()
|
||||
}
|
||||
|
||||
type ResumeFunc func(offset int64) error
|
||||
|
||||
type CreateWritableStreamOptions struct {
|
||||
Session SessionRef
|
||||
Reopenable bool
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Written int64
|
||||
State SourceState
|
||||
Err error
|
||||
WaitCh <-chan struct{}
|
||||
Written int64
|
||||
DeclaredSize int64
|
||||
Range bool
|
||||
Started bool
|
||||
State SourceState
|
||||
Err error
|
||||
WaitCh <-chan struct{}
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
@@ -66,16 +76,24 @@ type Source struct {
|
||||
waitCh chan struct{}
|
||||
session SessionRef
|
||||
sessionReleased bool
|
||||
reopenable bool
|
||||
rangeEnabled bool
|
||||
declaredSize int64
|
||||
started bool
|
||||
reopen ResumeFunc
|
||||
}
|
||||
|
||||
func (s *Source) Snapshot() Snapshot {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return Snapshot{
|
||||
Written: s.written,
|
||||
State: s.state,
|
||||
Err: s.err,
|
||||
WaitCh: s.waitCh,
|
||||
Written: s.written,
|
||||
DeclaredSize: s.declaredSize,
|
||||
Range: s.rangeEnabled,
|
||||
Started: s.started,
|
||||
State: s.state,
|
||||
Err: s.err,
|
||||
WaitCh: s.waitCh,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +140,13 @@ func (s *Source) releaseSessionLocked() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) retainSessionLocked() {
|
||||
if s.session != nil && s.sessionReleased {
|
||||
s.session.Retain()
|
||||
s.sessionReleased = false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) write(data []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -146,7 +171,9 @@ func (s *Source) write(data []byte) error {
|
||||
s.err = err
|
||||
s.file.Close()
|
||||
s.file = nil
|
||||
s.releaseSessionLocked()
|
||||
if !s.rangeEnabled {
|
||||
s.releaseSessionLocked()
|
||||
}
|
||||
s.notifyLocked()
|
||||
return err
|
||||
}
|
||||
@@ -187,7 +214,9 @@ func (s *Source) abort(err error) error {
|
||||
_ = s.file.Close()
|
||||
s.file = nil
|
||||
}
|
||||
s.releaseSessionLocked()
|
||||
if !s.rangeEnabled {
|
||||
s.releaseSessionLocked()
|
||||
}
|
||||
s.notifyLocked()
|
||||
return nil
|
||||
}
|
||||
@@ -196,6 +225,18 @@ func (s *Source) revoke() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.revoked = true
|
||||
if s.state == SourceStateOpen {
|
||||
s.state = SourceStateAborted
|
||||
if s.err == nil {
|
||||
s.err = ErrSourceRevoked
|
||||
}
|
||||
if s.file != nil {
|
||||
_ = s.file.Close()
|
||||
s.file = nil
|
||||
}
|
||||
s.releaseSessionLocked()
|
||||
s.notifyLocked()
|
||||
}
|
||||
return s.shouldCleanupLocked()
|
||||
}
|
||||
|
||||
@@ -229,6 +270,13 @@ type Registry struct {
|
||||
sources map[string]*Source
|
||||
}
|
||||
|
||||
func (r *Registry) Dir() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.dir
|
||||
}
|
||||
|
||||
func NewRegistry(baseDir string) *Registry {
|
||||
dir := baseDir
|
||||
if dir == "" {
|
||||
@@ -258,7 +306,7 @@ func ParseURL(raw string) (string, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *Registry) createSource(sourceType SourceType, contentType string, session SessionRef) (*Source, error) {
|
||||
func (r *Registry) createSource(sourceType SourceType, contentType string, session SessionRef, reopenable bool, declaredSize int64) (*Source, error) {
|
||||
if err := os.MkdirAll(r.dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -275,15 +323,19 @@ func (r *Registry) createSource(sourceType SourceType, contentType string, sessi
|
||||
session.Retain()
|
||||
}
|
||||
src := &Source{
|
||||
ID: id,
|
||||
URL: BuildURL(id),
|
||||
Path: filePath,
|
||||
Type: sourceType,
|
||||
ContentType: contentType,
|
||||
file: file,
|
||||
state: SourceStateOpen,
|
||||
waitCh: make(chan struct{}),
|
||||
session: session,
|
||||
ID: id,
|
||||
URL: BuildURL(id),
|
||||
Path: filePath,
|
||||
Type: sourceType,
|
||||
ContentType: contentType,
|
||||
file: file,
|
||||
state: SourceStateOpen,
|
||||
waitCh: make(chan struct{}),
|
||||
session: session,
|
||||
reopenable: reopenable,
|
||||
rangeEnabled: reopenable,
|
||||
declaredSize: declaredSize,
|
||||
started: !reopenable,
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.sources[id] = src
|
||||
@@ -292,7 +344,7 @@ func (r *Registry) createSource(sourceType SourceType, contentType string, sessi
|
||||
}
|
||||
|
||||
func (r *Registry) CreateBlob(data []byte, contentType string) (string, error) {
|
||||
src, err := r.createSource(SourceTypeBlob, contentType, nil)
|
||||
src, err := r.createSource(SourceTypeBlob, contentType, nil, false, int64(len(data)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -307,14 +359,59 @@ func (r *Registry) CreateBlob(data []byte, contentType string) (string, error) {
|
||||
return src.URL, nil
|
||||
}
|
||||
|
||||
func (r *Registry) CreateWritableStream(session SessionRef) (string, error) {
|
||||
src, err := r.createSource(SourceTypeWritableStream, "", session)
|
||||
func (r *Registry) CreateWritableStream(opts *CreateWritableStreamOptions) (string, error) {
|
||||
if opts == nil {
|
||||
opts = &CreateWritableStreamOptions{}
|
||||
}
|
||||
src, err := r.createSource(SourceTypeWritableStream, "", opts.Session, opts.Reopenable, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return src.URL, nil
|
||||
}
|
||||
|
||||
func (r *Registry) SetResumeOpener(raw string, reopen ResumeFunc) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src.mu.Lock()
|
||||
defer src.mu.Unlock()
|
||||
if !src.reopenable {
|
||||
return nil
|
||||
}
|
||||
src.reopen = reopen
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) SetRange(raw string, enabled bool) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src.mu.Lock()
|
||||
src.rangeEnabled = enabled
|
||||
if !enabled && (src.state == SourceStateAborted || src.state == SourceStateFailed) {
|
||||
src.releaseSessionLocked()
|
||||
}
|
||||
src.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) SetSize(raw string, size int64) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src.mu.Lock()
|
||||
if size < 0 {
|
||||
size = 0
|
||||
}
|
||||
src.declaredSize = size
|
||||
src.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) get(raw string) (*Source, error) {
|
||||
id, err := ParseURL(raw)
|
||||
if err != nil {
|
||||
@@ -405,10 +502,80 @@ func (r *Registry) WaitForReadable(ctx context.Context, raw string, offset int64
|
||||
return src.waitForReadable(ctx, offset)
|
||||
}
|
||||
|
||||
func (r *Registry) Reopen(raw string, offset int64) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
src.mu.Lock()
|
||||
if !src.reopenable || src.reopen == nil {
|
||||
src.mu.Unlock()
|
||||
return ErrSourceNotFound
|
||||
}
|
||||
if src.revoked {
|
||||
src.mu.Unlock()
|
||||
return ErrSourceRevoked
|
||||
}
|
||||
if src.file != nil {
|
||||
_ = src.file.Close()
|
||||
src.file = nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(src.Path), 0755); err != nil {
|
||||
src.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
file, err := os.Create(src.Path)
|
||||
if err != nil {
|
||||
src.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
src.file = file
|
||||
src.written = 0
|
||||
src.state = SourceStateOpen
|
||||
src.err = nil
|
||||
src.started = true
|
||||
src.retainSessionLocked()
|
||||
reopen := src.reopen
|
||||
src.mu.Unlock()
|
||||
|
||||
if err := reopen(offset); err != nil {
|
||||
src.mu.Lock()
|
||||
if src.file != nil {
|
||||
_ = src.file.Close()
|
||||
src.file = nil
|
||||
}
|
||||
src.state = SourceStateFailed
|
||||
src.err = err
|
||||
src.releaseSessionLocked()
|
||||
src.notifyLocked()
|
||||
src.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) StartSource(raw string) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src.mu.Lock()
|
||||
if !src.reopenable || src.started {
|
||||
src.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
src.mu.Unlock()
|
||||
return r.Reopen(raw, 0)
|
||||
}
|
||||
|
||||
func (r *Registry) cleanupIfNeeded(src *Source) {
|
||||
src.mu.Lock()
|
||||
shouldCleanup := src.shouldCleanupLocked()
|
||||
file := src.file
|
||||
if shouldCleanup {
|
||||
src.releaseSessionLocked()
|
||||
}
|
||||
src.mu.Unlock()
|
||||
if !shouldCleanup {
|
||||
return
|
||||
@@ -443,5 +610,8 @@ func (r *Registry) Close() error {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(r.dir); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
lastErr = err
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package gblob
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistry_RevokeOpenWritableStreamAbortsAndCleansUpAfterUnpin(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
url, err := registry.CreateWritableStream(&CreateWritableStreamOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Pin(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src, err := registry.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state := src.Snapshot().State; state != SourceStateOpen {
|
||||
t.Fatalf("expected initial open state, got %s", state)
|
||||
}
|
||||
|
||||
if err := registry.Revoke(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src, err = registry.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := src.Snapshot()
|
||||
if snapshot.State != SourceStateAborted {
|
||||
t.Fatalf("expected revoked open source to become aborted, got %s", snapshot.State)
|
||||
}
|
||||
if !errors.Is(snapshot.Err, ErrSourceRevoked) {
|
||||
t.Fatalf("expected revoke error, got %v", snapshot.Err)
|
||||
}
|
||||
|
||||
registry.Unpin(url)
|
||||
if _, err := registry.Get(url); !errors.Is(err, ErrSourceNotFound) {
|
||||
t.Fatalf("expected source cleanup after unpin, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -376,7 +376,7 @@ func TestFetcher_DownloadError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetcher_DownloadLimit(t *testing.T) {
|
||||
listener := test.StartTestLimitServer(4, 0)
|
||||
listener := test.StartTestConLimitServer(4)
|
||||
defer listener.Close()
|
||||
|
||||
downloadNormal(listener, 1, t)
|
||||
@@ -614,7 +614,7 @@ func TestFetcher_SlowStartExpansion(t *testing.T) {
|
||||
os.Remove(test.DownloadFile)
|
||||
|
||||
// Use 100ns delay per byte for faster test (~10MB/s theoretical)
|
||||
listener := test.StartTestSlowStartServer(100 * time.Nanosecond)
|
||||
listener := test.StartTestLowSpeedServer(100 * time.Nanosecond)
|
||||
|
||||
// Ensure cleanup happens before next subtest
|
||||
cleanup := func() {
|
||||
@@ -758,7 +758,7 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
|
||||
t.Run("PrefetchPartial", func(t *testing.T) {
|
||||
// Use slow server with 100 nanosecond delay per byte
|
||||
// This means ~10MB/s speed, so 100ms should download ~1MB
|
||||
listener := test.StartTestSlowStartServer(100 * time.Nanosecond)
|
||||
listener := test.StartTestLowSpeedServer(100 * time.Nanosecond)
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
|
||||
+12
-19
@@ -433,9 +433,8 @@ func StartTestExpiringRedirectServer(requestsBeforeExpire int32, delayPerByte ti
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestSlowStartServer creates a server with configurable delay per request
|
||||
// This allows testing slow-start connection expansion to reach max connections
|
||||
func StartTestSlowStartServer(delayPerByte time.Duration) net.Listener {
|
||||
// StartTestLowSpeedServer creates a server with configurable delay per request
|
||||
func StartTestLowSpeedServer(delayPerByte time.Duration) net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
@@ -484,10 +483,9 @@ func slowCopyNWithDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n in
|
||||
}
|
||||
}
|
||||
|
||||
// StartTestLimitServer connections limit server
|
||||
func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
||||
// StartTestConLimitServer connections limit server
|
||||
func StartTestConLimitServer(maxConnections int32) net.Listener {
|
||||
var connections atomic.Int32
|
||||
var slowOnce atomic.Bool
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
@@ -500,19 +498,11 @@ func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
||||
writer.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
|
||||
// First request intentionally delays the first write to trigger a single read timeout,
|
||||
// subsequent requests respond at normal speed.
|
||||
useInitialDelay := delay > 0 && !slowOnce.Swap(true)
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
if useInitialDelay {
|
||||
slowCopyAfterDelay(sl, writer, file, n, delay)
|
||||
return
|
||||
}
|
||||
slowCopyN(sl, writer, file, n, 0)
|
||||
},
|
||||
)
|
||||
@@ -711,10 +701,13 @@ func rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEn
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
end, err := strconv.ParseInt(s[1], 10, 64)
|
||||
if err != nil {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
end := int64(BuildSize - 1)
|
||||
if s[1] != "" {
|
||||
end, err = strconv.ParseInt(s[1], 10, 64)
|
||||
if err != nil {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
}
|
||||
if start < 0 || end < 0 || start > end {
|
||||
writer.WriteHeader(400)
|
||||
@@ -880,7 +873,7 @@ func StartSocks5Server(usr, pwd string) net.Listener {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0") // 你可以根据需要更改监听地址
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
var mainThreadTasks chan func()
|
||||
|
||||
func RunMainThreadLoop(run func() int) int {
|
||||
if mainThreadTasks != nil {
|
||||
return run()
|
||||
}
|
||||
|
||||
mainThreadTasks = make(chan func())
|
||||
resultCh := make(chan int, 1)
|
||||
|
||||
go func() {
|
||||
resultCh <- run()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case task := <-mainThreadTasks:
|
||||
task()
|
||||
case code := <-resultCh:
|
||||
mainThreadTasks = nil
|
||||
return code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func postMainThreadTask(task func()) bool {
|
||||
if mainThreadTasks == nil {
|
||||
return false
|
||||
}
|
||||
mainThreadTasks <- task
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
func RunMainThreadLoop(run func() int) int {
|
||||
return run()
|
||||
}
|
||||
|
||||
func postMainThreadTask(func()) bool {
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,15 @@
|
||||
//go:build cgo && webview && !darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/webview/integrationtest"
|
||||
)
|
||||
|
||||
func TestProviderContract(t *testing.T) {
|
||||
integrationtest.RunProviderContract(t, New(), integrationtest.ContractOptions{
|
||||
CookieDomainMode: integrationtest.CookieDomainModeRequired,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !cgo
|
||||
|
||||
package goprovider
|
||||
|
||||
import enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
|
||||
func New() enginewebview.Provider {
|
||||
return enginewebview.NewUnavailableProvider()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build cgo && darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
/*
|
||||
#cgo darwin CFLAGS: -x objective-c
|
||||
#cgo darwin LDFLAGS: -framework Cocoa
|
||||
|
||||
#include <Cocoa/Cocoa.h>
|
||||
|
||||
static void gopeedApplyHeadlessWindowOptions(void *windowPtr, int headless) {
|
||||
if (windowPtr == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSWindow *window = (__bridge NSWindow *)windowPtr;
|
||||
if (headless) {
|
||||
[window orderOut:nil];
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
webview "github.com/GopeedLab/webview_go"
|
||||
)
|
||||
|
||||
func applyWindowOptions(w webview.WebView, opts enginewebview.OpenOptions) {
|
||||
C.gopeedApplyHeadlessWindowOptions(w.Window(), C.int(boolToInt(opts.Headless)))
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build cgo && !darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
import (
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
webview "github.com/GopeedLab/webview_go"
|
||||
)
|
||||
|
||||
func applyWindowOptions(_ webview.WebView, _ enginewebview.OpenOptions) {}
|
||||
@@ -0,0 +1,378 @@
|
||||
package integrationtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
const TestUserAgent = "GopeedWebViewIntegration/1.0"
|
||||
|
||||
type ContractOptions struct {
|
||||
AvailabilityTimeout time.Duration
|
||||
CookieDomainMode CookieDomainMode
|
||||
CookieTestURL string
|
||||
}
|
||||
|
||||
type CookieDomainMode int
|
||||
|
||||
const (
|
||||
CookieDomainModeRequired CookieDomainMode = iota
|
||||
CookieDomainModeOmit
|
||||
)
|
||||
|
||||
func RunProviderContract(t *testing.T, provider enginewebview.Provider, opts ContractOptions) {
|
||||
t.Helper()
|
||||
|
||||
timeout := opts.AvailabilityTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
waitForProviderAvailable(t, provider, timeout)
|
||||
|
||||
page, server, testURL := openTestPage(t, provider, enginewebview.OpenOptions{
|
||||
Headless: true,
|
||||
Title: "Gopeed WebView Integration",
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
UserAgent: TestUserAgent,
|
||||
})
|
||||
|
||||
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||
t.Fatalf("goto failed: %v", err)
|
||||
}
|
||||
|
||||
ua, err := page.Execute(`() => navigator.userAgent`)
|
||||
if err != nil {
|
||||
t.Fatalf("navigator.userAgent execute failed: %v", err)
|
||||
}
|
||||
if ua != TestUserAgent {
|
||||
t.Fatalf("unexpected user agent: got %v want %q", ua, TestUserAgent)
|
||||
}
|
||||
|
||||
if err := page.Focus("#name"); err != nil {
|
||||
t.Fatalf("focus failed: %v", err)
|
||||
}
|
||||
activeElement, err := page.Execute(`() => document.activeElement && document.activeElement.id`)
|
||||
if err != nil {
|
||||
t.Fatalf("activeElement execute failed: %v", err)
|
||||
}
|
||||
if activeElement != "name" {
|
||||
t.Fatalf("expected active element to be #name, got %v", activeElement)
|
||||
}
|
||||
|
||||
if err := page.Type("#name", "Gopeed", map[string]any{"delay": 5}); err != nil {
|
||||
t.Fatalf("type failed: %v", err)
|
||||
}
|
||||
typeState, err := page.Execute(`() => ({
|
||||
value: document.querySelector('#name').value,
|
||||
mirror: document.querySelector('#name-value').textContent,
|
||||
inputCount: window.__inputEvents.length
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("type state execute failed: %v", err)
|
||||
}
|
||||
stateMap, ok := typeState.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected type state payload: %T", typeState)
|
||||
}
|
||||
if stateMap["value"] != "Gopeed" || stateMap["mirror"] != "Gopeed" {
|
||||
t.Fatalf("unexpected type state: %#v", stateMap)
|
||||
}
|
||||
if stateMap["inputCount"] != float64(len("Gopeed")) {
|
||||
t.Fatalf("unexpected input event count: %#v", stateMap["inputCount"])
|
||||
}
|
||||
|
||||
if err := page.Click("#clicker"); err != nil {
|
||||
t.Fatalf("click failed: %v", err)
|
||||
}
|
||||
clickCount, err := page.Execute(`() => document.querySelector('#click-count').textContent`)
|
||||
if err != nil {
|
||||
t.Fatalf("click count execute failed: %v", err)
|
||||
}
|
||||
if clickCount != "1" {
|
||||
t.Fatalf("expected click count to be 1, got %v", clickCount)
|
||||
}
|
||||
|
||||
found, err := page.WaitForSelector("#delayed", map[string]any{
|
||||
"timeoutMs": 5000,
|
||||
"pollIntervalMs": 25,
|
||||
"visible": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForSelector failed: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected delayed selector to appear")
|
||||
}
|
||||
|
||||
value, err := page.WaitForFunction(`() => window.__readyValue`, map[string]any{
|
||||
"timeoutMs": 5000,
|
||||
"pollIntervalMs": 25,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForFunction failed: %v", err)
|
||||
}
|
||||
readyValue, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected waitForFunction value: %T", value)
|
||||
}
|
||||
if readyValue["status"] != "ready" {
|
||||
t.Fatalf("unexpected waitForFunction payload: %#v", readyValue)
|
||||
}
|
||||
|
||||
found, err = page.WaitForSelector("#missing", map[string]any{
|
||||
"timeoutMs": 200,
|
||||
"pollIntervalMs": 25,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForSelector timeout case failed: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected missing selector wait to time out")
|
||||
}
|
||||
|
||||
cookieTestURL := opts.CookieTestURL
|
||||
if cookieTestURL == "" {
|
||||
cookieTestURL = testURL
|
||||
}
|
||||
runCookieLifecycleAssertions(t, page, server, cookieTestURL, opts.CookieDomainMode)
|
||||
}
|
||||
|
||||
func waitForProviderAvailable(t *testing.T, provider enginewebview.Provider, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if provider != nil && provider.IsAvailable() {
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("provider did not become available within %s", timeout)
|
||||
}
|
||||
|
||||
func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebview.OpenOptions) (*enginewebview.PageHandle, *httptest.Server, string) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
switch r.URL.Path {
|
||||
case "/next":
|
||||
fmt.Fprint(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Next Page</title></head>
|
||||
<body>
|
||||
<h1 id="next-page">Navigation complete</h1>
|
||||
</body>
|
||||
</html>`)
|
||||
default:
|
||||
fmt.Fprint(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test Page</title></head>
|
||||
<body>
|
||||
<input id="name" type="text" />
|
||||
<div id="name-value"></div>
|
||||
<button id="clicker" type="button">Click me</button>
|
||||
<div id="click-count">0</div>
|
||||
<button id="nav-btn" type="button">Navigate</button>
|
||||
<script>
|
||||
window.__inputEvents = [];
|
||||
window.__readyValue = null;
|
||||
const nameInput = document.getElementById('name');
|
||||
const nameValue = document.getElementById('name-value');
|
||||
nameInput.addEventListener('input', () => {
|
||||
nameValue.textContent = nameInput.value;
|
||||
window.__inputEvents.push(nameInput.value);
|
||||
});
|
||||
document.getElementById('clicker').addEventListener('click', () => {
|
||||
const counter = document.getElementById('click-count');
|
||||
counter.textContent = String(Number(counter.textContent || '0') + 1);
|
||||
});
|
||||
document.getElementById('nav-btn').addEventListener('click', () => {
|
||||
setTimeout(() => {
|
||||
window.location.href = '/next';
|
||||
}, 50);
|
||||
});
|
||||
setTimeout(() => {
|
||||
const marker = document.createElement('div');
|
||||
marker.id = 'delayed';
|
||||
marker.textContent = 'Loaded later';
|
||||
document.body.appendChild(marker);
|
||||
}, 150);
|
||||
setTimeout(() => {
|
||||
window.__readyValue = { status: 'ready', source: 'timer' };
|
||||
}, 200);
|
||||
</script>
|
||||
</body>
|
||||
</html>`)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
runtime := enginewebview.NewRuntime(provider, true)
|
||||
page, err := runtime.Open(map[string]any{
|
||||
"headless": opts.Headless,
|
||||
"debug": opts.Debug,
|
||||
"title": opts.Title,
|
||||
"width": opts.Width,
|
||||
"height": opts.Height,
|
||||
"userAgent": opts.UserAgent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open webview: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = runtime.Close()
|
||||
})
|
||||
|
||||
return page, server, server.URL
|
||||
}
|
||||
|
||||
func runCookieLifecycleAssertions(t *testing.T, page *enginewebview.PageHandle, _ *httptest.Server, testURL string, domainMode CookieDomainMode) {
|
||||
t.Helper()
|
||||
|
||||
parsedURL, err := url.Parse(testURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cookie test url failed: %v", err)
|
||||
}
|
||||
cookieDomain := parsedURL.Hostname()
|
||||
|
||||
if err := page.ClearCookies(); err != nil {
|
||||
t.Fatalf("clearCookies before cookie assertions failed: %v", err)
|
||||
}
|
||||
|
||||
visibleCookie := map[string]any{
|
||||
"name": "visible",
|
||||
"value": "1",
|
||||
"path": "/",
|
||||
}
|
||||
secretCookie := map[string]any{
|
||||
"name": "secret",
|
||||
"value": "2",
|
||||
"path": "/",
|
||||
"httpOnly": true,
|
||||
"expires": time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
deleteCookie := map[string]any{
|
||||
"name": "visible",
|
||||
"path": "/",
|
||||
}
|
||||
if domainMode == CookieDomainModeRequired {
|
||||
visibleCookie["domain"] = cookieDomain
|
||||
secretCookie["domain"] = cookieDomain
|
||||
deleteCookie["domain"] = cookieDomain
|
||||
}
|
||||
|
||||
if err := page.SetCookie(visibleCookie); err != nil {
|
||||
t.Fatalf("set visible cookie failed: %v", err)
|
||||
}
|
||||
if err := page.SetCookie(secretCookie); err != nil {
|
||||
t.Fatalf("set secret cookie failed: %v", err)
|
||||
}
|
||||
|
||||
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||
t.Fatalf("goto after setCookie failed: %v", err)
|
||||
}
|
||||
|
||||
documentCookie, err := page.Execute(`() => document.cookie`)
|
||||
if err != nil {
|
||||
t.Fatalf("document.cookie execute failed: %v", err)
|
||||
}
|
||||
documentCookieText, ok := documentCookie.(string)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected document.cookie payload: %T", documentCookie)
|
||||
}
|
||||
if !strings.Contains(documentCookieText, "visible=1") {
|
||||
t.Fatalf("expected visible cookie in document.cookie, got %q", documentCookieText)
|
||||
}
|
||||
if strings.Contains(documentCookieText, "secret=2") {
|
||||
t.Fatalf("expected httpOnly cookie to be hidden from document.cookie, got %q", documentCookieText)
|
||||
}
|
||||
|
||||
cookies, err := page.GetCookies()
|
||||
if err != nil {
|
||||
t.Fatalf("getCookies failed: %v", err)
|
||||
}
|
||||
if len(cookies) < 2 {
|
||||
t.Fatalf("expected at least 2 cookies, got %#v", cookies)
|
||||
}
|
||||
if !hasCookie(cookies, "visible", "1") {
|
||||
t.Fatalf("expected visible cookie in native cookie store, got %#v", cookies)
|
||||
}
|
||||
if !hasCookie(cookies, "secret", "2") {
|
||||
t.Fatalf("expected secret cookie in native cookie store, got %#v", cookies)
|
||||
}
|
||||
|
||||
if err := page.DeleteCookie(deleteCookie); err != nil {
|
||||
t.Fatalf("deleteCookie failed: %v", err)
|
||||
}
|
||||
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||
t.Fatalf("goto after deleteCookie failed: %v", err)
|
||||
}
|
||||
|
||||
documentCookie, err = page.Execute(`() => document.cookie`)
|
||||
if err != nil {
|
||||
t.Fatalf("document.cookie execute after delete failed: %v", err)
|
||||
}
|
||||
documentCookieText, ok = documentCookie.(string)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected document.cookie payload after delete: %T", documentCookie)
|
||||
}
|
||||
if strings.Contains(documentCookieText, "visible=1") {
|
||||
t.Fatalf("expected visible cookie to be deleted, got %q", documentCookieText)
|
||||
}
|
||||
if strings.Contains(documentCookieText, "secret=2") {
|
||||
t.Fatalf("expected httpOnly cookie to remain hidden after delete, got %q", documentCookieText)
|
||||
}
|
||||
|
||||
cookies, err = page.GetCookies()
|
||||
if err != nil {
|
||||
t.Fatalf("getCookies after delete failed: %v", err)
|
||||
}
|
||||
if hasCookie(cookies, "visible", "1") {
|
||||
t.Fatalf("expected visible cookie to be removed from native store, got %#v", cookies)
|
||||
}
|
||||
if !hasCookie(cookies, "secret", "2") {
|
||||
t.Fatalf("expected secret cookie to remain in native store after delete, got %#v", cookies)
|
||||
}
|
||||
|
||||
if err := page.ClearCookies(); err != nil {
|
||||
t.Fatalf("clearCookies failed: %v", err)
|
||||
}
|
||||
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||
t.Fatalf("goto after clearCookies failed: %v", err)
|
||||
}
|
||||
documentCookie, err = page.Execute(`() => document.cookie`)
|
||||
if err != nil {
|
||||
t.Fatalf("document.cookie execute after clear failed: %v", err)
|
||||
}
|
||||
documentCookieText, ok = documentCookie.(string)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected document.cookie payload after clear: %T", documentCookie)
|
||||
}
|
||||
if documentCookieText != "" {
|
||||
t.Fatalf("expected document.cookie to be empty after clear, got %q", documentCookieText)
|
||||
}
|
||||
cookies, err = page.GetCookies()
|
||||
if err != nil {
|
||||
t.Fatalf("getCookies after clear failed: %v", err)
|
||||
}
|
||||
if len(cookies) != 0 {
|
||||
t.Fatalf("expected cookie store to be empty after clear, got %#v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func hasCookie(cookies []enginewebview.Cookie, name string, value string) bool {
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name && cookie.Value == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
# rpcprovider
|
||||
|
||||
`rpcprovider` is the RPC-backed implementation of Gopeed's `webview.Provider` interface.
|
||||
|
||||
It is intended for environments where WebView capability is owned by another process, most notably mobile hosts. This package does not implement a WebView itself. Instead, it translates the `pkg/download/engine/webview` page API into single-request `POST + JSON` calls sent to a local host-side RPC service.
|
||||
|
||||
## When to use it
|
||||
|
||||
Use `rpcprovider` for entrypoints such as `bind/mobile`, where the Go process cannot directly own a native WebView.
|
||||
|
||||
Do not use it for desktop or `cmd/web` local WebView integration. Those entrypoints should use `internal/webview/goprovider` instead.
|
||||
|
||||
## Configuration
|
||||
|
||||
The provider is configured through `pkg/download/engine/webview.RPCConfig`:
|
||||
|
||||
```go
|
||||
type RPCConfig struct {
|
||||
Network string `json:"network"`
|
||||
Address string `json:"address"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `Network` currently supports `tcp` and `unix`.
|
||||
- `Address` examples:
|
||||
- `127.0.0.1:38765`
|
||||
- `/path/to/webview.sock`
|
||||
- `Token` is optional. When set, the client sends `Authorization: Bearer <token>`.
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
provider := rpcprovider.New(webview.RPCConfig{
|
||||
Network: "tcp",
|
||||
Address: "127.0.0.1:38765",
|
||||
Token: "secret-token",
|
||||
})
|
||||
```
|
||||
|
||||
## Transport
|
||||
|
||||
- Endpoint: `POST /webview`
|
||||
- Content type: `application/json`
|
||||
- One request, one response
|
||||
- No `id` field yet
|
||||
- The envelope is intentionally close to JSON-RPC so it can evolve later without renaming the methods or reshaping the payloads
|
||||
|
||||
Request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.execute",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"expression": "document.title",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Successful response:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"title": "Example"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
Failure response:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": null,
|
||||
"error": {
|
||||
"code": "PAGE_NOT_FOUND",
|
||||
"message": "page not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Supported methods
|
||||
|
||||
- `webview.isAvailable`
|
||||
- `page.open`
|
||||
- `page.addInitScript`
|
||||
- `page.goto`
|
||||
- `page.execute`
|
||||
- `page.getCookies`
|
||||
- `page.setCookie`
|
||||
- `page.deleteCookie`
|
||||
- `page.clearCookies`
|
||||
- `page.close`
|
||||
|
||||
The canonical wire models for these methods are defined in:
|
||||
|
||||
- [pkg/download/engine/webview/rpc.go](../../../pkg/download/engine/webview/rpc.go)
|
||||
- [pkg/download/engine/webview/runtime.go](../../../pkg/download/engine/webview/runtime.go)
|
||||
|
||||
The README is only a guide. The Go source above is the authoritative protocol reference.
|
||||
|
||||
## Method reference
|
||||
|
||||
Each method below uses the common envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "<method-name>",
|
||||
"params": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
and returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": ...,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": null,
|
||||
"error": {
|
||||
"code": "<ERROR_CODE>",
|
||||
"message": "<message>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `webview.isAvailable`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "webview.isAvailable",
|
||||
"params": {}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"available": true
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.open`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.open",
|
||||
"params": {
|
||||
"headless": true,
|
||||
"debug": false,
|
||||
"title": "Gopeed WebView",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"userAgent": "Mozilla/5.0 ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"pageId": "page-1"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.addInitScript`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.addInitScript",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"script": "window.__READY__ = true;"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.goto`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.goto",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"url": "https://example.com",
|
||||
"timeoutMs": 15000,
|
||||
"waitUntil": "domcontentloaded"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
`waitUntil` currently supports `load` and `domcontentloaded`. The default is `load`.
|
||||
|
||||
### `page.execute`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.execute",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"expression": "document.title",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "Example Domain",
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.getCookies`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.getCookies",
|
||||
"params": {
|
||||
"pageId": "page-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": [
|
||||
{
|
||||
"name": "session",
|
||||
"value": "abc",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"expires": "2026-03-26T12:00:00Z",
|
||||
"secure": true,
|
||||
"httpOnly": true
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.setCookie`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.setCookie",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"cookie": {
|
||||
"name": "session",
|
||||
"value": "abc",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"expires": "2026-03-26T12:00:00Z",
|
||||
"secure": true,
|
||||
"httpOnly": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.deleteCookie`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.deleteCookie",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"cookie": {
|
||||
"name": "session",
|
||||
"domain": ".example.com",
|
||||
"path": "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.clearCookies`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.clearCookies",
|
||||
"params": {
|
||||
"pageId": "page-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.close`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.close",
|
||||
"params": {
|
||||
"pageId": "page-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
## Method semantics
|
||||
|
||||
- `page.open`
|
||||
- Creates a page session
|
||||
- Returns `pageId`
|
||||
- `page.addInitScript`
|
||||
- Registers a page initialization script
|
||||
- `page.goto`
|
||||
- Goes to the target URL
|
||||
- The server should return only after navigation completes or fails
|
||||
- `page.execute`
|
||||
- Executes an expression or serialized function body
|
||||
- The returned value must be JSON-serializable
|
||||
- `page.getCookies`
|
||||
- Returns cookies from the underlying native WebView cookie store
|
||||
- `page.setCookie`
|
||||
- Inserts or updates a cookie
|
||||
- `page.deleteCookie`
|
||||
- Deletes a cookie using `name/domain/path`
|
||||
- `page.clearCookies`
|
||||
- Clears the current cookie store
|
||||
- `page.close`
|
||||
- Closes the page and releases related resources
|
||||
|
||||
## Cookie model
|
||||
|
||||
Cookies use `runtime/webview.Cookie`.
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "session",
|
||||
"value": "abc",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"expires": "2026-03-26T12:00:00Z",
|
||||
"secure": true,
|
||||
"httpOnly": true
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `expires` uses RFC3339 / RFC3339Nano string form.
|
||||
- If the host platform supports a full native cookie store, it should return fields such as `httpOnly` and `secure`.
|
||||
- `setCookie -> goto` is a valid pattern for preloading authenticated session state.
|
||||
|
||||
## Error codes
|
||||
|
||||
The current error code set is defined in `pkg/download/engine/webview/rpc.go`:
|
||||
|
||||
- `INVALID_REQUEST`
|
||||
- `UNKNOWN_METHOD`
|
||||
- `UNAVAILABLE`
|
||||
- `BROWSER_NOT_FOUND`
|
||||
- `PAGE_NOT_FOUND`
|
||||
- `NAVIGATION_FAILED`
|
||||
- `EVALUATION_FAILED`
|
||||
- `TIMEOUT`
|
||||
- `INTERNAL_ERROR`
|
||||
|
||||
`BROWSER_NOT_FOUND` is a historical leftover from an earlier shape of the protocol. The current protocol no longer has a separate browser model, so this can be cleaned up later if the protocol is tightened further.
|
||||
|
||||
## Current protocol boundary
|
||||
|
||||
This protocol is intentionally limited:
|
||||
|
||||
- Single-direction request/response only
|
||||
- Not REST-style resource routing
|
||||
- No bidirectional events
|
||||
- No streaming event subscriptions
|
||||
- No host-initiated push channel
|
||||
|
||||
If the protocol needs to evolve toward full JSON-RPC later, the intended migration path is to keep the existing `method/params/result/error` structure and add:
|
||||
|
||||
- `id`
|
||||
- notifications
|
||||
- a bidirectional transport model
|
||||
|
||||
## Host implementation guidance
|
||||
|
||||
A host implementation should usually:
|
||||
|
||||
- Listen on a local `tcp` address or `unix socket`
|
||||
- Expose `POST /webview`
|
||||
- Validate the bearer token if configured
|
||||
- Maintain `pageId -> native webview/session`
|
||||
- Own page lifecycle, cleanup, and timeout handling
|
||||
|
||||
With that split, Gopeed stays a pure RPC client and does not need to know how the host's native WebView is implemented.
|
||||
@@ -0,0 +1,195 @@
|
||||
package rpcprovider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
func New(cfg enginewebview.RPCConfig) enginewebview.Provider {
|
||||
return &Provider{
|
||||
client: NewClient(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) IsAvailable() bool {
|
||||
if p == nil || p.client == nil || !p.client.Enabled() {
|
||||
return false
|
||||
}
|
||||
var result enginewebview.IsAvailableResult
|
||||
if err := p.client.Call(enginewebview.MethodIsAvailable, enginewebview.IsAvailableParams{}, &result); err != nil {
|
||||
return false
|
||||
}
|
||||
return result.Available
|
||||
}
|
||||
|
||||
func (p *Provider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
|
||||
if p == nil || p.client == nil || !p.IsAvailable() {
|
||||
return nil, enginewebview.ErrUnavailable
|
||||
}
|
||||
var result enginewebview.PageOpenResult
|
||||
if err := p.client.Call(enginewebview.MethodPageOpen, enginewebview.NewPageOpenParams(opts), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &page{
|
||||
client: p.client,
|
||||
id: result.PageID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type page struct {
|
||||
client *Client
|
||||
id string
|
||||
}
|
||||
|
||||
func (p *page) AddInitScript(script string) error {
|
||||
return p.client.Call(enginewebview.MethodPageAddInitScript, enginewebview.PageAddInitScriptParams{
|
||||
PageID: p.id,
|
||||
Script: script,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *page) Goto(url string, opts enginewebview.GotoOptions) error {
|
||||
return p.client.Call(enginewebview.MethodPageGoto, enginewebview.NewPageGotoParams(p.id, url, opts), nil)
|
||||
}
|
||||
|
||||
func (p *page) Execute(expression string, args ...any) (any, error) {
|
||||
var result any
|
||||
if err := p.client.Call(enginewebview.MethodPageExecute, enginewebview.PageExecuteParams{
|
||||
PageID: p.id,
|
||||
Expression: expression,
|
||||
Args: args,
|
||||
}, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *page) GetCookies() ([]enginewebview.Cookie, error) {
|
||||
var result []enginewebview.Cookie
|
||||
if err := p.client.Call(enginewebview.MethodPageGetCookies, enginewebview.PageGetCookiesParams{
|
||||
PageID: p.id,
|
||||
}, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
return []enginewebview.Cookie{}, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *page) SetCookie(cookie enginewebview.Cookie) error {
|
||||
return p.client.Call(enginewebview.MethodPageSetCookie, enginewebview.PageSetCookieParams{
|
||||
PageID: p.id,
|
||||
Cookie: cookie,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *page) DeleteCookie(cookie enginewebview.Cookie) error {
|
||||
return p.client.Call(enginewebview.MethodPageDeleteCookie, enginewebview.PageDeleteCookieParams{
|
||||
PageID: p.id,
|
||||
Cookie: cookie,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *page) ClearCookies() error {
|
||||
return p.client.Call(enginewebview.MethodPageClearCookies, enginewebview.PageClearCookiesParams{
|
||||
PageID: p.id,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *page) Close() error {
|
||||
return p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{
|
||||
PageID: p.id,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
endpoint string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg enginewebview.RPCConfig) *Client {
|
||||
client := &Client{token: cfg.Token}
|
||||
if !cfg.Enabled() {
|
||||
return client
|
||||
}
|
||||
switch strings.ToLower(cfg.Network) {
|
||||
case "unix":
|
||||
client.endpoint = "http://unix" + enginewebview.RPCEndpointPath
|
||||
client.http = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", cfg.Address)
|
||||
},
|
||||
},
|
||||
}
|
||||
default:
|
||||
client.endpoint = "http://" + cfg.Address + enginewebview.RPCEndpointPath
|
||||
client.http = &http.Client{}
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *Client) Enabled() bool {
|
||||
return c != nil && c.endpoint != "" && c.http != nil
|
||||
}
|
||||
|
||||
func (c *Client) Call(method string, params any, result any) error {
|
||||
if !c.Enabled() {
|
||||
return enginewebview.ErrUnavailable
|
||||
}
|
||||
payload, err := json.Marshal(enginewebview.RPCRequest{
|
||||
Method: method,
|
||||
Params: params,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("webview rpc status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body enginewebview.RPCResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
if body.Error != nil {
|
||||
return body.Error
|
||||
}
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if len(body.Result) == 0 || string(body.Result) == "null" {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(body.Result, result)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build webview && darwin
|
||||
|
||||
package rpcprovider
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/webview/integrationtest"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
var (
|
||||
webViewRPCNetwork = flag.String("webview-rpc-network", "unix", "WebView RPC network for integration tests")
|
||||
webViewRPCAddress = flag.String("webview-rpc-address", defaultWebViewRPCAddress(), "WebView RPC address for integration tests")
|
||||
webViewRPCToken = flag.String("webview-rpc-token", "", "WebView RPC token for integration tests")
|
||||
)
|
||||
|
||||
func TestProviderContract(t *testing.T) {
|
||||
provider := New(enginewebview.RPCConfig{
|
||||
Network: *webViewRPCNetwork,
|
||||
Address: *webViewRPCAddress,
|
||||
Token: *webViewRPCToken,
|
||||
})
|
||||
integrationtest.RunProviderContract(t, provider, integrationtest.ContractOptions{
|
||||
AvailabilityTimeout: 5 * time.Minute,
|
||||
CookieDomainMode: integrationtest.CookieDomainModeRequired,
|
||||
CookieTestURL: "https://example.com/",
|
||||
})
|
||||
}
|
||||
|
||||
func defaultWebViewRPCAddress() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(homeDir, "Library", "Application Support", "com.gopeed.gopeed", "gopeed_webview.sock")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package rpcprovider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
func TestProviderIsAvailableOverUnixSocket(t *testing.T) {
|
||||
socketPath := filepath.Join("/tmp", "gopeed-rpcwebview-"+t.Name()+".sock")
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
defer os.Remove(socketPath)
|
||||
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req enginewebview.RPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Method != enginewebview.MethodIsAvailable {
|
||||
t.Fatalf("unexpected unix method: %s", req.Method)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": enginewebview.IsAvailableResult{Available: true},
|
||||
"error": nil,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}),
|
||||
}
|
||||
defer server.Close()
|
||||
go server.Serve(listener)
|
||||
|
||||
provider := New(enginewebview.RPCConfig{
|
||||
Network: "unix",
|
||||
Address: socketPath,
|
||||
})
|
||||
if !provider.IsAvailable() {
|
||||
t.Fatal("expected unix provider to be available")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package rpcprovider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
func TestProviderLifecycleOverHTTP(t *testing.T) {
|
||||
var calls []enginewebview.RPCRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("unexpected method: %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != enginewebview.RPCEndpointPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer secret-token" {
|
||||
t.Fatalf("unexpected auth header: %q", got)
|
||||
}
|
||||
var req enginewebview.RPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls = append(calls, req)
|
||||
|
||||
var body any
|
||||
switch req.Method {
|
||||
case enginewebview.MethodIsAvailable:
|
||||
body = map[string]any{
|
||||
"result": enginewebview.IsAvailableResult{Available: true},
|
||||
"error": nil,
|
||||
}
|
||||
case enginewebview.MethodPageOpen:
|
||||
body = map[string]any{
|
||||
"result": enginewebview.PageOpenResult{PageID: "page-open-1"},
|
||||
"error": nil,
|
||||
}
|
||||
case enginewebview.MethodPageAddInitScript, enginewebview.MethodPageGoto:
|
||||
body = map[string]any{
|
||||
"result": enginewebview.EmptyResult{},
|
||||
"error": nil,
|
||||
}
|
||||
case enginewebview.MethodPageExecute:
|
||||
body = map[string]any{
|
||||
"result": map[string]any{"title": "Example"},
|
||||
"error": nil,
|
||||
}
|
||||
case enginewebview.MethodPageClose:
|
||||
body = map[string]any{
|
||||
"result": enginewebview.EmptyResult{},
|
||||
"error": nil,
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected method: %s", req.Method)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := enginewebview.RPCConfig{
|
||||
Network: "tcp",
|
||||
Address: server.Listener.Addr().String(),
|
||||
Token: "secret-token",
|
||||
}
|
||||
provider := New(cfg)
|
||||
if !provider.IsAvailable() {
|
||||
t.Fatal("expected provider to be available")
|
||||
}
|
||||
|
||||
opener, ok := provider.(enginewebview.Opener)
|
||||
if !ok {
|
||||
t.Fatal("expected provider to implement runtime webview opener")
|
||||
}
|
||||
opened, err := opener.Open(enginewebview.OpenOptions{
|
||||
Headless: true,
|
||||
Width: 1280,
|
||||
Height: 720,
|
||||
Title: "Gopeed",
|
||||
UserAgent: "UA",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := opened.AddInitScript("window.__TEST__ = true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := opened.Goto("https://example.com", enginewebview.GotoOptions{
|
||||
TimeoutMS: 3210,
|
||||
WaitUntil: "domcontentloaded",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := opened.Execute("document.title")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := value.(map[string]any)
|
||||
if !ok || got["title"] != "Example" {
|
||||
t.Fatalf("unexpected evaluate result: %#v", value)
|
||||
}
|
||||
if err := opened.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedMethods := []string{
|
||||
enginewebview.MethodIsAvailable,
|
||||
enginewebview.MethodIsAvailable,
|
||||
enginewebview.MethodPageOpen,
|
||||
enginewebview.MethodPageAddInitScript,
|
||||
enginewebview.MethodPageGoto,
|
||||
enginewebview.MethodPageExecute,
|
||||
enginewebview.MethodPageClose,
|
||||
}
|
||||
if len(calls) != len(expectedMethods) {
|
||||
t.Fatalf("unexpected call count: got %d want %d", len(calls), len(expectedMethods))
|
||||
}
|
||||
for i, method := range expectedMethods {
|
||||
if calls[i].Method != method {
|
||||
t.Fatalf("unexpected method at %d: got %s want %s", i, calls[i].Method, method)
|
||||
}
|
||||
}
|
||||
gotoParamsJSON, err := json.Marshal(calls[4].Params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var gotoParams enginewebview.PageGotoParams
|
||||
if err := json.Unmarshal(gotoParamsJSON, &gotoParams); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotoParams.WaitUntil != "domcontentloaded" {
|
||||
t.Fatalf("unexpected waitUntil: %q", gotoParams.WaitUntil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderLaunchUnavailableWithoutEndpoint(t *testing.T) {
|
||||
provider := New(enginewebview.RPCConfig{})
|
||||
if provider.IsAvailable() {
|
||||
t.Fatal("expected empty provider to be unavailable")
|
||||
}
|
||||
opener, ok := provider.(enginewebview.Opener)
|
||||
if !ok {
|
||||
t.Fatal("expected provider to implement runtime webview opener")
|
||||
}
|
||||
if _, err := opener.Open(enginewebview.OpenOptions{}); err == nil || err.Error() != enginewebview.ErrUnavailable.Error() {
|
||||
t.Fatalf("unexpected launch error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageSetCookieParamsOmitsZeroExpires(t *testing.T) {
|
||||
params := enginewebview.PageSetCookieParams{
|
||||
PageID: "page-1",
|
||||
Cookie: enginewebview.Cookie{
|
||||
Name: "visible",
|
||||
Value: "1",
|
||||
Domain: "example.com",
|
||||
Path: "/",
|
||||
},
|
||||
}
|
||||
payload, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(payload) == "" {
|
||||
t.Fatal("expected non-empty json payload")
|
||||
}
|
||||
if got := string(payload); strings.Contains(got, `"expires"`) {
|
||||
t.Fatalf("unexpected zero expires in payload: %s", got)
|
||||
}
|
||||
|
||||
params.Cookie.Expires = time.Now().UTC().Truncate(time.Second)
|
||||
payload, err = json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(payload); !strings.Contains(got, `"expires"`) {
|
||||
t.Fatalf("expected expires in payload: %s", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user