Remove extension WebView data during uninstall

This commit is contained in:
Levi committed 2026-09-14 10:11:53 +08:00
1 parent c47b735fc4
commit d642ae5018
25 files changed
+609 -19

No files matched your search

+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+13
View File
@@ -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
+9
View File
@@ -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
}
+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)
}
@@ -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)
}
}
+6 -4
View File
@@ -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
+6
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"
@@ -147,3 +148,8 @@ type PageCloseParams struct {
}
type EmptyResult struct{}
// ProfileRemoveParams belongs to the private host RPC.
type ProfileRemoveParams struct {
ProfileID string `json:"profileId"`
}
+5
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
+9
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 {
+1 -1
View File
@@ -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)
+115 -6
View File
@@ -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) {
+5 -1
View File
@@ -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'
@@ -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) {
@@ -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;
@@ -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;
@@ -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)
}
}
}
}
@@ -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
@@ -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)
}
}
}
}
@@ -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
+19 -1
View File
@@ -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);