diff --git a/docs/api/session.md b/docs/api/session.md index e560d27fc2b9..41126bb92521 100644 --- a/docs/api/session.md +++ b/docs/api/session.md @@ -1360,6 +1360,36 @@ specified when registering the protocol. Returns `Promise` - resolves when the code cache clear operation is complete. +#### `ses.getSharedDictionaryUsageInfo()` + +Returns `Promise` - an array of shared dictionary information entries in Chromium's networking service's storage. + +Shared dictionaries are used to power advanced compression of data sent over the wire, specifically with Brotli and ZStandard. You don't need to call any of the shared dictionary APIs in Electron to make use of this advanced web feature, but if you do, they allow deeper control and inspection of the shared dictionaries used during decompression. + +To get detailed information about a specific shared dictionary entry, call `getSharedDictionaryInfo(options)`. + +#### `ses.getSharedDictionaryInfo(options)` + +* `options` Object + * `frameOrigin` string - The origin of the frame where the request originates. It’s specific to the individual frame making the request and is defined by its scheme, host, and port. In practice, will look like a URL. + * `topFrameSite` string - The site of the top-level browsing context (the main frame or tab that contains the request). It’s less granular than `frameOrigin` and focuses on the broader "site" scope. In practice, will look like a URL. + +Returns `Promise` - an array of shared dictionary information entries in Chromium's networking service's storage. + +To get information about all present shared dictionaries, call `getSharedDictionaryUsageInfo()`. + +#### `ses.clearSharedDictionaryCache()` + +Returns `Promise` - resolves when the dictionary cache has been cleared, both in memory and on disk. + +#### `ses.clearSharedDictionaryCacheForIsolationKey(options)` + +* `options` Object + * `frameOrigin` string - The origin of the frame where the request originates. It’s specific to the individual frame making the request and is defined by its scheme, host, and port. In practice, will look like a URL. + * `topFrameSite` string - The site of the top-level browsing context (the main frame or tab that contains the request). It’s less granular than `frameOrigin` and focuses on the broader "site" scope. In practice, will look like a URL. + +Returns `Promise` - resolves when the dictionary cache has been cleared for the specified isolation key, both in memory and on disk. + #### `ses.setSpellCheckerEnabled(enable)` * `enable` boolean @@ -1537,6 +1567,8 @@ This method clears more types of data and is more thorough than the **Note:** Cookies are stored at a broader scope than origins. When removing cookies and filtering by `origins` (or `excludeOrigins`), the cookies will be removed at the [registrable domain](https://url.spec.whatwg.org/#host-registrable-domain) level. For example, clearing cookies for the origin `https://really.specific.origin.example.com/` will end up clearing all cookies for `example.com`. Clearing cookies for the origin `https://my.website.example.co.uk/` will end up clearing all cookies for `example.co.uk`. +**Note:** Clearing cache data will also clear the shared dictionary cache. This means that any dictionaries used for compression may be reloaded after clearing the cache. If you wish to clear the shared dictionary cache but leave other cached data intact, you may want to use the `clearSharedDictionaryCache` method. + For more information, refer to Chromium's [`BrowsingDataRemover` interface][browsing-data-remover]. ### Instance Properties diff --git a/docs/api/structures/shared-dictionary-info.md b/docs/api/structures/shared-dictionary-info.md new file mode 100644 index 000000000000..09710d5fd6fa --- /dev/null +++ b/docs/api/structures/shared-dictionary-info.md @@ -0,0 +1,12 @@ +# SharedDictionaryInfo Object + +* `match` string - The matching path pattern for the dictionary which was declared in 'use-as-dictionary' response header's `match` option. +* `matchDestinations` string[] - An array of matching destinations for the dictionary which was declared in 'use-as-dictionary' response header's `match-dest` option. +* `id` string - The Id for the dictionary which was declared in 'use-as-dictionary' response header's `id` option. +* `dictionaryUrl` string - URL of the dictionary. +* `lastFetchTime` Date - The time of when the dictionary was received from the network layer. +* `responseTime` Date - The time of when the dictionary was received from the server. For cached responses, this time could be "far" in the past. +* `expirationDuration` number - The expiration time for the dictionary which was declared in 'use-as-dictionary' response header's `expires` option in seconds. +* `lastUsedTime` Date - The time when the dictionary was last used. +* `size` number - The amount of bytes stored for this shared dictionary information object in Chromium's internal storage (usually Sqlite). +* `hash` string - The sha256 hash of the dictionary binary. diff --git a/docs/api/structures/shared-dictionary-usage-info.md b/docs/api/structures/shared-dictionary-usage-info.md new file mode 100644 index 000000000000..c0b9217d1878 --- /dev/null +++ b/docs/api/structures/shared-dictionary-usage-info.md @@ -0,0 +1,5 @@ +# SharedDictionaryUsageInfo Object + +* `frameOrigin` string - The origin of the frame where the request originates. It’s specific to the individual frame making the request and is defined by its scheme, host, and port. In practice, will look like a URL. +* `topFrameSite` string - The site of the top-level browsing context (the main frame or tab that contains the request). It’s less granular than `frameOrigin` and focuses on the broader "site" scope. In practice, will look like a URL. +* `totalSizeBytes` number - The amount of bytes stored for this shared dictionary information object in Chromium's internal storage (usually Sqlite). diff --git a/filenames.auto.gni b/filenames.auto.gni index 6dc0cf6e5b61..d937bcd9e745 100644 --- a/filenames.auto.gni +++ b/filenames.auto.gni @@ -133,6 +133,8 @@ auto_filenames = { "docs/api/structures/segmented-control-segment.md", "docs/api/structures/serial-port.md", "docs/api/structures/service-worker-info.md", + "docs/api/structures/shared-dictionary-info.md", + "docs/api/structures/shared-dictionary-usage-info.md", "docs/api/structures/shared-worker-info.md", "docs/api/structures/sharing-item.md", "docs/api/structures/shortcut-details.md", diff --git a/shell/browser/api/electron_api_session.cc b/shell/browser/api/electron_api_session.cc index 223a495e8041..e72a309caa9e 100644 --- a/shell/browser/api/electron_api_session.cc +++ b/shell/browser/api/electron_api_session.cc @@ -54,6 +54,7 @@ #include "net/http/http_util.h" #include "services/network/network_service.h" #include "services/network/public/cpp/features.h" +#include "services/network/public/cpp/request_destination.h" #include "services/network/public/mojom/clear_data_filter.mojom.h" #include "shell/browser/api/electron_api_app.h" #include "shell/browser/api/electron_api_cookies.h" @@ -79,6 +80,7 @@ #include "shell/common/gin_converters/gurl_converter.h" #include "shell/common/gin_converters/media_converter.h" #include "shell/common/gin_converters/net_converter.h" +#include "shell/common/gin_converters/time_converter.h" #include "shell/common/gin_converters/usb_protected_classes_converter.h" #include "shell/common/gin_converters/value_converter.h" #include "shell/common/gin_helper/dictionary.h" @@ -1074,6 +1076,178 @@ std::vector Session::GetPreloads() const { return prefs->preloads(); } +/** + * Exposes the network service's ClearSharedDictionaryCacheForIsolationKey + * method, allowing clearing the Shared Dictionary cache for a given isolation + * key. Details about the feature available at + * https://developer.chrome.com/blog/shared-dictionary-compression + */ +v8::Local Session::ClearSharedDictionaryCacheForIsolationKey( + const gin_helper::Dictionary& options) { + gin_helper::Promise promise(isolate_); + auto handle = promise.GetHandle(); + + GURL frame_origin_url, top_frame_site_url; + if (!options.Get("frameOrigin", &frame_origin_url) || + !options.Get("topFrameSite", &top_frame_site_url)) { + promise.RejectWithErrorMessage( + "Must provide frameOrigin and topFrameSite strings to " + "`clearSharedDictionaryCacheForIsolationKey`"); + return handle; + } + + if (!frame_origin_url.is_valid() || !top_frame_site_url.is_valid()) { + promise.RejectWithErrorMessage( + "Invalid URLs provided for frameOrigin or topFrameSite"); + return handle; + } + + url::Origin frame_origin = url::Origin::Create(frame_origin_url); + net::SchemefulSite top_frame_site(top_frame_site_url); + net::SharedDictionaryIsolationKey isolation_key(frame_origin, top_frame_site); + + browser_context_->GetDefaultStoragePartition() + ->GetNetworkContext() + ->ClearSharedDictionaryCacheForIsolationKey( + isolation_key, + base::BindOnce(gin_helper::Promise::ResolvePromise, + std::move(promise))); + + return handle; +} + +/** + * Exposes the network service's ClearSharedDictionaryCache + * method, allowing clearing the Shared Dictionary cache. + * https://developer.chrome.com/blog/shared-dictionary-compression + */ +v8::Local Session::ClearSharedDictionaryCache() { + gin_helper::Promise promise(isolate_); + auto handle = promise.GetHandle(); + + browser_context_->GetDefaultStoragePartition() + ->GetNetworkContext() + ->ClearSharedDictionaryCache( + base::Time(), base::Time::Max(), + nullptr /*mojom::ClearDataFilterPtr*/, + base::BindOnce(gin_helper::Promise::ResolvePromise, + std::move(promise))); + + return handle; +} + +/** + * Exposes the network service's GetSharedDictionaryInfo method, allowing + * inspection of Shared Dictionary information. Details about the feature + * available at https://developer.chrome.com/blog/shared-dictionary-compression + */ +v8::Local Session::GetSharedDictionaryInfo( + const gin_helper::Dictionary& options) { + gin_helper::Promise> promise(isolate_); + auto handle = promise.GetHandle(); + + GURL frame_origin_url, top_frame_site_url; + if (!options.Get("frameOrigin", &frame_origin_url) || + !options.Get("topFrameSite", &top_frame_site_url)) { + promise.RejectWithErrorMessage( + "Must provide frameOrigin and topFrameSite strings"); + return handle; + } + + if (!frame_origin_url.is_valid() || !top_frame_site_url.is_valid()) { + promise.RejectWithErrorMessage( + "Invalid URLs provided for frameOrigin or topFrameSite"); + return handle; + } + + url::Origin frame_origin = url::Origin::Create(frame_origin_url); + net::SchemefulSite top_frame_site(top_frame_site_url); + net::SharedDictionaryIsolationKey isolation_key(frame_origin, top_frame_site); + + browser_context_->GetDefaultStoragePartition() + ->GetNetworkContext() + ->GetSharedDictionaryInfo( + isolation_key, + base::BindOnce( + [](gin_helper::Promise> + promise, + std::vector info) { + v8::Isolate* isolate = promise.isolate(); + v8::HandleScope handle_scope(isolate); + + std::vector result; + result.reserve(info.size()); + + for (const auto& item : info) { + gin_helper::Dictionary dict = + gin_helper::Dictionary::CreateEmpty(isolate); + dict.Set("match", item->match); + + // Convert RequestDestination enum values to strings + std::vector destinations; + for (const auto& dest : item->match_dest) { + destinations.push_back( + network::RequestDestinationToString(dest)); + } + dict.Set("matchDestinations", destinations); + dict.Set("id", item->id); + dict.Set("dictionaryUrl", item->dictionary_url.spec()); + dict.Set("lastFetchTime", item->last_fetch_time); + dict.Set("responseTime", item->response_time); + dict.Set("expirationDuration", + item->expiration.InMillisecondsF()); + dict.Set("lastUsedTime", item->last_used_time); + dict.Set("size", item->size); + dict.Set("hash", net::HashValue(item->hash).ToString()); + + result.push_back(dict); + } + + promise.Resolve(result); + }, + std::move(promise))); + + return handle; +} + +/** + * Exposes the network service's GetSharedDictionaryUsageInfo method, allowing + * inspection of Shared Dictionary information. Details about the feature + * available at https://developer.chrome.com/blog/shared-dictionary-compression + */ +v8::Local Session::GetSharedDictionaryUsageInfo() { + gin_helper::Promise> promise(isolate_); + auto handle = promise.GetHandle(); + + browser_context_->GetDefaultStoragePartition() + ->GetNetworkContext() + ->GetSharedDictionaryUsageInfo(base::BindOnce( + [](gin_helper::Promise> promise, + const std::vector& info) { + v8::Isolate* isolate = promise.isolate(); + v8::HandleScope handle_scope(isolate); + + std::vector result; + result.reserve(info.size()); + + for (const auto& item : info) { + gin_helper::Dictionary dict = + gin_helper::Dictionary::CreateEmpty(isolate); + dict.Set("frameOrigin", + item.isolation_key.frame_origin().Serialize()); + dict.Set("topFrameSite", + item.isolation_key.top_frame_site().Serialize()); + dict.Set("totalSizeBytes", item.total_size_bytes); + result.push_back(dict); + } + + promise.Resolve(result); + }, + std::move(promise))); + + return handle; +} + #if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS) v8::Local Session::LoadExtension( const base::FilePath& extension_path, @@ -1627,6 +1801,13 @@ void Session::FillObjectTemplate(v8::Isolate* isolate, &Session::CreateInterruptedDownload) .SetMethod("setPreloads", &Session::SetPreloads) .SetMethod("getPreloads", &Session::GetPreloads) + .SetMethod("getSharedDictionaryUsageInfo", + &Session::GetSharedDictionaryUsageInfo) + .SetMethod("getSharedDictionaryInfo", &Session::GetSharedDictionaryInfo) + .SetMethod("clearSharedDictionaryCache", + &Session::ClearSharedDictionaryCache) + .SetMethod("clearSharedDictionaryCacheForIsolationKey", + &Session::ClearSharedDictionaryCacheForIsolationKey) #if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS) .SetMethod("loadExtension", &Session::LoadExtension) .SetMethod("removeExtension", &Session::RemoveExtension) diff --git a/shell/browser/api/electron_api_session.h b/shell/browser/api/electron_api_session.h index e603e77f361c..902609b2c707 100644 --- a/shell/browser/api/electron_api_session.h +++ b/shell/browser/api/electron_api_session.h @@ -140,6 +140,12 @@ class Session final : public gin::Wrappable, void CreateInterruptedDownload(const gin_helper::Dictionary& options); void SetPreloads(const std::vector& preloads); std::vector GetPreloads() const; + v8::Local GetSharedDictionaryInfo( + const gin_helper::Dictionary& options); + v8::Local GetSharedDictionaryUsageInfo(); + v8::Local ClearSharedDictionaryCache(); + v8::Local ClearSharedDictionaryCacheForIsolationKey( + const gin_helper::Dictionary& options); v8::Local Cookies(v8::Isolate* isolate); v8::Local Protocol(v8::Isolate* isolate); v8::Local ServiceWorkerContext(v8::Isolate* isolate); diff --git a/spec/api-session-spec.ts b/spec/api-session-spec.ts index 323e48735809..46e94256bd3c 100644 --- a/spec/api-session-spec.ts +++ b/spec/api-session-spec.ts @@ -277,6 +277,106 @@ describe('session module', () => { }); }); + describe('shared dictionary APIs', () => { + // Shared dictionaries can only be created from real https websites, which we + // lack the APIs to fake in CI. If you're working on this code, you can run + // the real-internet tests below by uncommenting the `skip` below. + // In CI, we'll run simple tests here that ensure that the code in question doesn't + // crash, even if we expect it to not return any real dictionaries. + it('can get shared dictionary usage info', async () => { + expect(await session.defaultSession.getSharedDictionaryUsageInfo()).to.deep.equal([]); + }); + + it('can get shared dictionary info', async () => { + expect(await session.defaultSession.getSharedDictionaryInfo({ + frameOrigin: 'https://compression-dictionary-transport-threejs-demo.glitch.me', + topFrameSite: 'https://compression-dictionary-transport-threejs-demo.glitch.me' + })).to.deep.equal([]); + }); + + it('can clear shared dictionary cache', async () => { + await session.defaultSession.clearSharedDictionaryCache(); + }); + + it('can clear shared dictionary cache for isolation key', async () => { + await session.defaultSession.clearSharedDictionaryCacheForIsolationKey({ + frameOrigin: 'https://compression-dictionary-transport-threejs-demo.glitch.me', + topFrameSite: 'https://compression-dictionary-transport-threejs-demo.glitch.me' + }); + }); + }); + + describe.skip('shared dictionary APIs (using a real website with real dictionaries)', () => { + const appPath = path.join(fixtures, 'api', 'shared-dictionary'); + const runApp = (command: 'getSharedDictionaryInfo' | 'getSharedDictionaryUsageInfo' | 'clearSharedDictionaryCache' | 'clearSharedDictionaryCacheForIsolationKey') => { + return new Promise((resolve) => { + let output = ''; + + const appProcess = ChildProcess.spawn( + process.execPath, + [appPath, command] + ); + + appProcess.stdout.on('data', data => { output += data; }); + appProcess.on('exit', () => { + const trimmedOutput = output.replaceAll(/(\r\n|\n|\r)/gm, ''); + + try { + resolve(JSON.parse(trimmedOutput)); + } catch (e) { + console.error(`Error trying to deserialize ${trimmedOutput}`); + throw e; + } + }); + }); + }; + + afterEach(() => { + fs.rmSync(path.join(fixtures, 'api', 'shared-dictionary', 'user-data-dir'), { recursive: true }); + }); + + it('can get shared dictionary usage info', async () => { + // In our fixture, this calls session.defaultSession.getSharedDictionaryUsageInfo() + expect(await runApp('getSharedDictionaryUsageInfo')).to.deep.equal([{ + frameOrigin: 'https://compression-dictionary-transport-threejs-demo.glitch.me', + topFrameSite: 'https://compression-dictionary-transport-threejs-demo.glitch.me', + totalSizeBytes: 1198641 + }]); + }); + + it('can get shared dictionary info', async () => { + // In our fixture, this calls session.defaultSession.getSharedDictionaryInfo({ + // frameOrigin: 'https://compression-dictionary-transport-threejs-demo.glitch.me', + // topFrameSite: 'https://compression-dictionary-transport-threejs-demo.glitch.me' + // }) + const sharedDictionaryInfo = await runApp('getSharedDictionaryInfo') as Electron.SharedDictionaryInfo[]; + + expect(sharedDictionaryInfo).to.have.lengthOf(1); + expect(sharedDictionaryInfo[0].match).to.not.be.undefined(); + expect(sharedDictionaryInfo[0].hash).to.not.be.undefined(); + expect(sharedDictionaryInfo[0].lastFetchTime).to.not.be.undefined(); + expect(sharedDictionaryInfo[0].responseTime).to.not.be.undefined(); + expect(sharedDictionaryInfo[0].expirationDuration).to.not.be.undefined(); + expect(sharedDictionaryInfo[0].lastUsedTime).to.not.be.undefined(); + expect(sharedDictionaryInfo[0].size).to.not.be.undefined(); + }); + + it('can clear shared dictionary cache', async () => { + // In our fixture, this calls session.defaultSession.clearSharedDictionaryCache() + // followed by session.defaultSession.getSharedDictionaryUsageInfo() + expect(await runApp('clearSharedDictionaryCache')).to.deep.equal([]); + }); + + it('can clear shared dictionary cache for isolation key', async () => { + // In our fixture, this calls session.defaultSession.clearSharedDictionaryCacheForIsolationKey({ + // frameOrigin: 'https://compression-dictionary-transport-threejs-demo.glitch.me', + // topFrameSite: 'https://compression-dictionary-transport-threejs-demo.glitch.me' + // }) + // followed by session.defaultSession.getSharedDictionaryUsageInfo() + expect(await runApp('clearSharedDictionaryCacheForIsolationKey')).to.deep.equal([]); + }); + }); + describe('will-download event', () => { afterEach(closeAllWindows); it('can cancel default download behavior', async () => { diff --git a/spec/fixtures/api/shared-dictionary/main.js b/spec/fixtures/api/shared-dictionary/main.js new file mode 100644 index 000000000000..431893ec307c --- /dev/null +++ b/spec/fixtures/api/shared-dictionary/main.js @@ -0,0 +1,42 @@ +const { app, BrowserWindow, session } = require('electron'); + +const path = require('node:path'); + +app.setPath('userData', path.join(__dirname, 'user-data-dir')); + +// Grab the command to run from process.argv +const command = process.argv[2]; +app.whenReady().then(async () => { + const bw = new BrowserWindow({ show: true }); + await bw.loadURL('https://compression-dictionary-transport-threejs-demo.glitch.me/demo.html?r=151'); + + // Wait a second for glitch to load, it sometimes takes a while + // if the glitch app is booting up (did-finish-load will fire too soon) + await new Promise(resolve => setTimeout(resolve, 1000)); + + try { + let result; + const isolationKey = { + frameOrigin: 'https://compression-dictionary-transport-threejs-demo.glitch.me', + topFrameSite: 'https://compression-dictionary-transport-threejs-demo.glitch.me' + }; + + if (command === 'getSharedDictionaryInfo') { + result = await session.defaultSession.getSharedDictionaryInfo(isolationKey); + } else if (command === 'getSharedDictionaryUsageInfo') { + result = await session.defaultSession.getSharedDictionaryUsageInfo(); + } else if (command === 'clearSharedDictionaryCache') { + await session.defaultSession.clearSharedDictionaryCache(); + result = await session.defaultSession.getSharedDictionaryUsageInfo(); + } else if (command === 'clearSharedDictionaryCacheForIsolationKey') { + await session.defaultSession.clearSharedDictionaryCacheForIsolationKey(isolationKey); + result = await session.defaultSession.getSharedDictionaryUsageInfo(); + } + + console.log(JSON.stringify(result)); + } catch (e) { + console.log('error', e); + } finally { + app.quit(); + } +}); diff --git a/spec/fixtures/api/shared-dictionary/package.json b/spec/fixtures/api/shared-dictionary/package.json new file mode 100644 index 000000000000..15ce54dfad0d --- /dev/null +++ b/spec/fixtures/api/shared-dictionary/package.json @@ -0,0 +1,4 @@ +{ + "name": "electron-test-shared-dictionary-app", + "main": "main.js" +}