feat(webview): add proxy support and per-extension profile lifecycle (#1517)

This commit is contained in:
Levi authored and GitHub committed 2026-09-16 23:17:43 +08:00
1 parent e99ef6c7f4
commit 21702d5d46
32 files changed
+1708 -45

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# Keep upstream plugin sources recognizable and collapsed in GitHub reviews.
ui/flutter/third_party/** linguist-vendored whitespace=-blank-at-eol,-blank-at-eof
+2 -1
View File
@@ -91,7 +91,8 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Test webview
if: runner.os == 'Windows'
run: go test -tags webview -v -count=1 -timeout 300s ./internal/webview/goprovider/
# Repeat close/reopen coverage to catch browser shutdown races.
run: go test -tags webview -v -count=3 -timeout 300s ./internal/webview/goprovider/
env:
CGO_ENABLED: 1
- name: Test webview
+2 -2
View File
@@ -4,13 +4,14 @@ go 1.25.4
require (
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5
github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
github.com/bodgit/sevenzip v1.6.1
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
github.com/go-git/go-git/v5 v5.8.1
github.com/google/jsonschema-go v0.4.3
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/imroc/req/v3 v3.52.2
@@ -40,7 +41,6 @@ require (
github.com/felixge/fgprof v0.9.5 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+2 -2
View File
@@ -25,8 +25,8 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 h1:86LVTaNDM/j/uTAD2HedkXNO9LIQlt642VAdbU/0D/M=
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a h1:Vzz+8aMVX9ZPPpkxgxa7mCorrAgdXCZL5EunOpOBvao=
github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+62
View File
@@ -0,0 +1,62 @@
# Extension browser host configuration
The downloader wraps each provider with the extension's stable identity before
constructing the runtime. `OpenOptions.ProfileID`, `DataPath`, and `ProxyURL` are
host-only fields. `parseOpenOptions` never reads them from extension JavaScript.
The extension API is unchanged. The Flutter host uses the fork's public
`WebViewProfile`, `InAppWebViewSettings.profileId`, and profile-scoped
`CookieManager` APIs; the library has no Gopeed-specific method-channel bridge.
Only the Gopeed extension JavaScript boundary keeps these options host-only.
Profiles are persistent and shared across invocations/pages of the same extension.
Different identities use different native stores. Go providers use a SHA-256-based
path under `<StorageDir>/webview`; Apple RPC hosts use a stable UUID data store;
Android uses a named WebView profile. Cookie operations select that same store,
including `clearCookies`. Closing an execution closes its pages, not its profile.
Uninstall closes the extension's pages, prevents its old runtimes from opening
new pages, and removes its native profile before deleting the extension record.
Cleanup failures abort uninstall and can be retried. Reinstallation starts with
empty browser data; an ordinary extension update keeps the existing profile.
Windows/Linux delete the profile directory; Apple removes the named WebKit store.
Android cannot delete a profile already loaded by the current process: when
DELETE_BROWSING_DATA is supported it clears all site data immediately, and records
an empty-profile deletion in SharedPreferences for the next process start. A
reinstallation may reuse that cleared profile and cancels the pending deletion.
If the full-data clearing API is unavailable, uninstall fails with a restart
instruction; the pending profile is deleted at startup before it can be loaded.
No removal operation is exposed through extension JavaScript.
Existing shared browser data is not copied into every extension's new profile.
The downloader lazily owns one loopback forward proxy with HTTP and SOCKS5
listeners. Windows/Linux/Android use HTTP; Apple uses SOCKS5 because its HTTP
CONNECT configuration may bypass plain HTTP. The forwarder reads the current
Gopeed proxy config for each new request/tunnel and supports direct, system,
HTTP(S) upstream proxies with Basic authentication, and authenticated SOCKS5.
Credentials remain in Go and are never sent over the WebView RPC.
Changes affect new requests/connections. Existing CONNECT/SOCKS tunnels keep their
route until closed; opening another page in the same profile does not reset it.
SOCKS carries host/port rather than a full URL: system proxy/PAC selection for
Apple is host-based, with port 80 treated as HTTP and other ports as HTTPS.
Path-dependent PAC rules cannot be reproduced from a SOCKS connection.
Apple requires macOS 14 / iOS 17. Android checks MULTI_PROFILE and PROXY_OVERRIDE.
Unsupported hosts fail explicitly instead of silently sharing browser storage or
ignoring the proxy.
## Validation
- `go test -race ./internal/webview/proxy ./pkg/download/engine/webview`
- `go test ./pkg/download -run 'TestWebViewProfileBound|TestDownloader_ExtensionRuntimeWebView'`
- Windows/Linux: `go test -tags webview -v ./internal/webview/goprovider` (Linux: run under `xvfb-run -a`).
- macOS: start the normal Flutter app, then run `go test -tags webview -v ./internal/webview/rpcprovider` with its socket address. See `rpcprovider/README.md`.
- In webview_go: `go test -tags webview_integration -v -count=1 .` (Linux: run under `xvfb-run -a`).
The shared provider contract covers HTTP/HTTPS proxy routing, concurrent pages,
HttpOnly cookies, LocalStorage, IndexedDB, profile-scoped clearing, reopening,
and removal without affecting another profile. HTTPS routing is checked with a
rejecting upstream tunnel; no test-only certificate bypass is added to the app.
Go proxy unit tests separately cover successful TLS forwarding and authentication.
These cases run in the existing `test.yml` WebView jobs; existing mobile build jobs
compile the Android/iOS bridge. No separate Flutter test application is required.
@@ -0,0 +1,47 @@
//go:build linux && cgo
package goprovider
/*
#cgo pkg-config: gtk+-3.0
#include <gtk/gtk.h>
static void pumpGTK(void) {
// Bound each batch so Go creation/removal requests cannot be starved.
for (int i = 0; i < 64 && g_main_context_pending(NULL); ++i)
g_main_context_iteration(NULL, FALSE);
}
*/
import "C"
import (
"runtime"
"sync"
"time"
)
var gtkThreadOnce sync.Once
var gtkThreadTasks = make(chan func())
func RunMainThreadLoop(run func() int) int { return run() }
func postMainThreadTask(task func()) bool {
gtkThreadOnce.Do(func() {
go func() {
runtime.LockOSThread()
// WebKit must always use its original UI thread, including after
// the last page closes. A page cannot stop this shared event loop.
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
select {
case task := <-gtkThreadTasks:
task()
case <-ticker.C:
}
C.pumpGTK()
}
}()
})
gtkThreadTasks <- task
return true
}
@@ -1,4 +1,4 @@
//go:build !darwin
//go:build !darwin && (!linux || !cgo)
package goprovider
+30 -2
View File
@@ -105,7 +105,15 @@ func (p *pageWrapper) start() error {
defer runtimepkg.UnlockOSThread()
var w webview.WebView
if p.opts.Headless {
if p.opts.DataPath != "" {
var err error
w, err = webview.NewWithOptions(webview.Options{Debug: p.opts.Debug, Headless: p.opts.Headless, DataPath: p.opts.DataPath, ProxyURL: p.opts.ProxyURL})
if err != nil {
p.ready <- err
close(p.done)
return
}
} else if p.opts.Headless {
w = webview.NewHeadless(p.opts.Debug)
} else {
w = webview.New(p.opts.Debug)
@@ -127,12 +135,18 @@ func (p *pageWrapper) start() error {
if err := w.Bind(p.callbackName, func(payload string) error {
return p.handleCallback(payload)
}); err != nil {
w.Destroy()
p.ready <- err
close(p.done)
return
}
applyWindowOptions(w, p.opts)
w.SetHtml(buildBootstrapHTML(p.callbackName, p.readyID))
if runtimepkg.GOOS == "linux" {
// All GTK pages share the process-wide event loop below.
return
}
w.Run()
w.Destroy()
close(p.done)
@@ -318,7 +332,12 @@ func (p *pageWrapper) Close() error {
// the quit message lands on the correct queue.
terminateDone := make(chan struct{})
w.Dispatch(func() {
w.Terminate()
if runtimepkg.GOOS == "linux" {
w.Destroy()
close(p.done)
} else {
w.Terminate()
}
close(terminateDone)
})
<-terminateDone
@@ -610,3 +629,12 @@ func firstNonEmpty(values ...string) string {
}
return ""
}
func (p *Provider) RemoveProfile(profileID, dataPath string) error {
done := make(chan error, 1)
run := func() { done <- webview.RemoveProfile(dataPath) }
if !postMainThreadTask(run) {
run()
}
return <-done
}
@@ -13,3 +13,5 @@ func TestProviderContract(t *testing.T) {
CookieDomainMode: integrationtest.CookieDomainModeRequired,
})
}
func TestProviderProfiles(t *testing.T) { integrationtest.RunProfileContract(t, New()) }
+37 -2
View File
@@ -1,10 +1,14 @@
package integrationtest
import (
"crypto/sha256"
"fmt"
webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -219,7 +223,19 @@ func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebv
}))
t.Cleanup(server.Close)
runtime := enginewebview.NewRuntime(provider, true)
proxy, err := webviewproxy.New(nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { proxy.Close() })
path := filepath.Join(t.TempDir(), "profile")
sum := sha256.Sum256([]byte(path))
profileID := fmt.Sprintf("%x-%x-%x-%x-%x", sum[:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
proxyURL := proxy.URL()
if runtime.GOOS == "darwin" {
proxyURL = proxy.SOCKSURL()
}
runtime := enginewebview.NewRuntime(&configuredOpener{provider: provider, profileID: profileID, dataPath: path, proxyURL: proxyURL}, true)
page, err := runtime.Open(map[string]any{
"headless": opts.Headless,
"debug": opts.Debug,
@@ -232,7 +248,14 @@ func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebv
t.Fatalf("failed to open webview: %v", err)
}
t.Cleanup(func() {
_ = runtime.Close()
if err := runtime.Close(); err != nil {
t.Errorf("close contract page: %v", err)
}
if remover, ok := provider.(enginewebview.ProfileRemover); ok {
if err := remover.RemoveProfile(profileID, path); err != nil {
t.Errorf("remove contract profile: %v", err)
}
}
})
return page, server, server.URL
@@ -380,3 +403,15 @@ func hasCookie(cookies []enginewebview.Cookie, name string, value string) bool {
}
return false
}
type configuredOpener struct {
provider enginewebview.Provider
profileID, dataPath, proxyURL string
}
func (o *configuredOpener) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
opts.ProfileID = o.profileID
opts.DataPath = o.dataPath
opts.ProxyURL = o.proxyURL
return o.provider.Open(opts)
}
@@ -0,0 +1,247 @@
package integrationtest
import (
"crypto/sha256"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"reflect"
"runtime"
"sync/atomic"
"testing"
"time"
webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy"
webview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
)
// RunProfileContract exercises the same host API through both native and RPC
// providers. It never bypasses the host bridge with direct Flutter plugin calls.
func RunProfileContract(t *testing.T, provider webview.Provider) {
t.Helper()
waitForProviderAvailable(t, provider, defaultAvailabilityTimeout)
remover, ok := provider.(webview.ProfileRemover)
if !ok {
t.Fatal("provider does not implement host profile removal")
}
proxyURL, secureHits := profileProxy(t)
a := newContractProfile(t, provider, remover, proxyURL)
b := newContractProfile(t, provider, remover, proxyURL)
first := a.open(t)
assertProfileValue(t, first, `async () => { localStorage.setItem('owner','a'); return 'a'; }`, "a")
assertProfileValue(t, first, profileDatabaseScript(true), "saved")
if err := first.SetCookie(webview.Cookie{Name: "session", Value: "a", Domain: "profiles.invalid", Path: "/", HTTPOnly: true, Expires: time.Now().Add(time.Hour)}); err != nil {
t.Fatal(err)
}
second := b.open(t)
assertProfileValue(t, second, `() => localStorage.getItem('owner')`, nil)
assertProfileValue(t, second, profileDatabaseScript(false), nil)
assertProfileCookies(t, second, "", "new profile B")
assertProfileValue(t, second, `() => { localStorage.setItem('owner','b'); return 'b'; }`, "b")
if err := second.SetCookie(webview.Cookie{Name: "session", Value: "b", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil {
t.Fatal(err)
}
shared := a.open(t)
assertProfileValue(t, shared, `() => localStorage.getItem('owner')`, "a")
assertProfileValue(t, shared, profileDatabaseScript(false), "saved")
assertProfileCookies(t, shared, "a", "shared profile A")
storedCookies, err := shared.GetCookies()
if err != nil || len(storedCookies) != 1 || storedCookies[0].Expires.Before(time.Now()) {
t.Fatalf("persistent cookie lost its expiration: %#v, error %v", storedCookies, err)
}
assertProfileValue(t, shared, `() => document.cookie`, "") // HttpOnly stays hidden.
if err := second.ClearCookies(); err != nil {
t.Fatal(err)
}
assertProfileCookies(t, first, "a", "clearing B leaves A intact")
if err := second.SetCookie(webview.Cookie{Name: "session", Value: "b", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil {
t.Fatal(err)
}
a.close(t)
reopened := a.open(t)
assertProfileValue(t, reopened, `() => localStorage.getItem('owner')`, "a")
assertProfileValue(t, reopened, profileDatabaseScript(false), "saved")
assertProfileCookies(t, reopened, "a", "reopened profile A")
// Include a session cookie immediately before deletion to catch stale
// in-process CookieStore caches when the same profile is recreated.
if err := reopened.SetCookie(webview.Cookie{Name: "session", Value: "remove-me", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil {
t.Fatal(err)
}
a.close(t)
a.remove(t)
a.remove(t) // Retrying a successful uninstall is harmless.
reinstalled := a.open(t)
assertProfileValue(t, reinstalled, `() => localStorage.getItem('owner')`, nil)
assertProfileValue(t, reinstalled, profileDatabaseScript(false), nil)
assertProfileCookies(t, reinstalled, "", "reinstalled profile A")
assertProfileValue(t, second, `() => localStorage.getItem('owner')`, "b")
assertProfileCookies(t, second, "b", "B after removing A")
// Refuse the HTTPS tunnel deliberately. Seeing it at our proxy proves HTTPS
// routing without adding a certificate-validation bypass to the test host.
// WebView2 may commit its built-in error page and report navigation as
// complete. The invariant here is that the HTTPS tunnel reaches the proxy.
_ = reinstalled.Goto("https://profiles.invalid/", webview.GotoOptions{TimeoutMS: 5000})
deadline := time.Now().Add(5 * time.Second)
for secureHits.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if secureHits.Load() == 0 {
t.Fatal("HTTPS bypassed the host proxy")
}
}
type contractProfile struct {
provider webview.Provider
remover webview.ProfileRemover
options webview.OpenOptions
pages []webview.Page
}
func newContractProfile(t *testing.T, provider webview.Provider, remover webview.ProfileRemover, proxyURL string) *contractProfile {
t.Helper()
path := filepath.Join(t.TempDir(), "profile")
hash := sha256.Sum256([]byte(path))
id := fmt.Sprintf("%x-%x-%x-%x-%x", hash[:4], hash[4:6], hash[6:8], hash[8:10], hash[10:16])
p := &contractProfile{provider: provider, remover: remover, options: webview.OpenOptions{ProfileID: id, DataPath: path, ProxyURL: proxyURL, Headless: true, Title: "Gopeed profile contract"}}
t.Cleanup(func() { p.close(t); p.remove(t) })
return p
}
func (p *contractProfile) open(t *testing.T) webview.Page {
t.Helper()
page, err := p.provider.Open(p.options)
if err != nil {
t.Fatal(err)
}
p.pages = append(p.pages, page)
if err := page.Goto("http://profiles.invalid/", webview.GotoOptions{TimeoutMS: 10000}); err != nil {
t.Fatal(err)
}
return page
}
func (p *contractProfile) close(t *testing.T) {
t.Helper()
for _, page := range p.pages {
if err := page.Close(); err != nil {
t.Errorf("close profile page: %v", err)
}
}
p.pages = nil
}
func (p *contractProfile) remove(t *testing.T) {
t.Helper()
if err := p.remover.RemoveProfile(p.options.ProfileID, p.options.DataPath); err != nil {
t.Errorf("remove profile: %v", err)
}
}
func assertProfileValue(t *testing.T, page webview.Page, script string, want any) {
t.Helper()
got, err := page.Execute(script)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("script %s: got %#v, want %#v", script, got, want)
}
}
func assertProfileCookies(t *testing.T, page webview.Page, want, stage string) {
t.Helper()
// Native cookie managers propagate updates across browser processes
// asynchronously. Wait for the expected state rather than assuming the
// first IPC read already includes the preceding write.
deadline := time.Now().Add(5 * time.Second)
for {
cookies, err := page.GetCookies()
if err != nil {
t.Fatalf("%s: %v", stage, err)
}
matches := len(cookies) == 0 && want == "" || len(cookies) == 1 && want != "" && cookies[0].Name == "session" && cookies[0].Value == want && cookies[0].HTTPOnly
if matches {
return
}
if time.Now().After(deadline) {
t.Fatalf("%s: cookies %#v, want %q", stage, cookies, want)
}
time.Sleep(20 * time.Millisecond)
}
}
func profileDatabaseScript(write bool) string {
operation := "store.get('owner')"
if write {
operation = "store.put('saved','owner')"
}
return fmt.Sprintf(`async () => await new Promise((resolve,reject)=>{
const request=indexedDB.open('profile-contract',1);
request.onupgradeneeded=()=>request.result.createObjectStore('state');
request.onerror=()=>reject(String(request.error));
request.onsuccess=()=>{
const db=request.result, tx=db.transaction('state','readwrite'), store=tx.objectStore('state');
const op=%s; let value;
op.onsuccess=()=>value=%s;
tx.oncomplete=()=>{db.close();resolve(value===undefined?null:value)};
tx.onerror=()=>{db.close();reject(String(tx.error))};
};
})`, operation, map[bool]string{true: "'saved'", false: "op.result"}[write])
}
func profileProxy(t *testing.T) (string, *atomic.Int32) {
t.Helper()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprint(w, "<!doctype html><title>Profile contract</title>")
}))
t.Cleanup(origin.Close)
secureHits := &atomic.Int32{}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect {
origin.Config.Handler.ServeHTTP(w, r)
return
}
if r.Host == "profiles.invalid:443" {
secureHits.Add(1)
http.Error(w, "HTTPS routing fixture rejects tunnels", http.StatusBadGateway)
return
}
if r.Host != "profiles.invalid:80" {
http.Error(w, "unexpected target", http.StatusBadGateway)
return
}
remote, err := net.DialTimeout("tcp", origin.Listener.Addr().String(), 5*time.Second)
if err != nil {
http.Error(w, "origin unavailable", http.StatusBadGateway)
return
}
defer remote.Close()
client, buffer, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer client.Close()
if _, err := buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
return
}
if err := buffer.Flush(); err != nil {
return
}
go func() { io.Copy(remote, buffer); remote.Close() }()
io.Copy(client, remote)
}))
t.Cleanup(upstream.Close)
upstreamURL, err := url.Parse(upstream.URL)
if err != nil {
t.Fatal(err)
}
proxy, err := webviewproxy.New(func(*http.Request) (*url.URL, error) { return upstreamURL, nil })
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { proxy.Close() })
if runtime.GOOS == "darwin" {
return proxy.SOCKSURL(), secureHits
}
return proxy.URL(), secureHits
}
+341
View File
@@ -0,0 +1,341 @@
// Package proxy provides the host-owned WebView forward proxy. Browser engines
// always use this endpoint; Gopeed decides the upstream route for each request.
package proxy
import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/armon/go-socks5"
xproxy "golang.org/x/net/proxy"
)
type Selector func(*http.Request) (*url.URL, error)
type Server struct {
listener net.Listener
socksListener net.Listener
server *http.Server
transport *http.Transport
selectProxy Selector
mu sync.Mutex
connections map[io.Closer]struct{}
closed bool
}
func New(selectProxy Selector) (*Server, error) {
if selectProxy == nil {
selectProxy = func(*http.Request) (*url.URL, error) { return nil, nil }
}
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return nil, err
}
s := &Server{listener: listener, selectProxy: selectProxy, connections: make(map[io.Closer]struct{})}
s.transport = &http.Transport{Proxy: selectProxy, DisableKeepAlives: true, TLSHandshakeTimeout: 15 * time.Second, ResponseHeaderTimeout: 30 * time.Second}
s.server = &http.Server{Handler: s, ReadHeaderTimeout: 15 * time.Second}
s.socksListener, err = net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
listener.Close()
return nil, err
}
socks, err := socks5.New(&socks5.Config{Logger: log.New(io.Discard, "", 0), Resolver: unresolvedDNS{}, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
_, port, _ := net.SplitHostPort(address)
scheme := "https"
if port == "80" {
scheme = "http"
}
request := &http.Request{URL: &url.URL{Scheme: scheme, Host: address}, Header: make(http.Header)}
upstream, err := selectProxy(request)
if err != nil {
return nil, err
}
timed, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
conn, err := dialTunnel(timed, address, upstream)
if err != nil {
return nil, err
}
return s.track(conn)
}})
if err != nil {
listener.Close()
s.socksListener.Close()
return nil, err
}
go s.server.Serve(listener)
go socks.Serve(&trackedListener{Listener: s.socksListener, server: s})
return s, nil
}
func (s *Server) URL() string { return "http://" + s.listener.Addr().String() }
func (s *Server) SOCKSURL() string { return "socks5://" + s.socksListener.Addr().String() }
func (s *Server) Close() error {
s.mu.Lock()
s.closed = true
connections := make([]io.Closer, 0, len(s.connections))
for c := range s.connections {
connections = append(connections, c)
}
s.mu.Unlock()
for _, c := range connections {
c.Close()
}
s.socksListener.Close()
s.transport.CloseIdleConnections()
return s.server.Close()
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
s.connect(w, r)
return
}
if !r.URL.IsAbs() || (r.URL.Scheme != "http" && r.URL.Scheme != "https") {
http.Error(w, "absolute HTTP URL required", http.StatusBadRequest)
return
}
out := r.Clone(r.Context())
out.RequestURI = ""
upgrade := out.Header.Get("Upgrade")
stripHopHeaders(out.Header)
if upgrade != "" {
out.Header.Set("Connection", "Upgrade")
out.Header.Set("Upgrade", upgrade)
}
resp, err := s.transport.RoundTrip(out)
if err != nil {
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusSwitchingProtocols {
remote, ok := resp.Body.(io.ReadWriteCloser)
if !ok {
http.Error(w, "invalid upstream upgrade", 502)
return
}
client, buffer, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer client.Close()
resp.Body = nil
if err := resp.Write(buffer); err != nil {
return
}
if err := buffer.Flush(); err != nil {
return
}
s.bridge(client, buffer, remote)
return
}
stripHopHeaders(resp.Header)
for k, v := range resp.Header {
w.Header()[k] = v
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func stripHopHeaders(h http.Header) {
for _, key := range strings.Split(h.Get("Connection"), ",") {
h.Del(strings.TrimSpace(key))
}
for _, key := range []string{"Connection", "Proxy-Connection", "Proxy-Authorization", "Proxy-Authenticate", "Keep-Alive", "TE", "Trailer", "Transfer-Encoding", "Upgrade"} {
h.Del(key)
}
}
func (s *Server) connect(w http.ResponseWriter, r *http.Request) {
if _, _, err := net.SplitHostPort(r.Host); err != nil {
http.Error(w, "invalid tunnel destination", 400)
return
}
target := r.Clone(r.Context())
target.URL = &url.URL{Scheme: "https", Host: r.Host}
upstream, err := s.selectProxy(target)
if err != nil {
http.Error(w, "proxy selection failed", 502)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
remote, err := dialTunnel(ctx, r.Host, upstream)
if err != nil {
http.Error(w, "upstream connection failed", 502)
return
}
defer remote.Close()
client, buffer, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer client.Close()
if _, err := buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
return
}
if err := buffer.Flush(); err != nil {
return
}
s.bridge(client, buffer, remote)
}
func (s *Server) bridge(client net.Conn, buffer *bufio.ReadWriter, remote io.ReadWriteCloser) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.connections[client] = struct{}{}
s.connections[remote] = struct{}{}
s.mu.Unlock()
defer func() { s.mu.Lock(); delete(s.connections, client); delete(s.connections, remote); s.mu.Unlock() }()
done := make(chan struct{})
go func() { io.Copy(remote, buffer); remote.Close(); close(done) }()
io.Copy(client, remote)
client.Close()
<-done
}
type bufferedConn struct {
net.Conn
reader *bufio.Reader
}
func (c *bufferedConn) Read(p []byte) (int, error) { return c.reader.Read(p) }
func dialTunnel(ctx context.Context, target string, upstream *url.URL) (net.Conn, error) {
dialer := &net.Dialer{}
if upstream == nil {
return dialer.DialContext(ctx, "tcp", target)
}
if upstream.Scheme == "socks5" || upstream.Scheme == "socks5h" {
u := *upstream
u.Scheme = "socks5"
d, err := xproxy.FromURL(&u, dialer)
if err != nil {
return nil, err
}
return d.(xproxy.ContextDialer).DialContext(ctx, "tcp", target)
}
if upstream.Scheme != "http" && upstream.Scheme != "https" {
return nil, errors.New("unsupported proxy scheme")
}
host := upstream.Host
if upstream.Port() == "" {
port := "80"
if upstream.Scheme == "https" {
port = "443"
}
host = net.JoinHostPort(upstream.Hostname(), port)
}
conn, err := dialer.DialContext(ctx, "tcp", host)
if err != nil {
return nil, err
}
ok := false
defer func() {
if !ok {
conn.Close()
}
}()
if deadline, exists := ctx.Deadline(); exists {
conn.SetDeadline(deadline)
}
if upstream.Scheme == "https" {
secure := tls.Client(conn, &tls.Config{ServerName: upstream.Hostname(), MinVersion: tls.VersionTLS12})
if err := secure.HandshakeContext(ctx); err != nil {
return nil, err
}
conn = secure
}
request := &http.Request{Method: http.MethodConnect, URL: &url.URL{Opaque: target}, Host: target, Header: make(http.Header)}
if upstream.User != nil {
password, _ := upstream.User.Password()
request.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(upstream.User.Username()+":"+password)))
}
if err := request.Write(conn); err != nil {
return nil, err
}
reader := bufio.NewReader(conn)
response, err := http.ReadResponse(reader, request)
if err != nil {
return nil, err
}
if response.StatusCode != 200 {
response.Body.Close()
return nil, fmt.Errorf("upstream CONNECT status %d", response.StatusCode)
}
conn.SetDeadline(time.Time{})
ok = true
return &bufferedConn{Conn: conn, reader: reader}, nil
}
// Leave hostname resolution to the selected upstream (SOCKS remote DNS / CONNECT).
type unresolvedDNS struct{}
func (unresolvedDNS) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) {
return ctx, nil, nil
}
type trackedListener struct {
net.Listener
server *Server
}
func (l *trackedListener) Accept() (net.Conn, error) {
c, err := l.Listener.Accept()
if err != nil {
return nil, err
}
return l.server.track(c)
}
type trackedConn struct {
net.Conn
server *Server
}
func (c *trackedConn) Close() error {
err := c.Conn.Close()
c.server.mu.Lock()
delete(c.server.connections, c)
c.server.mu.Unlock()
return err
}
func (s *Server) track(c net.Conn) (net.Conn, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
c.Close()
return nil, net.ErrClosed
}
tracked := &trackedConn{Conn: c, server: s}
s.connections[tracked] = struct{}{}
return tracked, nil
}
func (c *trackedConn) CloseWrite() error { return closeWrite(c.Conn) }
func (c *bufferedConn) CloseWrite() error { return closeWrite(c.Conn) }
func closeWrite(c net.Conn) error {
if writer, ok := c.(interface{ CloseWrite() error }); ok {
return writer.CloseWrite()
}
return c.Close()
}
+315
View File
@@ -0,0 +1,315 @@
package proxy
import (
"bufio"
"context"
"crypto/tls"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/armon/go-socks5"
)
func TestRejectedProxyRequests(t *testing.T) {
secret := "upstream-password"
for _, tc := range []struct {
name, method, target string
status int
}{
{"relative URL", "GET", "/relative", 400},
{"unsupported URL", "GET", "ftp://example.com/file", 400},
{"invalid CONNECT", "CONNECT", "example.com", 400},
{"HTTP selection failure", "GET", "http://example.com/", 502},
{"CONNECT selection failure", "CONNECT", "example.com:443", 502},
} {
t.Run(tc.name, func(t *testing.T) {
s := &Server{selectProxy: func(*http.Request) (*url.URL, error) { return nil, errors.New(secret) }}
s.transport = &http.Transport{Proxy: s.selectProxy}
defer s.transport.CloseIdleConnections()
response := httptest.NewRecorder()
s.ServeHTTP(response, httptest.NewRequest(tc.method, tc.target, nil))
if response.Code != tc.status || strings.Contains(response.Body.String(), secret) {
t.Fatalf("unexpected proxy rejection: %d %q", response.Code, response.Body.String())
}
})
}
}
func TestTunnelRejectsUnsafeUpstreams(t *testing.T) {
rejected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "authentication required", http.StatusProxyAuthRequired)
}))
defer rejected.Close()
secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("untrusted TLS proxy received a request")
}))
defer secure.Close()
for _, upstream := range []string{"ftp://127.0.0.1", rejected.URL, secure.URL} {
t.Run(upstream, func(t *testing.T) {
selected, err := url.Parse(upstream)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conn, err := dialTunnel(ctx, "example.com:443", selected)
if conn != nil {
conn.Close()
}
if err == nil {
t.Fatal("unsafe upstream unexpectedly accepted")
}
})
}
}
func TestRoutesAndAuthentication(t *testing.T) {
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Proxy-Authorization") != "" {
t.Error("proxy credentials leaked to origin")
}
w.Write([]byte("origin"))
}))
defer origin.Close()
secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("secure")) }))
defer secure.Close()
upstream, err := New(func(*http.Request) (*url.URL, error) { return nil, nil })
if err != nil {
t.Fatal(err)
}
defer upstream.Close()
var authenticated atomic.Int32
authProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Proxy-Authorization") != "Basic dXNlcjpwYXNz" {
http.Error(w, "authentication required", 407)
return
}
authenticated.Add(1)
upstream.ServeHTTP(w, r)
}))
defer authProxy.Close()
socks, err := socks5.New(&socks5.Config{Credentials: socks5.StaticCredentials{"user": "pass"}})
if err != nil {
t.Fatal(err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go socks.Serve(listener)
for _, route := range []string{"", authProxy.URL, "socks5://" + listener.Addr().String()} {
t.Run(route, func(t *testing.T) {
var selected *url.URL
if route != "" {
selected, _ = url.Parse(route)
selected.User = url.UserPassword("user", "pass")
}
local, err := New(func(r *http.Request) (*url.URL, error) { return selected, nil })
if err != nil {
t.Fatal(err)
}
defer local.Close()
proxyURL, _ := url.Parse(local.URL())
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL), TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
for _, test := range []struct{ url, want string }{{origin.URL, "origin"}, {secure.URL, "secure"}} {
response, err := client.Get(test.url)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(response.Body)
response.Body.Close()
if string(body) != test.want {
t.Fatalf("got status %d, body %q", response.StatusCode, body)
}
}
})
}
if authenticated.Load() != 2 {
t.Fatalf("expected authenticated HTTP and CONNECT, got %d", authenticated.Load())
}
}
func TestRoutingChangesAndFailureDoesNotBypassProxy(t *testing.T) {
var hits atomic.Int32
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1); w.Write([]byte("direct")) }))
defer origin.Close()
var useProxy atomic.Bool
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("proxied")) }))
upstreamURL, _ := url.Parse(upstream.URL)
local, err := New(func(*http.Request) (*url.URL, error) {
if useProxy.Load() {
return upstreamURL, nil
}
return nil, nil
})
if err != nil {
t.Fatal(err)
}
defer local.Close()
endpoint, _ := url.Parse(local.URL())
transport := &http.Transport{Proxy: http.ProxyURL(endpoint)}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
for _, want := range []string{"direct", "proxied", "upstream request failed\n"} {
resp, err := client.Get(origin.URL)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if string(body) != want {
t.Fatalf("got %q want %q", body, want)
}
if want == "direct" {
useProxy.Store(true)
} else if want == "proxied" {
upstream.Close()
}
}
if hits.Load() != 1 {
t.Fatal("failed proxy must not fall back to direct")
}
}
func TestCloseTerminatesTunnels(t *testing.T) {
echo, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer echo.Close()
go func() {
c, e := echo.Accept()
if e == nil {
defer c.Close()
io.Copy(c, c)
}
}()
s, err := New(func(*http.Request) (*url.URL, error) { return nil, nil })
if err != nil {
t.Fatal(err)
}
endpoint, _ := url.Parse(s.URL())
c, err := dialTunnel(context.Background(), echo.Addr().String(), endpoint)
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(3 * time.Second))
c.Write([]byte("hello\n"))
line, err := bufio.NewReader(c).ReadString('\n')
if err != nil || line != "hello\n" {
t.Fatalf("echo %q %v", line, err)
}
s.Close()
if _, err := c.Read(make([]byte, 1)); err == nil {
t.Fatal("tunnel stayed open")
}
}
func TestSOCKSIngressPreservesRemoteDNS(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "CONNECT" || r.Host != "unresolved.invalid:80" {
t.Errorf("lost destination: %s %s", r.Method, r.Host)
}
c, b, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer c.Close()
b.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n")
b.Flush()
request, err := http.ReadRequest(b.Reader)
if err != nil {
t.Error(err)
return
}
request.Body.Close()
b.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\nproxied")
b.Flush()
}))
defer upstream.Close()
target, _ := url.Parse(upstream.URL)
local, err := New(func(r *http.Request) (*url.URL, error) {
if r.URL.Host != "unresolved.invalid:80" {
t.Errorf("selector lost hostname: %s", r.URL)
}
return target, nil
})
if err != nil {
t.Fatal(err)
}
defer local.Close()
endpoint, _ := url.Parse(local.SOCKSURL())
transport := &http.Transport{Proxy: http.ProxyURL(endpoint)}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
response, err := client.Get("http://unresolved.invalid/")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if string(body) != "proxied" {
t.Fatalf("unexpected body %q", body)
}
}
func TestHTTPUpgradeIsForwarded(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Upgrade") != "fixture" {
http.Error(w, "upgrade lost", 400)
return
}
c, b, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer c.Close()
b.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: fixture\r\n\r\n")
b.Flush()
line, err := b.ReadString('\n')
if err != nil {
return
}
b.WriteString(line)
b.Flush()
}))
defer upstream.Close()
local, err := New(nil)
if err != nil {
t.Fatal(err)
}
defer local.Close()
endpoint, _ := url.Parse(local.URL())
c, err := net.Dial("tcp", endpoint.Host)
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(3 * time.Second))
io.WriteString(c, "GET "+upstream.URL+"/ HTTP/1.1\r\nHost: fixture\r\nConnection: Upgrade\r\nUpgrade: fixture\r\n\r\n")
reader := bufio.NewReader(c)
response, err := http.ReadResponse(reader, nil)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != 101 {
t.Fatalf("upgrade failed: %s", response.Status)
}
io.WriteString(c, "hello\n")
line, err := reader.ReadString('\n')
if err != nil || line != "hello\n" {
t.Fatalf("upgraded echo %q %v", line, err)
}
}
+6
View File
@@ -1,5 +1,11 @@
# rpcprovider
The private Flutter native profile bridge is maintained in the
[GopeedLab flutter_inappwebview fork](https://github.com/GopeedLab/flutter_inappwebview/tree/codex/gopeed-profiles).
All three platform overrides pin one commit in `ui/flutter/pubspec.yaml`;
see the fork’s [GOPEED.md](https://github.com/GopeedLab/flutter_inappwebview/blob/codex/gopeed-profiles/GOPEED.md)
for patch scope and upstream synchronization.
`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.
+11 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
@@ -109,9 +110,14 @@ func (p *page) ClearCookies() error {
}
func (p *page) Close() error {
return p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{
err := p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{
PageID: p.id,
}, nil)
var rpcErr *enginewebview.RPCError
if errors.As(err, &rpcErr) && rpcErr.Code == enginewebview.ErrorCodePageNotFound {
return nil
}
return err
}
type Client struct {
@@ -193,3 +199,7 @@ func (c *Client) Call(method string, params any, result any) error {
}
return json.Unmarshal(body.Result, result)
}
func (p *Provider) RemoveProfile(profileID, dataPath string) error {
return p.client.Call(enginewebview.MethodProfileRemove, enginewebview.ProfileRemoveParams{ProfileID: profileID}, nil)
}
@@ -37,3 +37,8 @@ func defaultWebViewRPCAddress() string {
}
return filepath.Join(homeDir, "Library", "Application Support", "com.gopeed.gopeed", "gopeed_webview.sock")
}
func TestProviderProfiles(t *testing.T) {
provider := New(enginewebview.RPCConfig{Network: *webViewRPCNetwork, Address: *webViewRPCAddress, Token: *webViewRPCToken})
integrationtest.RunProfileContract(t, provider)
}
@@ -184,3 +184,29 @@ func TestPageSetCookieParamsOmitsZeroExpires(t *testing.T) {
t.Fatalf("expected expires in payload: %s", got)
}
}
func TestRemoveProfileUsesPrivateHostRPC(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
Method string `json:"method"`
Params map[string]string `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Error(err)
}
if request.Method != enginewebview.MethodProfileRemove || request.Params["profileId"] != "extension-profile" || len(request.Params) != 1 {
t.Errorf("unexpected profile removal request: %+v", request)
}
if r.Header.Get("Authorization") != "Bearer secret" {
t.Error("missing host authentication")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"error":{"code":"PROFILE_REMOVE_FAILED","message":"store busy"}}`))
}))
defer server.Close()
provider := New(enginewebview.RPCConfig{Network: "tcp", Address: strings.TrimPrefix(server.URL, "http://"), Token: "secret"})
err := provider.(enginewebview.ProfileRemover).RemoveProfile("extension-profile", "/host-only/path")
if err == nil || err.Error() != "store busy" {
t.Fatalf("cleanup failure was lost: %v", err)
}
}
+12 -2
View File
@@ -21,6 +21,7 @@ import (
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/logger"
webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/GopeedLab/gopeed/pkg/util"
@@ -97,8 +98,12 @@ type Progress struct {
}
type Downloader struct {
Logger *logger.Logger
ExtensionLogger *logger.Logger
webviewProfilesLock sync.Mutex
webviewProfiles map[string]*extensionWebViewProfile
webviewProxyLock sync.Mutex
webviewProxy *webviewproxy.Server
Logger *logger.Logger
ExtensionLogger *logger.Logger
cfg *DownloaderConfig
fetcherCache map[string]fetcher.Fetcher
@@ -1114,6 +1119,11 @@ func (d *Downloader) doDelete(task *Task, force bool) (err error) {
func (d *Downloader) Close() error {
d.closed.Store(true)
d.webviewProxyLock.Lock()
if d.webviewProxy != nil {
_ = d.webviewProxy.Close()
}
d.webviewProxyLock.Unlock()
closeArr := []func() error{
d.pauseAll,
+12
View File
@@ -5,6 +5,7 @@ import "encoding/json"
const RPCEndpointPath = "/webview"
const (
MethodProfileRemove = "profile.remove"
MethodIsAvailable = "webview.isAvailable"
MethodPageOpen = "page.open"
MethodPageAddInitScript = "page.addInitScript"
@@ -68,6 +69,9 @@ type IsAvailableResult struct {
}
type PageOpenParams struct {
ProfileID string `json:"profileId,omitempty"`
DataPath string `json:"dataPath,omitempty"`
ProxyURL string `json:"proxyUrl,omitempty"`
Headless bool `json:"headless,omitempty"`
Debug bool `json:"debug,omitempty"`
Title string `json:"title,omitempty"`
@@ -78,6 +82,9 @@ type PageOpenParams struct {
func NewPageOpenParams(opts OpenOptions) PageOpenParams {
return PageOpenParams{
ProfileID: opts.ProfileID,
DataPath: opts.DataPath,
ProxyURL: opts.ProxyURL,
Headless: opts.Headless,
Debug: opts.Debug,
Title: opts.Title,
@@ -141,3 +148,8 @@ type PageCloseParams struct {
}
type EmptyResult struct{}
// ProfileRemoveParams belongs to the private host RPC.
type ProfileRemoveParams struct {
ProfileID string `json:"profileId"`
}
+9
View File
@@ -17,6 +17,11 @@ type Opener interface {
Open(opts OpenOptions) (Page, error)
}
// ProfileRemover is a host-only lifecycle operation, never exposed to extension JS.
type ProfileRemover interface {
RemoveProfile(profileID, dataPath string) error
}
type Page interface {
AddInitScript(script string) error
Goto(url string, opts GotoOptions) error
@@ -29,6 +34,10 @@ type Page interface {
}
type OpenOptions struct {
// Host-owned fields are never parsed from extension JavaScript options.
ProfileID string
DataPath string
ProxyURL string
Headless bool
Debug bool
Title string
@@ -336,3 +336,15 @@ func (p *fakePage) Close() error {
p.closed = true
return nil
}
func TestExtensionCannotSupplyHostProfileOrProxy(t *testing.T) {
opener := &fakeOpener{page: &fakePage{}}
runtime := NewRuntime(opener, true)
_, err := runtime.Open(map[string]any{"profileId": "other-extension", "dataPath": "/tmp/other-profile", "proxyUrl": "http://example.com:1234", "ProfileID": "other"})
if err != nil {
t.Fatal(err)
}
if opener.opts.ProfileID != "" || opener.opts.DataPath != "" || opener.opts.ProxyURL != "" {
t.Fatalf("extension controlled host settings: %+v", opener.opts)
}
}
+10 -1
View File
@@ -65,6 +65,7 @@ func (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Exten
// if extension is not installed, add it to the list, otherwise update it
installedExt := d.getExtension(ext.Identity)
newInstallation := installedExt == nil
if installedExt == nil {
ext.CreatedAt = time.Now()
ext.UpdatedAt = ext.CreatedAt
@@ -76,6 +77,11 @@ func (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Exten
if err = d.storage.Put(bucketExtension, installedExt.Identity, installedExt); err != nil {
return nil, err
}
if newInstallation {
d.webviewProfilesLock.Lock()
delete(d.webviewProfiles, ext.Identity)
d.webviewProfilesLock.Unlock()
}
return installedExt, nil
}
@@ -144,6 +150,9 @@ func (d *Downloader) DeleteExtension(identity string) error {
if err != nil {
return err
}
if err := d.removeExtensionWebViewProfile(identity); err != nil {
return fmt.Errorf("remove extension WebView data: %w", err)
}
// remove from disk
if !ext.DevMode {
if err := os.RemoveAll(d.ExtensionPath(ext)); err != nil {
@@ -365,7 +374,7 @@ func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, c
engine, session := d.newExtensionEngine()
defer session.CloseIfIdle()
gopeed.Runtime = &InstanceRuntime{
WebView: d.newExtensionWebViewRuntime(session),
WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()),
}
err = injectGopeed(engine.Runtime, gopeed)
if err != nil {
+1 -1
View File
@@ -30,7 +30,7 @@ func (d *Downloader) NewExtensionEngine(ext *Extension, settings map[string]any)
identity: ext.buildIdentity(),
},
Runtime: &InstanceRuntime{
WebView: d.newExtensionWebViewRuntime(session),
WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()),
},
}
if err := injectGopeed(engine.Runtime, gopeed); err != nil {
+2 -2
View File
@@ -135,13 +135,13 @@ func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) {
return e, session
}
func (d *Downloader) newExtensionWebViewRuntime(session *engineSession) *enginewebview.Runtime {
func (d *Downloader) newExtensionWebViewRuntime(session *engineSession, identity string) *enginewebview.Runtime {
var (
opener enginewebview.Opener
available bool
)
if provider := d.cfg.WebViewProvider; provider != nil && provider.IsAvailable() {
opener = provider
opener = &extensionWebViewOpener{downloader: d, provider: provider, identity: identity, profile: d.extensionWebViewProfile(identity)}
available = true
}
runtime := enginewebview.NewRuntime(opener, available)
+183
View File
@@ -0,0 +1,183 @@
package download
import (
"crypto/sha256"
"fmt"
"net/http"
"net/url"
"os"
"sync"
"github.com/GopeedLab/gopeed/pkg/base"
"path/filepath"
"runtime"
webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
)
// extensionWebViewOpener binds browser state to the owning extension. Neither
// identity, filesystem paths nor proxy settings are accepted from JavaScript.
type extensionWebViewOpener struct {
downloader *Downloader
provider enginewebview.Provider
identity string
profile *extensionWebViewProfile
}
func (o *extensionWebViewOpener) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
if o.identity == "" {
return nil, fmt.Errorf("webview extension identity is required")
}
profile := o.profile
if profile == nil {
profile = o.downloader.extensionWebViewProfile(o.identity)
}
profile.mu.Lock()
defer profile.mu.Unlock()
if profile.revoked {
return nil, fmt.Errorf("extension WebView profile has been removed")
}
id, path, err := o.downloader.extensionWebViewLocation(o.identity)
if err != nil {
return nil, err
}
opts.ProfileID, opts.DataPath = id, path
opts.ProxyURL, err = o.downloader.webviewProxyURL()
if err != nil {
return nil, err
}
page, err := o.provider.Open(opts)
if err != nil {
return nil, err
}
tracked := &extensionWebViewPage{Page: page, profile: profile}
profile.pages[tracked] = struct{}{}
return tracked, nil
}
func (d *Downloader) webviewProxyURL() (string, error) {
d.webviewProxyLock.Lock()
defer d.webviewProxyLock.Unlock()
if d.closed.Load() {
return "", fmt.Errorf("downloader is closed")
}
if d.webviewProxy == nil {
server, err := webviewproxy.New(func(r *http.Request) (*url.URL, error) {
if d.blob != nil && d.blob.IsURL(r.URL.String()) {
return nil, nil
}
d.configLock.RLock()
var cfg base.DownloaderProxyConfig
if d.cfg.Proxy != nil {
cfg = *d.cfg.Proxy
}
d.configLock.RUnlock()
handler := cfg.ToHandler()
if handler == nil {
return nil, nil
}
return handler(r)
})
if err != nil {
return "", err
}
d.webviewProxy = server
}
if runtime.GOOS == "darwin" || runtime.GOOS == "ios" {
return d.webviewProxy.SOCKSURL(), nil
}
return d.webviewProxy.URL(), nil
}
// A revoked profile is retained until a fresh installation. Existing runtimes
// retain this object, so reinstalling cannot let an old runtime recreate data.
type extensionWebViewProfile struct {
mu sync.Mutex
deleteMu sync.Mutex
revoked bool
pages map[*extensionWebViewPage]struct{}
}
type extensionWebViewPage struct {
enginewebview.Page
profile *extensionWebViewProfile
mu sync.Mutex
closed bool
}
func (p *extensionWebViewPage) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return nil
}
if err := p.Page.Close(); err != nil {
return err
}
p.closed = true
p.profile.mu.Lock()
delete(p.profile.pages, p)
p.profile.mu.Unlock()
return nil
}
func (d *Downloader) extensionWebViewProfile(identity string) *extensionWebViewProfile {
d.webviewProfilesLock.Lock()
defer d.webviewProfilesLock.Unlock()
if d.webviewProfiles == nil {
d.webviewProfiles = make(map[string]*extensionWebViewProfile)
}
profile := d.webviewProfiles[identity]
if profile == nil {
profile = &extensionWebViewProfile{pages: make(map[*extensionWebViewPage]struct{})}
d.webviewProfiles[identity] = profile
}
return profile
}
func (d *Downloader) extensionWebViewLocation(identity string) (string, string, error) {
if identity == "" {
return "", "", fmt.Errorf("webview extension identity is required")
}
sum := sha256.Sum256([]byte(identity))
id := fmt.Sprintf("%x-%x-%x-%x-%x", sum[:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
path, err := filepath.Abs(filepath.Join(d.cfg.StorageDir, "webview", fmt.Sprintf("%x", sum[:])))
return id, path, err
}
func (d *Downloader) removeExtensionWebViewProfile(identity string) error {
profile := d.extensionWebViewProfile(identity)
profile.deleteMu.Lock()
defer profile.deleteMu.Unlock()
profile.mu.Lock()
profile.revoked = true
pages := make([]*extensionWebViewPage, 0, len(profile.pages))
for page := range profile.pages {
pages = append(pages, page)
}
profile.mu.Unlock()
for _, page := range pages {
if err := page.Close(); err != nil {
return err
}
}
id, path, err := d.extensionWebViewLocation(identity)
if err != nil {
return err
}
if provider := d.cfg.WebViewProvider; provider != nil {
remover, ok := provider.(enginewebview.ProfileRemover)
if !ok {
// An unavailable backend cannot have created a profile this run.
// Still fail if a persisted native profile directory exists.
_, statErr := os.Stat(path)
if provider.IsAvailable() || len(pages) != 0 || !os.IsNotExist(statErr) {
return fmt.Errorf("WebView provider does not support profile removal")
}
} else if err := remover.RemoveProfile(id, path); err != nil {
return err
}
}
return os.RemoveAll(path)
}
@@ -0,0 +1,51 @@
package download
import (
"path/filepath"
"sync"
"testing"
"github.com/GopeedLab/gopeed/pkg/base"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
)
type profileCaptureProvider struct{ options []enginewebview.OpenOptions }
func (*profileCaptureProvider) IsAvailable() bool { return true }
func (p *profileCaptureProvider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
p.options = append(p.options, opts)
return fakeRuntimeWebViewPage{}, nil
}
func TestWebViewProfileBoundToExtension(t *testing.T) {
root := t.TempDir()
d := &Downloader{cfg: &DownloaderConfig{StorageDir: root, DownloaderStoreConfig: &base.DownloaderStoreConfig{}}, configLock: &sync.RWMutex{}}
t.Cleanup(func() {
if d.webviewProxy != nil {
d.webviewProxy.Close()
}
})
provider := &profileCaptureProvider{}
for _, id := range []string{"extension-a", "extension-b", "extension-a", "../../escape"} {
opener := &extensionWebViewOpener{downloader: d, provider: provider, identity: id}
_, err := opener.Open(enginewebview.OpenOptions{ProfileID: "forged", DataPath: "/tmp/forged", ProxyURL: "http://wrong:80"})
if err != nil {
t.Fatal(err)
}
}
a, b, reopen := provider.options[0], provider.options[1], provider.options[2]
if a.ProfileID == b.ProfileID || a.DataPath == b.DataPath {
t.Fatal("different extensions shared browser storage")
}
if a.ProfileID != reopen.ProfileID || a.DataPath != reopen.DataPath {
t.Fatal("same extension lost profile identity")
}
for _, opts := range provider.options {
if filepath.Dir(opts.DataPath) != filepath.Join(root, "webview") {
t.Fatalf("profile escaped storage root: %s", opts.DataPath)
}
if opts.ProxyURL != d.webviewProxy.URL() && opts.ProxyURL != d.webviewProxy.SOCKSURL() {
t.Fatal("host proxy was overridden")
}
}
}
@@ -0,0 +1,189 @@
package download
import (
"errors"
"os"
"path/filepath"
"sync"
"testing"
"github.com/GopeedLab/gopeed/pkg/base"
webview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
)
type removalProvider struct {
pages map[string][]*removalPage
removed []string
failure error
entered chan struct{}
resume chan struct{}
}
type removalPage struct {
fakeRuntimeWebViewPage
closed bool
}
func (p *removalPage) Close() error { p.closed = true; return nil }
func (p *removalProvider) IsAvailable() bool { return true }
func (p *removalProvider) Open(opts webview.OpenOptions) (webview.Page, error) {
if p.entered != nil {
close(p.entered)
<-p.resume
}
if p.pages == nil {
p.pages = make(map[string][]*removalPage)
}
page := &removalPage{}
p.pages[opts.ProfileID] = append(p.pages[opts.ProfileID], page)
return page, nil
}
func (p *removalProvider) RemoveProfile(id, path string) error {
for _, page := range p.pages[id] {
if !page.closed {
return errors.New("profile removed before closing pages")
}
}
if p.failure != nil {
return p.failure
}
p.removed = append(p.removed, id)
return nil
}
func removalDownloader(t *testing.T, provider *removalProvider) *Downloader {
t.Helper()
storage := NewMemStorage()
if err := storage.Setup([]string{bucketExtension, bucketExtensionStorage}); err != nil {
t.Fatal(err)
}
d := &Downloader{cfg: &DownloaderConfig{StorageDir: t.TempDir(), DownloaderStoreConfig: &base.DownloaderStoreConfig{}, WebViewProvider: provider}, storage: storage, configLock: &sync.RWMutex{}}
t.Cleanup(func() {
if d.webviewProxy != nil {
d.webviewProxy.Close()
}
})
return d
}
func profileSentinel(t *testing.T, d *Downloader, id string) string {
t.Helper()
_, path, err := d.extensionWebViewLocation(id)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(path, 0700); err != nil {
t.Fatal(err)
}
sentinel := filepath.Join(path, "cookies.sqlite")
if err := os.WriteFile(sentinel, []byte("private browser state"), 0600); err != nil {
t.Fatal(err)
}
return sentinel
}
func TestDeleteExtensionRemovesWebViewData(t *testing.T) {
provider := &removalProvider{}
d := removalDownloader(t, provider)
ext, err := d.InstallExtensionByFolder("testdata/extensions/basic", true)
if err != nil {
t.Fatal(err)
}
identity := ext.Identity
opener := &extensionWebViewOpener{downloader: d, provider: provider, identity: identity, profile: d.extensionWebViewProfile(identity)}
for i := 0; i < 2; i++ {
if _, err := opener.Open(webview.OpenOptions{}); err != nil {
t.Fatal(err)
}
}
sentinel := profileSentinel(t, d, identity)
other := profileSentinel(t, d, "other-extension")
// Updating the installation keeps both its profile object and stored data.
if _, err := d.InstallExtensionByFolder("testdata/extensions/basic", true); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(sentinel); err != nil {
t.Fatal("upgrade removed browser data", err)
}
if err := d.DeleteExtension(identity); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
t.Fatalf("browser data retained: %v", err)
}
if _, err := os.Stat(other); err != nil {
t.Fatal("other extension data was removed", err)
}
if _, err := os.Stat(ext.DevPath); err != nil {
t.Fatal("dev source was removed", err)
}
if len(provider.removed) != 1 {
t.Fatal("native profile was not removed")
}
if _, err := opener.Open(webview.OpenOptions{}); err == nil {
t.Fatal("old runtime recreated removed profile")
}
if _, err := d.InstallExtensionByFolder("testdata/extensions/basic", true); err != nil {
t.Fatal(err)
}
fresh := &extensionWebViewOpener{downloader: d, provider: provider, identity: identity, profile: d.extensionWebViewProfile(identity)}
if _, err := fresh.Open(webview.OpenOptions{}); err != nil {
t.Fatal("reinstall could not open profile", err)
}
if _, err := opener.Open(webview.OpenOptions{}); err == nil {
t.Fatal("reinstall reactivated old runtime")
}
}
func TestDeleteExtensionProfileFailureIsRetryable(t *testing.T) {
provider := &removalProvider{failure: errors.New("native store busy")}
d := removalDownloader(t, provider)
ext, err := d.InstallExtensionByFolder("testdata/extensions/basic", true)
if err != nil {
t.Fatal(err)
}
sentinel := profileSentinel(t, d, ext.Identity)
if err := d.DeleteExtension(ext.Identity); err == nil {
t.Fatal("cleanup failure was swallowed")
}
if _, err := d.GetExtension(ext.Identity); err != nil {
t.Fatal("extension removed before cleanup completed")
}
if _, err := os.Stat(sentinel); err != nil {
t.Fatal("profile path removed before native cleanup completed")
}
provider.failure = nil
if err := d.DeleteExtension(ext.Identity); err != nil {
t.Fatal(err)
}
if _, err := d.GetExtension(ext.Identity); !errors.Is(err, ErrExtensionNotFound) {
t.Fatal("extension was not uninstalled")
}
}
func TestRemoveProfileWaitsForOpeningPage(t *testing.T) {
provider := &removalProvider{entered: make(chan struct{}), resume: make(chan struct{})}
d := removalDownloader(t, provider)
opener := &extensionWebViewOpener{downloader: d, provider: provider, identity: "race", profile: d.extensionWebViewProfile("race")}
opened := make(chan error, 1)
go func() { _, err := opener.Open(webview.OpenOptions{}); opened <- err }()
<-provider.entered
removed := make(chan error, 1)
go func() { removed <- d.removeExtensionWebViewProfile("race") }()
close(provider.resume)
if err := <-opened; err != nil {
t.Fatal(err)
}
if err := <-removed; err != nil {
t.Fatal(err)
}
if len(provider.removed) != 1 {
t.Fatal("profile not removed")
}
}
func TestUninstallWithoutAvailableWebView(t *testing.T) {
d := removalDownloader(t, &removalProvider{})
d.cfg.WebViewProvider = webview.NewUnavailableProvider()
ext, err := d.InstallExtensionByFolder("testdata/extensions/basic", true)
if err != nil {
t.Fatal(err)
}
if err := d.DeleteExtension(ext.Identity); err != nil {
t.Fatal(err)
}
}
+4
View File
@@ -29,3 +29,7 @@ linter:
formatter:
page_width: 120
analyzer:
exclude:
- third_party/**
@@ -3,6 +3,7 @@ import 'dart:collection';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../../core/common/start_config.dart';
@@ -73,7 +74,6 @@ class WebViewRpcService {
final Map<String, WebViewRpcPageSession> _pagesById = {};
final Completer<void> _overlayReady = Completer<void>();
late final CookieManager _cookieManager = CookieManager.instance();
RpcServerHandle? _server;
int _pageSeq = 0;
@@ -129,6 +129,9 @@ class WebViewRpcService {
switch (method) {
case 'webview.isAvailable':
return {'available': true};
case 'profile.remove':
await _removeProfile(_string(params, 'profileId'));
return {};
case 'page.open':
return _openPage(params);
case 'page.addInitScript':
@@ -162,10 +165,12 @@ class WebViewRpcService {
}
Future<Map<String, dynamic>> _openPage(Map<String, dynamic> params) async {
final profile = WebViewProfile(_string(params, 'profileId'));
await profile.prepare(proxyUrl: Uri.parse(_string(params, 'proxyUrl')));
final pageId = 'page-${++_pageSeq}';
final session = WebViewRpcPageSession(
pageId: pageId,
cookieManager: _cookieManager,
profile: profile,
headless: params['headless'] as bool? ?? false,
debug: params['debug'] as bool? ?? false,
title: params['title'] as String? ?? '',
@@ -185,6 +190,15 @@ class WebViewRpcService {
return {'pageId': pageId};
}
Future<void> _removeProfile(String id) async {
for (final page in _pagesById.values.where((page) => page.profile.id == id).toList()) {
await _closePage(page.pageId);
}
// Let visible platform views unmount before releasing their native store.
await WidgetsBinding.instance.endOfFrame;
await WebViewProfile(id).remove();
}
WebViewRpcPageSession _page(Map<String, dynamic> params) {
final page = _pagesById[_pageId(params)];
if (page == null) {
@@ -271,6 +285,9 @@ class WebViewRpcService {
if (error is WebViewRpcException) {
return {'code': error.code, 'message': error.message};
}
if (error is PlatformException && error.code == 'UNAVAILABLE') {
return {'code': error.code, 'message': error.message ?? 'WebView is unavailable'};
}
final code = switch (method) {
'page.goto' => 'NAVIGATION_FAILED',
'page.execute' => 'EVALUATION_FAILED',
@@ -283,7 +300,7 @@ class WebViewRpcService {
class WebViewRpcPageSession {
WebViewRpcPageSession({
required this.pageId,
required this.cookieManager,
required this.profile,
required this.headless,
required this.debug,
required this.title,
@@ -293,7 +310,8 @@ class WebViewRpcPageSession {
});
final String pageId;
final CookieManager cookieManager;
final WebViewProfile profile;
late final CookieManager cookieManager = profile.cookieManager;
final bool headless;
final bool debug;
final String title;
@@ -449,6 +467,8 @@ class WebViewRpcPageSession {
'path': cookie.path ?? '/',
'secure': cookie.isSecure,
'httpOnly': cookie.isHttpOnly,
if (cookie.expiresDate != null)
'expires': DateTime.fromMillisecondsSinceEpoch(cookie.expiresDate!, isUtc: true).toIso8601String(),
};
})
.toList(growable: false);
@@ -524,6 +544,7 @@ class WebViewRpcPageSession {
}
InAppWebViewSettings get _settings => InAppWebViewSettings(
profileId: profile.id,
javaScriptEnabled: true,
transparentBackground: true,
isInspectable: debug,
+5
View File
@@ -2,6 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
+29 -24
View File
@@ -457,18 +457,20 @@ packages:
flutter_inappwebview:
dependency: "direct main"
description:
name: flutter_inappwebview
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
url: "https://pub.dev"
source: hosted
path: flutter_inappwebview
ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
url: "https://github.com/GopeedLab/flutter_inappwebview.git"
source: git
version: "6.1.5"
flutter_inappwebview_android:
dependency: transitive
dependency: "direct overridden"
description:
name: flutter_inappwebview_android
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
url: "https://pub.dev"
source: hosted
path: flutter_inappwebview_android
ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
url: "https://github.com/GopeedLab/flutter_inappwebview.git"
source: git
version: "1.1.3"
flutter_inappwebview_internal_annotations:
dependency: transitive
@@ -479,28 +481,31 @@ packages:
source: hosted
version: "1.3.0"
flutter_inappwebview_ios:
dependency: transitive
dependency: "direct overridden"
description:
name: flutter_inappwebview_ios
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
url: "https://pub.dev"
source: hosted
path: flutter_inappwebview_ios
ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
url: "https://github.com/GopeedLab/flutter_inappwebview.git"
source: git
version: "1.1.2"
flutter_inappwebview_macos:
dependency: transitive
dependency: "direct overridden"
description:
name: flutter_inappwebview_macos
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
url: "https://pub.dev"
source: hosted
path: flutter_inappwebview_macos
ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
url: "https://github.com/GopeedLab/flutter_inappwebview.git"
source: git
version: "1.1.2"
flutter_inappwebview_platform_interface:
dependency: transitive
dependency: "direct overridden"
description:
name: flutter_inappwebview_platform_interface
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
url: "https://pub.dev"
source: hosted
path: flutter_inappwebview_platform_interface
ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
url: "https://github.com/GopeedLab/flutter_inappwebview.git"
source: git
version: "1.3.0+1"
flutter_inappwebview_web:
dependency: transitive
+26
View File
@@ -86,6 +86,32 @@ dependencies:
go_router: ^16.2.1
dependency_overrides:
# Public persistent-profile and proxy APIs; keep all packages on one commit.
flutter_inappwebview:
git:
url: https://github.com/GopeedLab/flutter_inappwebview.git
ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
path: flutter_inappwebview
flutter_inappwebview_platform_interface:
git:
url: https://github.com/GopeedLab/flutter_inappwebview.git
ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
path: flutter_inappwebview_platform_interface
flutter_inappwebview_macos:
git:
url: https://github.com/GopeedLab/flutter_inappwebview.git
ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
path: flutter_inappwebview_macos
flutter_inappwebview_ios:
git:
url: https://github.com/GopeedLab/flutter_inappwebview.git
ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
path: flutter_inappwebview_ios
flutter_inappwebview_android:
git:
url: https://github.com/GopeedLab/flutter_inappwebview.git
ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8
path: flutter_inappwebview_android
# Prevent a native Windows crash when the push notification service is unavailable.
# Keep this fork until https://github.com/MaikuB/flutter_local_notifications/pull/2813 is merged and released.
flutter_local_notifications: