From d642ae50187ac5a530c5b9863509a01f0eb53961 Mon Sep 17 00:00:00 2001 From: Levi Date: Mon, 14 Sep 2026 10:11:53 +0800 Subject: [PATCH] Remove extension WebView data during uninstall --- go.mod | 2 +- go.sum | 4 +- internal/webview/README.md | 13 ++ internal/webview/goprovider/provider.go | 9 + internal/webview/rpcprovider/provider.go | 12 +- internal/webview/rpcprovider/provider_test.go | 26 +++ pkg/download/downloader.go | 10 +- pkg/download/engine/webview/rpc.go | 6 + pkg/download/engine/webview/runtime.go | 5 + pkg/download/extension.go | 9 + pkg/download/extension_engine_session.go | 2 +- pkg/download/extension_webview_config.go | 121 ++++++++++- pkg/download/extension_webview_remove_test.go | 189 ++++++++++++++++++ ui/flutter/lib/app/rpc/webview_profile.dart | 4 + .../lib/app/rpc/webview_rpc_service.dart | 12 ++ ui/flutter/third_party/README.md | 6 +- .../android/build.gradle | 4 +- .../GopeedProfiles.java | 83 ++++++++ .../InAppWebViewFlutterPlugin.java | 1 + .../MyCookieManager.java | 4 + .../ios/Classes/GopeedProfiles.swift | 39 ++++ .../ios/Classes/MyCookieManager.swift | 4 + .../macos/Classes/GopeedProfiles.swift | 39 ++++ .../macos/Classes/MyCookieManager.swift | 4 + ui/flutter/tool/webview_profile_check.dart | 20 +- 25 files changed, 609 insertions(+), 19 deletions(-) create mode 100644 pkg/download/extension_webview_remove_test.go diff --git a/go.mod b/go.mod index c397d41f..dda65f07 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 5f1e9d04..c8f554b2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/webview/README.md b/internal/webview/README.md index 9b7e3e18..5e849659 100644 --- a/internal/webview/README.md +++ b/internal/webview/README.md @@ -10,6 +10,19 @@ Different identities use different native stores. Go providers use a SHA-256-bas path under `/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 diff --git a/internal/webview/goprovider/provider.go b/internal/webview/goprovider/provider.go index d58d2422..da6130ff 100644 --- a/internal/webview/goprovider/provider.go +++ b/internal/webview/goprovider/provider.go @@ -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 +} diff --git a/internal/webview/rpcprovider/provider.go b/internal/webview/rpcprovider/provider.go index 9a22bea2..607bb5a6 100644 --- a/internal/webview/rpcprovider/provider.go +++ b/internal/webview/rpcprovider/provider.go @@ -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) +} diff --git a/internal/webview/rpcprovider/provider_test.go b/internal/webview/rpcprovider/provider_test.go index 9ec03ac1..0a28cd9f 100644 --- a/internal/webview/rpcprovider/provider_test.go +++ b/internal/webview/rpcprovider/provider_test.go @@ -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) + } +} diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 6eb98058..d3a4ff6a 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -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 diff --git a/pkg/download/engine/webview/rpc.go b/pkg/download/engine/webview/rpc.go index d3fbf64b..77c91976 100644 --- a/pkg/download/engine/webview/rpc.go +++ b/pkg/download/engine/webview/rpc.go @@ -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"` +} diff --git a/pkg/download/engine/webview/runtime.go b/pkg/download/engine/webview/runtime.go index 763e87ef..cc115826 100644 --- a/pkg/download/engine/webview/runtime.go +++ b/pkg/download/engine/webview/runtime.go @@ -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 diff --git a/pkg/download/extension.go b/pkg/download/extension.go index 8733072c..53f9e989 100644 --- a/pkg/download/extension.go +++ b/pkg/download/extension.go @@ -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 { diff --git a/pkg/download/extension_engine_session.go b/pkg/download/extension_engine_session.go index 92379550..7d5b10c9 100644 --- a/pkg/download/extension_engine_session.go +++ b/pkg/download/extension_engine_session.go @@ -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) diff --git a/pkg/download/extension_webview_config.go b/pkg/download/extension_webview_config.go index 2eb02d17..164df1e4 100644 --- a/pkg/download/extension_webview_config.go +++ b/pkg/download/extension_webview_config.go @@ -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) +} diff --git a/pkg/download/extension_webview_remove_test.go b/pkg/download/extension_webview_remove_test.go new file mode 100644 index 00000000..50bc3191 --- /dev/null +++ b/pkg/download/extension_webview_remove_test.go @@ -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) + } +} diff --git a/ui/flutter/lib/app/rpc/webview_profile.dart b/ui/flutter/lib/app/rpc/webview_profile.dart index 0b172930..15329862 100644 --- a/ui/flutter/lib/app/rpc/webview_profile.dart +++ b/ui/flutter/lib/app/rpc/webview_profile.dart @@ -18,6 +18,10 @@ class WebViewProfile { final String id; static const _channel = MethodChannel('com.pichillilorenzo/flutter_inappwebview_cookiemanager'); + Future remove() async { + await _call('gopeed.removeProfile', {}); + } + Future prepare(String proxyUrl) async { await _call('gopeed.prepareProfile', {'proxyUrl': proxyUrl}); } diff --git a/ui/flutter/lib/app/rpc/webview_rpc_service.dart b/ui/flutter/lib/app/rpc/webview_rpc_service.dart index df560ef7..1bf4676e 100644 --- a/ui/flutter/lib/app/rpc/webview_rpc_service.dart +++ b/ui/flutter/lib/app/rpc/webview_rpc_service.dart @@ -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 _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 params) { final page = _pagesById[_pageId(params)]; if (page == null) { diff --git a/ui/flutter/third_party/README.md b/ui/flutter/third_party/README.md index 09448d82..af35170b 100644 --- a/ui/flutter/third_party/README.md +++ b/ui/flutter/third_party/README.md @@ -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. diff --git a/ui/flutter/third_party/flutter_inappwebview_android/android/build.gradle b/ui/flutter/third_party/flutter_inappwebview_android/android/build.gradle index ddb812ca..d0fec691 100755 --- a/ui/flutter/third_party/flutter_inappwebview_android/android/build.gradle +++ b/ui/flutter/third_party/flutter_inappwebview_android/android/build.gradle @@ -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' diff --git a/ui/flutter/third_party/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/GopeedProfiles.java b/ui/flutter/third_party/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/GopeedProfiles.java index 81e13f62..62508c17 100644 --- a/ui/flutter/third_party/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/GopeedProfiles.java +++ b/ui/flutter/third_party/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/GopeedProfiles.java @@ -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 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 deletions() { + return new HashSet<>(preferences.getStringSet(DELETIONS, new HashSet<>())); + } + + private static void setPending(String id, boolean pending) { + Set 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 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) { diff --git a/ui/flutter/third_party/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/InAppWebViewFlutterPlugin.java b/ui/flutter/third_party/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/InAppWebViewFlutterPlugin.java index 21ce254a..20caa3cf 100755 --- a/ui/flutter/third_party/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/InAppWebViewFlutterPlugin.java +++ b/ui/flutter/third_party/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/InAppWebViewFlutterPlugin.java @@ -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; diff --git a/ui/flutter/third_party/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/MyCookieManager.java b/ui/flutter/third_party/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/MyCookieManager.java index 345725e6..3f2bcab8 100755 --- a/ui/flutter/third_party/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/MyCookieManager.java +++ b/ui/flutter/third_party/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/MyCookieManager.java @@ -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; diff --git a/ui/flutter/third_party/flutter_inappwebview_ios/ios/Classes/GopeedProfiles.swift b/ui/flutter/third_party/flutter_inappwebview_ios/ios/Classes/GopeedProfiles.swift index f431fc7b..18c65c2c 100644 --- a/ui/flutter/third_party/flutter_inappwebview_ios/ios/Classes/GopeedProfiles.swift +++ b/ui/flutter/third_party/flutter_inappwebview_ios/ios/Classes/GopeedProfiles.swift @@ -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) + } + } + } + } diff --git a/ui/flutter/third_party/flutter_inappwebview_ios/ios/Classes/MyCookieManager.swift b/ui/flutter/third_party/flutter_inappwebview_ios/ios/Classes/MyCookieManager.swift index acc147bb..98d23d29 100755 --- a/ui/flutter/third_party/flutter_inappwebview_ios/ios/Classes/MyCookieManager.swift +++ b/ui/flutter/third_party/flutter_inappwebview_ios/ios/Classes/MyCookieManager.swift @@ -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 diff --git a/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/GopeedProfiles.swift b/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/GopeedProfiles.swift index e2f455ae..ca0602f5 100644 --- a/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/GopeedProfiles.swift +++ b/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/GopeedProfiles.swift @@ -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) + } + } + } + } diff --git a/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/MyCookieManager.swift b/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/MyCookieManager.swift index 763078ed..7be4c01f 100755 --- a/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/MyCookieManager.swift +++ b/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/MyCookieManager.swift @@ -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 diff --git a/ui/flutter/tool/webview_profile_check.dart b/ui/flutter/tool/webview_profile_check.dart index 94ae2d92..2a84af54 100644 --- a/ui/flutter/tool/webview_profile_check.dart +++ b/ui/flutter/tool/webview_profile_check.dart @@ -11,7 +11,9 @@ Future 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 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);