mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-08 17:40:28 +00:00
Remove extension WebView data during uninstall
This commit is contained in:
1 parent
c47b735fc4
commit
d642ae5018
25 files changed
+609
-19
No files matched your search
@@ -3,7 +3,7 @@ module github.com/GopeedLab/gopeed
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260913163948-312792cc8833
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260914021003-80e1a0717dbc
|
||||
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
|
||||
|
||||
@@ -23,8 +23,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-20260913163948-312792cc8833 h1:g0jFsuDhGZvALiBr1rxiNZlPC5eb4H9k7KUpkQ//MuI=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260913163948-312792cc8833/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260914021003-80e1a0717dbc h1:YDlMnhe2WyTmSJF2g2QCM8GbzYYrz9id0SyQgwdUulM=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260914021003-80e1a0717dbc/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=
|
||||
|
||||
@@ -10,6 +10,19 @@ Different identities use different native stores. Go providers use a SHA-256-bas
|
||||
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
|
||||
|
||||
@@ -618,3 +618,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -98,10 +98,12 @@ type Progress struct {
|
||||
}
|
||||
|
||||
type Downloader struct {
|
||||
webviewProxyLock sync.Mutex
|
||||
webviewProxy *webviewproxy.Server
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ import "encoding/json"
|
||||
const RPCEndpointPath = "/webview"
|
||||
|
||||
const (
|
||||
MethodProfileRemove = "profile.remove"
|
||||
MethodIsAvailable = "webview.isAvailable"
|
||||
MethodPageOpen = "page.open"
|
||||
MethodPageAddInitScript = "page.addInitScript"
|
||||
@@ -147,3 +148,8 @@ type PageCloseParams struct {
|
||||
}
|
||||
|
||||
type EmptyResult struct{}
|
||||
|
||||
// ProfileRemoveParams belongs to the private host RPC.
|
||||
type ProfileRemoveParams struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -133,7 +133,7 @@ func (d *Downloader) newExtensionWebViewRuntime(session *engineSession, identity
|
||||
available bool
|
||||
)
|
||||
if provider := d.cfg.WebViewProvider; provider != nil && provider.IsAvailable() {
|
||||
opener = &extensionWebViewOpener{downloader: d, provider: provider, identity: identity}
|
||||
opener = &extensionWebViewOpener{downloader: d, provider: provider, identity: identity, profile: d.extensionWebViewProfile(identity)}
|
||||
available = true
|
||||
}
|
||||
runtime := enginewebview.NewRuntime(opener, available)
|
||||
|
||||
@@ -3,9 +3,12 @@ package download
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
@@ -19,24 +22,38 @@ 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")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(o.identity))
|
||||
opts.ProfileID = 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(o.downloader.cfg.StorageDir, "webview", fmt.Sprintf("%x", sum[:])))
|
||||
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.DataPath = path
|
||||
opts.ProfileID, opts.DataPath = id, path
|
||||
opts.ProxyURL, err = o.downloader.webviewProxyURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return o.provider.Open(opts)
|
||||
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) {
|
||||
@@ -72,3 +89,95 @@ func (d *Downloader) webviewProxyURL() (string, error) {
|
||||
}
|
||||
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,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)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ class WebViewProfile {
|
||||
final String id;
|
||||
static const _channel = MethodChannel('com.pichillilorenzo/flutter_inappwebview_cookiemanager');
|
||||
|
||||
Future<void> remove() async {
|
||||
await _call('gopeed.removeProfile', {});
|
||||
}
|
||||
|
||||
Future<void> prepare(String proxyUrl) async {
|
||||
await _call('gopeed.prepareProfile', {'proxyUrl': proxyUrl});
|
||||
}
|
||||
|
||||
@@ -130,6 +130,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':
|
||||
@@ -188,6 +191,15 @@ class WebViewRpcService {
|
||||
return {'pageId': pageId};
|
||||
}
|
||||
|
||||
Future<void> _removeProfile(String id) async {
|
||||
for (final page in _pagesById.values.where((page) => page.cookieManager.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) {
|
||||
|
||||
Vendored
+5
-1
@@ -11,7 +11,7 @@ are available in the parent commit's pubspec.lock.
|
||||
|
||||
Local native changes are limited to:
|
||||
|
||||
- `GopeedProfiles`: prepare a persistent profile and apply the internal loopback
|
||||
- `GopeedProfiles`: remove profiles on uninstall and prepare a persistent profile and apply the internal loopback
|
||||
proxy before creating a WebView. Apple uses SOCKS5 to cover HTTP as well as HTTPS;
|
||||
Android uses the application-wide HTTP proxy override completion callback.
|
||||
- `InAppWebViewSettings` and `InAppWebView`: read the host-only `gopeedProfileId`
|
||||
@@ -29,3 +29,7 @@ an explicit unavailable error rather than opening a shared, unproxied browser.
|
||||
When updating upstream, refresh these three packages, reapply the above changes,
|
||||
and run `tool/webview_profile_check.dart` on a native Apple host. Do not patch the
|
||||
user's pub cache: dependency_overrides selects these tracked sources reproducibly.
|
||||
|
||||
Android uses AndroidX WebKit 1.13.0 for profile-scoped full browsing-data deletion.
|
||||
Loaded profile shells are queued durably for deletion before WebViews initialize
|
||||
at the next process start. No global cookie/cache clearing is used for uninstall.
|
||||
@@ -29,7 +29,7 @@ android {
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 19
|
||||
minSdkVersion 21
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
@@ -49,7 +49,7 @@ android {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation 'androidx.webkit:webkit:1.12.0'
|
||||
implementation 'androidx.webkit:webkit:1.13.0'
|
||||
implementation 'androidx.browser:browser:1.8.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
|
||||
+83
@@ -1,6 +1,12 @@
|
||||
package com.pichillilorenzo.flutter_inappwebview_android;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import androidx.webkit.Profile;
|
||||
import androidx.webkit.WebStorageCompat;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import androidx.webkit.ProfileStore;
|
||||
import androidx.webkit.ProxyConfig;
|
||||
import androidx.webkit.ProxyController;
|
||||
@@ -10,6 +16,76 @@ import io.flutter.plugin.common.MethodChannel;
|
||||
|
||||
// Internal host bridge: profile creation and proxy completion precede navigation.
|
||||
final class GopeedProfiles {
|
||||
private static SharedPreferences preferences;
|
||||
private static final Set<String> cleared = new HashSet<>();
|
||||
private static final String DELETIONS = "pendingDeletions";
|
||||
|
||||
static void initialize(Context context) {
|
||||
if (preferences != null) return;
|
||||
preferences = context.getSharedPreferences("gopeed_webview_profiles", Context.MODE_PRIVATE);
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) return;
|
||||
for (String id : deletions()) {
|
||||
try {
|
||||
// Do this before getProfile/getOrCreateProfile load any profile.
|
||||
ProfileStore.getInstance().deleteProfile(id);
|
||||
setPending(id, false);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Keep the durable entry and reject use until removal succeeds.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> deletions() {
|
||||
return new HashSet<>(preferences.getStringSet(DELETIONS, new HashSet<>()));
|
||||
}
|
||||
|
||||
private static void setPending(String id, boolean pending) {
|
||||
Set<String> ids = deletions();
|
||||
if (pending) ids.add(id); else ids.remove(id);
|
||||
if (!preferences.edit().putStringSet(DELETIONS, ids).commit())
|
||||
throw new IllegalStateException("Unable to persist WebView profile removal");
|
||||
}
|
||||
|
||||
static void remove(MethodCall call, MethodChannel.Result result) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) {
|
||||
result.error("UNAVAILABLE", "Android WebView profile removal is unavailable", null);
|
||||
return;
|
||||
}
|
||||
String id = call.argument("gopeedProfileId");
|
||||
try {
|
||||
java.util.UUID.fromString(id);
|
||||
setPending(id, true);
|
||||
try {
|
||||
ProfileStore.getInstance().deleteProfile(id);
|
||||
setPending(id, false);
|
||||
cleared.remove(id);
|
||||
result.success(true);
|
||||
return;
|
||||
} catch (IllegalStateException loadedProfile) {
|
||||
// Android forbids deleting a profile loaded in this process, even
|
||||
// after every WebView has been destroyed. Clear all its data now;
|
||||
// remove the empty profile at the next process start.
|
||||
}
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)) {
|
||||
result.error("PROFILE_REMOVE_FAILED", "Restart Gopeed to finish removing WebView profile data", null);
|
||||
return;
|
||||
}
|
||||
Profile profile = ProfileStore.getInstance().getProfile(id);
|
||||
if (profile == null) {
|
||||
setPending(id, false);
|
||||
result.success(true);
|
||||
return;
|
||||
}
|
||||
profile.getGeolocationPermissions().clearAll();
|
||||
WebStorageCompat.deleteBrowsingData(profile.getWebStorage(), () -> {
|
||||
cleared.add(id);
|
||||
result.success(true);
|
||||
});
|
||||
} catch (RuntimeException e) {
|
||||
result.error("PROFILE_REMOVE_FAILED", "Unable to remove WebView profile data", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static String appliedProxy;
|
||||
private static String pendingProxy;
|
||||
private static final java.util.List<MethodChannel.Result> pending = new java.util.ArrayList<>();
|
||||
@@ -26,6 +102,13 @@ final class GopeedProfiles {
|
||||
Uri uri = Uri.parse(proxy);
|
||||
if (!"http".equals(uri.getScheme()) || !"127.0.0.1".equals(uri.getHost()) || uri.getPort() <= 0)
|
||||
throw new IllegalArgumentException("Invalid host proxy configuration");
|
||||
if (deletions().contains(id)) {
|
||||
if (!cleared.contains(id)) throw new IllegalStateException("Profile removal requires restart");
|
||||
// Reinstallation may reuse the now-empty profile. Do not delete its
|
||||
// new data on the next start.
|
||||
setPending(id, false);
|
||||
cleared.remove(id);
|
||||
}
|
||||
ProfileStore.getInstance().getOrCreateProfile(id);
|
||||
if (proxy.equals(appliedProxy)) { result.success(true); return; }
|
||||
if (pendingProxy != null) {
|
||||
|
||||
+1
@@ -87,6 +87,7 @@ public class InAppWebViewFlutterPlugin implements FlutterPlugin, ActivityAware {
|
||||
|
||||
private void onAttachedToEngine(Context applicationContext, BinaryMessenger messenger, Activity activity, PlatformViewRegistry platformViewRegistry, FlutterView flutterView) {
|
||||
this.applicationContext = applicationContext;
|
||||
GopeedProfiles.initialize(applicationContext);
|
||||
this.activity = activity;
|
||||
this.messenger = messenger;
|
||||
this.flutterView = flutterView;
|
||||
|
||||
+4
@@ -49,6 +49,10 @@ public class MyCookieManager extends ChannelDelegateImpl {
|
||||
|
||||
@Override
|
||||
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||
if (call.method.equals("gopeed.removeProfile")) {
|
||||
GopeedProfiles.remove(call, result);
|
||||
return;
|
||||
}
|
||||
if (call.method.equals("gopeed.prepareProfile")) {
|
||||
GopeedProfiles.prepare(call, result);
|
||||
return;
|
||||
|
||||
+39
@@ -32,4 +32,43 @@ final class GopeedProfiles {
|
||||
proxyURLs[identifier] = proxyURL
|
||||
result(true)
|
||||
}
|
||||
static func remove(arguments: NSDictionary?, result: @escaping FlutterResult) {
|
||||
guard let identifier = arguments?["gopeedProfileId"] as? String,
|
||||
let uuid = UUID(uuidString: identifier) else {
|
||||
result(FlutterError(code: "INVALID_REQUEST", message: "Invalid WebView profile identifier", details: nil))
|
||||
return
|
||||
}
|
||||
guard #available(macOS 14.0, iOS 17.0, *) else {
|
||||
// Named stores could not have been created on these OS versions.
|
||||
result(true)
|
||||
return
|
||||
}
|
||||
// Deleting a named store alone can leave in-process cookie caches.
|
||||
// Clear every website data type before releasing/removing the store.
|
||||
let store = stores[identifier] ?? WKWebsiteDataStore(forIdentifier: uuid)
|
||||
store.removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), modifiedSince: Date.distantPast) {
|
||||
stores.removeValue(forKey: identifier)
|
||||
proxyURLs.removeValue(forKey: identifier)
|
||||
removeStore(uuid, attempts: 100, result: result)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14.0, iOS 17.0, *)
|
||||
private static func removeStore(_ uuid: UUID, attempts: Int, result: @escaping FlutterResult) {
|
||||
WKWebsiteDataStore.remove(forIdentifier: uuid) { error in
|
||||
if error != nil && attempts > 1 {
|
||||
// Network-process references can outlive platform-view disposal.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
removeStore(uuid, attempts: attempts - 1, result: result)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let error = error {
|
||||
result(FlutterError(code: "PROFILE_REMOVE_FAILED", message: error.localizedDescription, details: nil))
|
||||
} else {
|
||||
result(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+4
@@ -22,6 +22,10 @@ public class MyCookieManager: ChannelDelegate {
|
||||
|
||||
public override func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let arguments = call.arguments as? NSDictionary
|
||||
if call.method == "gopeed.removeProfile" {
|
||||
GopeedProfiles.remove(arguments: arguments, result: result)
|
||||
return
|
||||
}
|
||||
if call.method == "gopeed.prepareProfile" {
|
||||
GopeedProfiles.prepare(arguments: arguments, result: result)
|
||||
return
|
||||
|
||||
+39
@@ -32,4 +32,43 @@ final class GopeedProfiles {
|
||||
proxyURLs[identifier] = proxyURL
|
||||
result(true)
|
||||
}
|
||||
static func remove(arguments: NSDictionary?, result: @escaping FlutterResult) {
|
||||
guard let identifier = arguments?["gopeedProfileId"] as? String,
|
||||
let uuid = UUID(uuidString: identifier) else {
|
||||
result(FlutterError(code: "INVALID_REQUEST", message: "Invalid WebView profile identifier", details: nil))
|
||||
return
|
||||
}
|
||||
guard #available(macOS 14.0, iOS 17.0, *) else {
|
||||
// Named stores could not have been created on these OS versions.
|
||||
result(true)
|
||||
return
|
||||
}
|
||||
// Deleting a named store alone can leave in-process cookie caches.
|
||||
// Clear every website data type before releasing/removing the store.
|
||||
let store = stores[identifier] ?? WKWebsiteDataStore(forIdentifier: uuid)
|
||||
store.removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), modifiedSince: Date.distantPast) {
|
||||
stores.removeValue(forKey: identifier)
|
||||
proxyURLs.removeValue(forKey: identifier)
|
||||
removeStore(uuid, attempts: 100, result: result)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14.0, iOS 17.0, *)
|
||||
private static func removeStore(_ uuid: UUID, attempts: Int, result: @escaping FlutterResult) {
|
||||
WKWebsiteDataStore.remove(forIdentifier: uuid) { error in
|
||||
if error != nil && attempts > 1 {
|
||||
// Network-process references can outlive platform-view disposal.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
removeStore(uuid, attempts: attempts - 1, result: result)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let error = error {
|
||||
result(FlutterError(code: "PROFILE_REMOVE_FAILED", message: error.localizedDescription, details: nil))
|
||||
} else {
|
||||
result(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+4
@@ -22,6 +22,10 @@ public class MyCookieManager: ChannelDelegate {
|
||||
|
||||
public override func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let arguments = call.arguments as? NSDictionary
|
||||
if call.method == "gopeed.removeProfile" {
|
||||
GopeedProfiles.remove(arguments: arguments, result: result)
|
||||
return
|
||||
}
|
||||
if call.method == "gopeed.prepareProfile" {
|
||||
GopeedProfiles.prepare(arguments: arguments, result: result)
|
||||
return
|
||||
|
||||
@@ -11,7 +11,9 @@ Future<void> main() async {
|
||||
runApp(const MaterialApp(home: SizedBox()));
|
||||
try {
|
||||
await checkProfiles();
|
||||
stdout.writeln('PASS: native proxy, concurrent profiles, cookies, LocalStorage, IndexedDB and reopening');
|
||||
stdout.writeln(
|
||||
'PASS: native proxy, concurrent profiles, cookies, LocalStorage, IndexedDB, reopening and profile deletion',
|
||||
);
|
||||
exit(0);
|
||||
} catch (error, stack) {
|
||||
stderr.writeln('FAIL: $error\n$stack');
|
||||
@@ -190,11 +192,27 @@ Future<void> checkProfiles() async {
|
||||
expectValue((await a.getCookies(url: target)).single.isHttpOnly, true);
|
||||
await a.deleteCookie(url: target, name: 'session', domain: 'profiles.invalid');
|
||||
expectValue((await a.getCookies(url: target)).length, 0);
|
||||
for (final page in pages) {
|
||||
await page.dispose();
|
||||
}
|
||||
pages.clear();
|
||||
final untouched = await open(b);
|
||||
await js(untouched, "localStorage.setItem('owner','other')");
|
||||
await a.setCookie(url: target, name: 'delete-me', value: 'secret', isHttpOnly: true);
|
||||
await a.remove();
|
||||
await a.remove(); // Uninstall retries must be safe.
|
||||
final reinstalled = await open(a);
|
||||
expectValue(await js(reinstalled, "localStorage.getItem('owner')"), null);
|
||||
expectValue(await database(reinstalled, false), null);
|
||||
expectValue((await a.getCookies(url: target)).length, 0);
|
||||
expectValue(await js(untouched, "localStorage.getItem('owner')"), 'other');
|
||||
if (proxyHits == 0) throw StateError('Browser bypassed proxy');
|
||||
} finally {
|
||||
for (final page in pages) {
|
||||
await page.dispose();
|
||||
}
|
||||
await a.remove();
|
||||
await b.remove();
|
||||
await proxy.close();
|
||||
await plainOrigin.close(force: true);
|
||||
await origin.close(force: true);
|
||||
|
||||
Reference in new issue
Block a user