diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..6a8c8a1 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "fallout.cli": { + "version": "11.0.18", + "commands": [ + "fallout" + ] + } + } +} diff --git a/.nuke/build.schema.json b/.fallout/build.schema.json similarity index 55% rename from .nuke/build.schema.json rename to .fallout/build.schema.json index c1999a9..82690d1 100644 --- a/.nuke/build.schema.json +++ b/.fallout/build.schema.json @@ -1,19 +1,55 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "title": "Build Schema", - "$ref": "#/definitions/build", "definitions": { - "build": { - "type": "object", + "Host": { + "type": "string", + "enum": [ + "AppVeyor", + "AzurePipelines", + "Bamboo", + "Bitbucket", + "Bitrise", + "GitHubActions", + "GitLab", + "Jenkins", + "Rider", + "SpaceAutomation", + "TeamCity", + "Terminal", + "TravisCI", + "VisualStudio", + "VSCode" + ] + }, + "ExecutableTarget": { + "type": "string", + "enum": [ + "Clean", + "Compile", + "CreatePackage", + "Default", + "Package", + "PrePublish", + "Publish", + "PublishPackage", + "PublishPreRelease", + "PublishRelease", + "Restore", + "RunUnitTests" + ] + }, + "Verbosity": { + "type": "string", + "description": "", + "enum": [ + "Verbose", + "Normal", + "Minimal", + "Quiet" + ] + }, + "FalloutBuild": { "properties": { - "Configuration": { - "type": "string", - "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)", - "enum": [ - "Debug", - "Release" - ] - }, "Continue": { "type": "boolean", "description": "Indicates to continue a previously failed build attempt" @@ -23,25 +59,8 @@ "description": "Shows the help text for this build assembly" }, "Host": { - "type": "string", "description": "Host for execution. Default is 'automatic'", - "enum": [ - "AppVeyor", - "AzurePipelines", - "Bamboo", - "Bitbucket", - "Bitrise", - "GitHubActions", - "GitLab", - "Jenkins", - "Rider", - "SpaceAutomation", - "TeamCity", - "Terminal", - "TravisCI", - "VisualStudio", - "VSCode" - ] + "$ref": "#/definitions/Host" }, "NoLogo": { "type": "boolean", @@ -62,10 +81,6 @@ "type": "string" } }, - "ReleaseNotesFilePath": { - "type": "string", - "description": "ReleaseNotesFilePath - To determine the SemanticVersion" - }, "Root": { "type": "string", "description": "Root directory during build execution" @@ -74,61 +89,57 @@ "type": "array", "description": "List of targets to be skipped. Empty list skips all dependencies", "items": { - "type": "string", - "enum": [ - "Clean", - "Compile", - "CopyFiles", - "CreatePackage", - "Default", - "Package", - "PrePublish", - "Publish", - "PublishPackage", - "PublishPreRelease", - "PublishRelease", - "Restore", - "RunUnitTests" - ] + "$ref": "#/definitions/ExecutableTarget" } }, - "Solution": { - "type": "string", - "description": "Path to a solution file that is automatically loaded" - }, "Target": { "type": "array", "description": "List of targets to be invoked. Default is '{default_target}'", "items": { - "type": "string", - "enum": [ - "Clean", - "Compile", - "CopyFiles", - "CreatePackage", - "Default", - "Package", - "PrePublish", - "Publish", - "PublishPackage", - "PublishPreRelease", - "PublishRelease", - "Restore", - "RunUnitTests" - ] + "$ref": "#/definitions/ExecutableTarget" } }, "Verbosity": { - "type": "string", "description": "Logging verbosity during build execution. Default is 'Normal'", + "$ref": "#/definitions/Verbosity" + }, + "BuildProjectFile": { + "type": [ + "null", + "string" + ], + "description": "Path to the build project (.csproj) relative to the repository root. Defaults to 'build/_build.csproj' when unset. Read by the Fallout global tool's in-tool runner." + } + } + } + }, + "allOf": [ + { + "properties": { + "AngleSharpVersion": { + "type": "string", + "description": "AngleSharp package version override (e.g. 1.0.0 for compatibility checks)" + }, + "Configuration": { + "type": "string", "enum": [ - "Minimal", - "Normal", - "Quiet", - "Verbose" - ] + "Debug", + "Release" + ], + "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)" + }, + "ReleaseNotesFilePath": { + "type": "string", + "description": "ReleaseNotesFilePath - To determine the SemanticVersion" + }, + "Solution": { + "type": "string", + "description": "Path to a solution file that is automatically loaded" } } + }, + { + "$ref": "#/definitions/FalloutBuild" } - } -} \ No newline at end of file + ] +} diff --git a/.nuke/parameters.json b/.fallout/parameters.json similarity index 100% rename from .nuke/parameters.json rename to .fallout/parameters.json diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 1f114d6..b74e64f 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,4 @@ # These are supported funding model platforms github: FlorianRappl -custom: https://salt.bountysource.com/teams/anglesharp +custom: ['https://www.paypal.me/FlorianRappl', 'https://buymeacoffee.com/florianrappl'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f01d24d..61e9edf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,12 +26,12 @@ jobs: if: needs.can_document.outputs.value == 'true' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Use Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v5 with: - node-version: "14.x" + node-version: "20.x" registry-url: 'https://registry.npmjs.org' - name: Install Dependencies @@ -48,37 +48,35 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Setup dotnet - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v5 with: dotnet-version: | - 6.0.x - 7.0.x + 10.0.x - name: Build - run: ./build.sh + run: ./build.sh -AngleSharpVersion 1.5.0 windows: runs-on: windows-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Setup dotnet - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v5 with: dotnet-version: | - 6.0.x - 7.0.x + 10.0.x - name: Build run: | if ($env:GITHUB_REF -eq "refs/heads/main") { - .\build.ps1 -Target Publish + .\build.ps1 -Target Publish -AngleSharpVersion 1.5.0 } elseif ($env:GITHUB_REF -eq "refs/heads/devel") { - .\build.ps1 -Target PrePublish + .\build.ps1 -Target PrePublish -AngleSharpVersion 1.5.0 } else { - .\build.ps1 + .\build.ps1 -AngleSharpVersion 1.5.0 } diff --git a/.gitignore b/.gitignore index de3ed79..32b162d 100644 --- a/.gitignore +++ b/.gitignore @@ -59,7 +59,8 @@ local.properties [Dd]ebug/ [Rr]elease/ [Bb]in/ -[Bb]uild/ +build/bin/ +build/obj/ [Oo]bj/ # Visual Studio 2015 cache/options directory @@ -171,7 +172,8 @@ Desktop.ini *.egg *.egg-info dist -build +build/bin +build/obj eggs parts var @@ -195,5 +197,5 @@ pip-log.txt # Mac crap .DS_Store -# Nuke build tool -.nuke/temp +# Fallout build tool +.fallout/temp diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1311aec --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,115 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. `CLAUDE.md` imports this file, so +keep it the single source of truth and every agent reads the same instructions. + +AngleSharp.Io is an AngleSharp extension package that provides additional requesters, +cookie handling, download support, and some DOM/network helpers for headless browsing. + +## Commands + +The orchestrator is Fallout (`build/Build.cs`), bootstrapped by `build.ps1` / `build.sh` +(`build.cmd` forwards to either). Default target is `RunUnitTests`. + +```powershell +.\build.ps1 # restore, compile, run the full test suite +.\build.ps1 -Target Compile # other targets: Clean Restore Compile RunUnitTests +.\build.ps1 -Target Package # CopyFiles CreatePackage Package PrePublish Publish +``` + +For the normal edit/test loop use the SDK directly — much faster than the Fallout bootstrap: + +```powershell +dotnet build src/AngleSharp.Io.sln +dotnet test src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj -f net8.0 +dotnet test src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj -f net8.0 --filter "FullyQualifiedName~ClassicTests" +dotnet test src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj -f net8.0 --filter "Name=SettingSimpleSingleCookieInRequestAppearsInDocument" +``` + +- Always pass `-f net8.0` when iterating. On Windows both projects also target `net462` and + `net472`, so omitting it runs everything three times. +- `TreatWarningsAsErrors` is on (`src/Directory.Build.props`) — a warning breaks the build. + There is no separate lint step; the compiler is it. +- The package version is parsed from the top entry of `CHANGELOG.md` (`ReleaseNotesParser`), + not from a csproj property. Release-worthy changes get a `CHANGELOG.md` line. +- `RunUnitTests` runs the suite twice, differing only in a `prefetched` environment variable + that nothing in this repo currently reads — a single run is equivalent locally. +- There is no `global.json`; the bootstrap scripts use the STS channel, CI installs 10.0.x. + +## Architecture + +The main integration surface is `src/AngleSharp.Io/IoConfigurationExtensions.cs`: + +- `WithRequesters(...)` registers the transport stack (`HttpClientRequester`, `DataRequester`, + `FtpRequester`, `FileRequester`, `AboutRequester`). +- `WithCookies(...)` wires `AdvancedCookieProvider` (memory or file-backed) as the cookie service. +- `WithDownload(...)` / `WithStandardDownload(...)` wraps AngleSharp's document factory to + intercept attachments / binary responses. + +Cookie subsystem layout (`src/AngleSharp.Io/Cookie`): + +- `AdvancedCookieProvider`: storage + RFC-like set/get behavior + expiry pruning. +- `CookieParser` / `WebCookie`: parsing and cookie model. +- `Helpers`: domain/path matching and canonicalization utilities. +- `NetscapeCookieSerializer`: persistence format conversion. +- `LocalFileHandler` / `MemoryFileHandler`: persistence backends. + +Important cookie behavior notes: + +- Domain canonicalization strips a leading dot (`.example.com` -> `example.com`) per RFC6265. +- Host-only cookies must match host exactly; non-host-only cookies use `DomainMatch(...)`. +- Be careful when filtering candidates for `GetCookie(...)`: exact-domain prefiltering can + accidentally exclude valid parent-domain cookies for subdomains (issue #36). +- `GetPublicSuffix(...)` in `Helpers` is currently a placeholder (returns empty string), so avoid + assuming public suffix protections are fully implemented. + +## Code conventions + +From `.github/CONTRIBUTING.md` and `.editorconfig` — these differ from typical modern C#: + +- `using` directives go **inside** the namespace declaration. +- Framework type names, not keywords: `String`, `Int32`, `Boolean`, `Object`. +- Prefer `var` on the left-hand side wherever possible. +- Always use statement blocks; blank line between two non-simple statements. +- `ConfigureAwait(false)` on every `await`. +- 4 spaces, LF, UTF-8, trimmed trailing whitespace; 2 spaces in `*.csproj`. +- Older files use `#region Fields / ctor / Properties / Methods / Helpers`; match the file + you are editing rather than converting it. +- Comments in the newer code explain *why* a non-obvious construct exists (prototype cycles, + laziness, Jint quirks). Keep that density — do not narrate what the code already says. + +## Tests + +NUnit 3 (classic model: `Assert.AreEqual`), fixtures in `src/AngleSharp.Io.Tests`. + +Useful test groups: + +- `Cookie/ParsingTests.cs`: fast, deterministic unit tests for parser + provider behavior. +- `Cookie/ClassicTests.cs`: higher-level cookie behavior, includes some network-dependent tests. +- `Network/*Tests.cs`: requester-focused behavior. + +Fast iteration commands: + +```powershell +dotnet test src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj -f net8.0 --filter "FullyQualifiedName~AngleSharp.Io.Tests.Cookie.ParsingTests" +dotnet test src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj -f net8.0 --filter "FullyQualifiedName~AngleSharp.Io.Tests.Cookie" +``` + +Test-writing tips for cookie work: + +- Prefer `MemoryFileHandler` to keep tests isolated and deterministic. +- For provider-level checks, exercise `ICookieProvider.SetCookie` + `ICookieProvider.GetCookie` + directly with explicit `Url` instances. +- Network-backed tests (e.g., against httpbingo) can fail when payload formats change; treat those + failures as potential external drift before assuming a regression in cookie logic. + +## Repository notes + +These files are synced from the `AngleSharp.GitBase` repository and should not be edited +here: `.editorconfig`, `.gitignore`, `.gitattributes`, `.github/*`, `build.ps1`, `build.sh`, +`tools/*`, `LICENSE`. + +CI (`.github/workflows/ci.yml`) builds on Linux and Windows; on Windows it selects the Fallout +target from the branch (`main` → `Publish`, `devel` → `PrePublish`, otherwise the default). +`CONTRIBUTING.md` asks for feature branches (`feature/#777`) and pull requests against +`devel`. diff --git a/CHANGELOG.md b/CHANGELOG.md index f35dce0..53d59d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +# 1.1.0 + +Released on Friday, July 31 2026. + +- Updated to use a minimum of AngleSharp 1.5 +- Fixed domain handling in cookies #36 +- Added the `Navigator` from *AngleSharp.Js* +- Added the `XmlHttpRequester` from *AngleSharp.Js* +- Added `fetch` for requesting resources +- Added `localStorage` and `sessionStorage` for web storages +- Added `indexedDB` using an IndexedDB implementation +- Added `caches` using a Cache API implementation +- Added `BroadcastChannel` +- Added `locks` using the `LockManager` web locks spec +- Added `Clipboard` providing the web clipboard API +- Added `Geolocation` providing the web geolocation API + # 1.0.0 Released on Sunday, January 15 2023. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9f02784 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +The guidance is shared with every AI agent working here, so it lives in AGENTS.md and is +imported below. Record new guidance there rather than in this file. + +@AGENTS.md diff --git a/LICENSE b/LICENSE index 470f190..eae958a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2013 - 2023 AngleSharp +Copyright (c) 2013 - 2026 AngleSharp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 5619f89..9bb7dd8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,26 @@ AngleSharp.Io extends AngleSharp with powerful requesters, caching mechanisms, a ## Basic Configuration +### All-In-One Setup + +If you want to register all core AngleSharp.Io services in one step, use `WithIo` with `IoOptions`: + +```cs +var options = new IoOptions +{ + // optional; defaults are already provided + ClipboardPlatform = myClipboardPlatform, + GeolocationPlatform = myGeolocationPlatform, +}; + +var config = Configuration.Default + .WithIo(options) + .WithDefaultLoader(); +``` + +`WithIo` wires navigator, cookies, requesters, storage, IndexedDB, and cache in one call. +Clipboard and geolocation are only enabled when platforms are provided. + ### Requesters If you just want to use *all* available requesters provided by AngleSharp.Io you can do the following: @@ -76,6 +96,295 @@ var config = Configuration.Default Alternatively, the new overloads for the `WithCookies` extension method can be used. +### Storage + +AngleSharp.Io provides a single storage engine with two storage modes: + +- `localStorage` using persistent per-origin files +- `sessionStorage` using in-memory buckets scoped to the top-level browsing context and origin + +To configure storage, create and register an `IStorageProviderFactory`: + +```cs +var syncDirectoryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "anglesharp.storage"); +var factory = new StorageProviderFactory(); + +factory.EnableLocalStorage(syncDirectoryPath); +factory.EnableSessionStorage(); + +var config = Configuration.Default.WithStorageProviderFactory(factory); +``` + +Once configured, storage services can be resolved from the browsing context: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); +var localStorage = document.DefaultView.LocalStorage(); +var sessionStorage = document.DefaultView.SessionStorage(); + +localStorage["token"] = "abc"; +sessionStorage["ephemeral"] = "42"; +``` + +### IndexedDB + +AngleSharp.Io provides an in-memory IndexedDB surface that is shared per origin: + +- `indexedDB` using in-memory databases and object stores scoped to the origin +- versioned open with upgrade callback support +- explicit transactions (`ReadOnly` / `ReadWrite`) with commit and abort +- request-style open wrappers (`OpenRequest(...)`) +- object store record methods (`Add`, `Put`, `Get`, `Delete`, `Count`) +- key-range queries (`IndexedDbKeyRange`) +- indexes with optional uniqueness (`CreateIndex`, `GetIndex`, `DeleteIndex`) +- cursors for store and index iteration (`OpenCursor`) +- request state (`Pending` / `Success` / `Error` / `Blocked`) and close lifecycle +- version-change notifications on open connections +- optional auto-unblock open requests (`OpenRequest(..., waitForUnblock: true)`) +- request-based delete with optional auto-unblock (`DeleteDatabaseRequest(..., waitForUnblock: true)`) +- transaction lifecycle state/events (`IsCompleted`, `IsAborted`, `Completed`, `Aborted`) +- single active read-write transaction per database + +To configure IndexedDB, create and register an `IIndexedDbProviderFactory`: + +```cs +var factory = new IndexedDbProviderFactory(); +var config = Configuration.Default.WithIndexedDbProviderFactory(factory); +``` + +Once configured, IndexedDB services can be resolved from the browsing context: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); +var database = document.DefaultView.IndexedDb().Open("app", 1, tx => +{ + tx.CreateObjectStore("records"); +}); + +using (var transaction = database.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) +{ + var store = transaction.GetObjectStore("records"); + var byType = store.CreateIndex("byType", "type"); + + store.Add("token", "abc"); + store.Put("token", "updated"); + + var notes = byType.GetAll("note"); + var range = store.GetAll(IndexedDbKeyRange.Bound("a", "m")); + + transaction.Commit(); +} + +var openRequest = document.DefaultView.IndexedDb().OpenRequest("app", 2, tx => +{ + tx.CreateObjectStore("events"); +}); + +var upgraded = await openRequest.WaitAsync(); + +var cursor = byType.OpenCursor(IndexedDbKeyRange.Bound("note", "task")); + +while (cursor != null) +{ + Console.WriteLine(cursor.Value); + cursor = cursor.Continue(); +} + +database.Close(); + +var waitingRequest = document.DefaultView.IndexedDb().OpenRequest( + "app", + 3, + tx => tx.CreateObjectStore("logs"), + waitForUnblock: true); + +var upgradedAgain = await waitingRequest.WaitAsync(); + +var deleteRequest = document.DefaultView.IndexedDb().DeleteDatabaseRequest("app", waitForUnblock: true); +var deleted = await deleteRequest.WaitAsync(); + +using (var writeTx = upgradedAgain.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) +{ + writeTx.Completed += () => Console.WriteLine("transaction committed"); + writeTx.Aborted += () => Console.WriteLine("transaction aborted"); + writeTx.GetObjectStore("records").Put("k", "v"); + writeTx.Commit(); +} +``` + +### Cache Storage + +AngleSharp.Io also exposes a lightweight Cache Storage surface that is shared per origin: + +- `caches` using in-memory caches and response snapshots scoped to the origin + +To configure Cache Storage, create and register an `ICacheProviderFactory`: + +```cs +var factory = new CacheProviderFactory(); +var config = Configuration.Default.WithCacheProviderFactory(factory); +``` + +Once configured, caches can be resolved from the browsing context: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); +var cache = document.DefaultView.Caches().Open("app"); + +cache.Put("https://example.org/data", new DefaultResponse()); +``` + +### BroadcastChannel + +AngleSharp.Io also provides a lightweight BroadcastChannel surface that is shared per origin: + +- `BroadcastChannel` delivering `message` events to same-origin channels with the same name + +Create a channel from a DOM window: + +```cs +var context = BrowsingContext.New(); +var document = await context.OpenAsync("https://example.org"); + +using var channel = new BroadcastChannel(document.DefaultView, "app"); +channel.Message += (s, e) => Console.WriteLine(((MessageEvent)e).Data); +channel.PostMessage("hello"); +``` + +### Web Locks + +AngleSharp.Io also provides a minimal Web Locks surface that serializes requests per origin and lock name: + +- `locks` using in-memory request queues scoped to the origin + +Create a lock manager from a navigator implementation: + +```cs +var config = Configuration.Default.WithNavigator(); +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); +var navigator = document.DefaultView.Navigator; +var locks = navigator.Locks(); + +await locks.Request("app", async _ => +{ + Console.WriteLine("inside the lock"); + await Task.CompletedTask; +}); +``` + +### Clipboard + +AngleSharp.Io provides a navigator clipboard surface backed by a user-supplied platform implementation: + +- `clipboard` exposing `readText` / `writeText` + +Register a platform implementation during configuration: + +```cs +public sealed class MyClipboardPlatform : IClipboardPlatform +{ + public Task ReadTextAsync() => Task.FromResult("sample"); + + public Task WriteTextAsync(string text) => Task.CompletedTask; +} + +var config = Configuration.Default.WithClipboard(new MyClipboardPlatform()); +``` + +To expose `navigator`, include navigator registration in the configuration: + +```cs +var config = Configuration.Default + .WithNavigator() + .WithClipboard(new MyClipboardPlatform()); +``` + +Resolve it from `INavigator`: + +```cs +var navigator = document.DefaultView.Navigator; +var clipboard = navigator.Clipboard(); + +await clipboard.WriteText("hello"); +var value = await clipboard.ReadText(); +``` + +### Geolocation + +AngleSharp.Io provides a navigator geolocation surface backed by a user-supplied platform implementation: + +- `geolocation` exposing `getCurrentPosition` + +Register a platform implementation during configuration: + +```cs +public sealed class MyGeolocationPlatform : IGeolocationPlatform +{ + public Task GetCurrentPositionAsync(GeolocationOptions options) + { + return Task.FromResult(new GeolocationReading + { + Latitude = 52.52, + Longitude = 13.405, + Accuracy = 8.0, + }); + } +} + +var config = Configuration.Default.WithGeolocation(new MyGeolocationPlatform()); +``` + +To expose `navigator`, include navigator registration in the configuration: + +```cs +var config = Configuration.Default + .WithNavigator() + .WithGeolocation(new MyGeolocationPlatform()); +``` + +Resolve it from `INavigator`: + +```cs +var navigator = document.DefaultView.Navigator; +var geolocation = navigator.Geolocation(); +var position = await geolocation.GetCurrentPosition(); +``` + +### Fetch + +AngleSharp.Io also exposes a `fetch` method on `window`: + +- `fetch` delegates to the configured `IDocumentLoader` + +Configure requesters and a default loader: + +```cs +var config = Configuration.Default + .WithRequesters() + .WithDefaultLoader(); +``` + +Use fetch from a DOM window: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); + +var response = await document.DefaultView.Fetch("/api/data", new FetchOptions +{ + Method = "POST", + Headers = new Dictionary + { + ["X-Requested-With"] = "AngleSharp.Io" + }, + Body = "payload" +}); +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. @@ -124,9 +433,12 @@ The `SaveToAsync` (as well as the `CopyToAsync`) are extension methods for the ` - Supporting file URLs - Enhanced support for about: URLs - WebSockets (mostly interesting for scripting engines, e.g., JS) -- Storage support by providing the `IStorage` interface +- Storage support with a unified `Storage` implementation - Improved cookie container (`AdvancedCookieContainer`) - Enhanced download capabilities for resources / links +- Web Locks support for origin-scoped request serialization +- Clipboard support with injectable platform implementation +- Geolocation support with injectable platform implementation ## Participating @@ -146,12 +458,4 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) ## License -The MIT License (MIT) - -Copyright (c) 2015 - 2023 AngleSharp - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +AngleSharp.Io is released using the MIT license. For more information see the [license file](./LICENSE). diff --git a/build.ps1 b/build.ps1 index 1f264e7..61c6628 100644 --- a/build.ps1 +++ b/build.ps1 @@ -13,16 +13,14 @@ $PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent # CONFIGURATION ########################################################################### -$BuildProjectFile = "$PSScriptRoot\nuke\_build.csproj" -$TempDirectory = "$PSScriptRoot\\.nuke\temp" +$TempDirectory = "$PSScriptRoot\.fallout\temp" -$DotNetGlobalFile = "$PSScriptRoot\\global.json" +$DotNetGlobalFile = "$PSScriptRoot\global.json" $DotNetInstallUrl = "https://dot.net/v1/dotnet-install.ps1" -$DotNetChannel = "Current" +$DotNetChannel = "STS" -$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = 1 $env:DOTNET_CLI_TELEMETRY_OPTOUT = 1 -$env:DOTNET_MULTILEVEL_LOOKUP = 0 +$env:DOTNET_NOLOGO = 1 ########################################################################### # EXECUTION @@ -33,7 +31,7 @@ function ExecSafe([scriptblock] $cmd) { if ($LASTEXITCODE) { exit $LASTEXITCODE } } -# If dotnet CLI is installed globally and it matches requested version, use for execution +# If dotnet CLI is installed globally, use it; otherwise provision a local copy under .fallout\temp. if ($null -ne (Get-Command "dotnet" -ErrorAction SilentlyContinue) -and ` $(dotnet --version) -and $LASTEXITCODE -eq 0) { $env:DOTNET_EXE = (Get-Command "dotnet").Path @@ -61,9 +59,10 @@ else { ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Version $DotNetVersion -NoPath } } $env:DOTNET_EXE = "$DotNetDirectory\dotnet.exe" + $env:PATH = "$DotNetDirectory;$env:PATH" } Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)" -ExecSafe { & $env:DOTNET_EXE build $BuildProjectFile /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet } -ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments } +ExecSafe { & $env:DOTNET_EXE tool restore } +ExecSafe { & $env:DOTNET_EXE fallout $BuildArguments } diff --git a/build.sh b/build.sh index 7534123..fc76bf8 100755 --- a/build.sh +++ b/build.sh @@ -9,16 +9,14 @@ SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) # CONFIGURATION ########################################################################### -BUILD_PROJECT_FILE="$SCRIPT_DIR/nuke/_build.csproj" -TEMP_DIRECTORY="$SCRIPT_DIR//.nuke/temp" +TEMP_DIRECTORY="$SCRIPT_DIR/.fallout/temp" -DOTNET_GLOBAL_FILE="$SCRIPT_DIR//global.json" +DOTNET_GLOBAL_FILE="$SCRIPT_DIR/global.json" DOTNET_INSTALL_URL="https://dot.net/v1/dotnet-install.sh" -DOTNET_CHANNEL="Current" +DOTNET_CHANNEL="STS" export DOTNET_CLI_TELEMETRY_OPTOUT=1 -export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 -export DOTNET_MULTILEVEL_LOOKUP=0 +export DOTNET_NOLOGO=1 ########################################################################### # EXECUTION @@ -28,7 +26,7 @@ function FirstJsonValue { perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" } -# If dotnet CLI is installed globally and it matches requested version, use for execution +# If dotnet CLI is installed globally, use it; otherwise provision a local copy under .fallout/temp. if [ -x "$(command -v dotnet)" ] && dotnet --version &>/dev/null; then export DOTNET_EXE="$(command -v dotnet)" else @@ -54,9 +52,10 @@ else "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version "$DOTNET_VERSION" --no-path fi export DOTNET_EXE="$DOTNET_DIRECTORY/dotnet" + export PATH="$DOTNET_DIRECTORY:$PATH" fi echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" -"$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet -"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@" +"$DOTNET_EXE" tool restore +exec "$DOTNET_EXE" fallout "$@" diff --git a/nuke/.editorconfig b/build/.editorconfig similarity index 100% rename from nuke/.editorconfig rename to build/.editorconfig diff --git a/nuke/Build.cs b/build/Build.cs similarity index 63% rename from nuke/Build.cs rename to build/Build.cs index cbd915b..b3acbd0 100644 --- a/nuke/Build.cs +++ b/build/Build.cs @@ -1,12 +1,11 @@ +using Fallout.Common; +using Fallout.Common.CI.GitHubActions; +using Fallout.Common.IO; +using Fallout.Common.Tools.DotNet; +using Fallout.Common.Tools.GitHub; +using Fallout.Common.Utilities.Collections; +using Fallout.Solutions; using Microsoft.Build.Exceptions; -using Nuke.Common; -using Nuke.Common.CI.GitHubActions; -using Nuke.Common.IO; -using Nuke.Common.ProjectModel; -using Nuke.Common.Tools.DotNet; -using Nuke.Common.Tools.GitHub; -using Nuke.Common.Tools.NuGet; -using Nuke.Common.Utilities.Collections; using Octokit; using Octokit.Internal; using Serilog; @@ -14,28 +13,22 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using static Nuke.Common.IO.FileSystemTasks; -using static Nuke.Common.IO.PathConstruction; -using static Nuke.Common.Tools.DotNet.DotNetTasks; -using static Nuke.Common.Tools.NuGet.NuGetTasks; -using Project = Nuke.Common.ProjectModel.Project; +using static Fallout.Common.Tools.DotNet.DotNetTasks; +using Project = Fallout.Solutions.Project; -class Build : NukeBuild +class Build : FalloutBuild { - /// Support plugins are available for: - /// - JetBrains ReSharper https://nuke.build/resharper - /// - JetBrains Rider https://nuke.build/rider - /// - Microsoft VisualStudio https://nuke.build/visualstudio - /// - Microsoft VSCode https://nuke.build/vscode - public static int Main () => Execute(x => x.RunUnitTests); - [Nuke.Common.Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] + [Fallout.Common.Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; - [Nuke.Common.Parameter("ReleaseNotesFilePath - To determine the SemanticVersion")] + [Fallout.Common.Parameter("ReleaseNotesFilePath - To determine the SemanticVersion")] readonly AbsolutePath ReleaseNotesFilePath = RootDirectory / "CHANGELOG.md"; + [Fallout.Common.Parameter("AngleSharp package version override (e.g. 1.0.0 for compatibility checks)")] + readonly string AngleSharpVersion; + [Solution] readonly Solution Solution; @@ -45,8 +38,6 @@ class Build : NukeBuild AbsolutePath SourceDirectory => RootDirectory / "src"; - AbsolutePath BuildDirectory => SourceDirectory / TargetProjectName / "bin" / Configuration; - AbsolutePath ResultDirectory => RootDirectory / "bin" / Version; AbsolutePath NugetDirectory => ResultDirectory / "nuget"; @@ -55,7 +46,7 @@ class Build : NukeBuild Project TargetProject { get; set; } - // Note: The ChangeLogTasks from Nuke itself look buggy. So using the Cake source code. + // Note: The built-in ChangeLogTasks (inherited from NUKE) look buggy. So using the Cake source code. IReadOnlyList ChangeLog { get; set; } ReleaseNotes LatestReleaseNotes { get; set; } @@ -89,17 +80,17 @@ protected override void OnBuildInitialized() if (ScheduledTargets.Contains(Default)) { - Version = $"{Version}-ci-{buildNumber}"; + Version = $"{Version}-ci.{buildNumber}"; } else if (ScheduledTargets.Contains(PrePublish)) { - Version = $"{Version}-alpha-{buildNumber}"; + Version = $"{Version}-beta.{buildNumber}"; } } Log.Information("Building version: {Version}", Version); - TargetProject = Solution.GetProject(SourceDirectory / TargetProjectName / $"{TargetLibName}.csproj" ); + TargetProject = Solution.GetProject(TargetLibName); TargetProject.NotNull("TargetProject could not be loaded!"); TargetFrameworks = TargetProject.GetTargetFrameworks(); @@ -112,70 +103,93 @@ protected override void OnBuildInitialized() .Before(Restore) .Executes(() => { - SourceDirectory.GlobDirectories("**/bin", "**/obj").ForEach(DeleteDirectory); + SourceDirectory.GlobDirectories("**/bin", "**/obj").ForEach(x => x.DeleteDirectory()); }); Target Restore => _ => _ .Executes(() => { - DotNetRestore(s => s - .SetProjectFile(Solution)); + DotNetRestore(s => + { + var settings = s.SetProjectFile(Solution); + + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } + + return settings; + }); }); Target Compile => _ => _ .DependsOn(Restore) .Executes(() => { - DotNetBuild(s => s - .SetProjectFile(Solution) - .SetConfiguration(Configuration) - .EnableNoRestore()); - }); + DotNetBuild(s => + { + var settings = s + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .SetVersion(Version) + .SetContinuousIntegrationBuild(IsServerBuild) + .EnableNoRestore(); + + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } - Target RunUnitTests => _ => _ - .DependsOn(Compile) - .Executes(() => - { - DotNetTest(s => s - .SetProjectFile(Solution) - .SetConfiguration(Configuration) - .EnableNoRestore() - .EnableNoBuild()); + return settings; + }); }); - Target CopyFiles => _ => _ + Target RunUnitTests => _ => _ .DependsOn(Compile) .Executes(() => { - foreach (var item in TargetFrameworks) + DotNetTest(s => { - var targetDir = NugetDirectory / "lib" / item; - var srcDir = BuildDirectory / item; - - CopyFile(srcDir / $"{TargetProjectName}.dll", targetDir / $"{TargetProjectName}.dll", FileExistsPolicy.OverwriteIfNewer); - CopyFile(srcDir / $"{TargetProjectName}.pdb", targetDir / $"{TargetProjectName}.pdb", FileExistsPolicy.OverwriteIfNewer); - CopyFile(srcDir / $"{TargetProjectName}.xml", targetDir / $"{TargetProjectName}.xml", FileExistsPolicy.OverwriteIfNewer); - } + var settings = s + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .SetProperty("Version", Version) + .EnableNoRestore() + .EnableNoBuild(); + + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } - CopyFile(SourceDirectory / $"{TargetProjectName}.nuspec", NugetDirectory / $"{TargetProjectName}.nuspec", FileExistsPolicy.OverwriteIfNewer); - CopyFile(RootDirectory / "logo.png", NugetDirectory / "logo.png", FileExistsPolicy.OverwriteIfNewer); - CopyFile(RootDirectory / "README.md", NugetDirectory / "README.md", FileExistsPolicy.OverwriteIfNewer); + return settings; + }); }); + // The package is produced by `dotnet pack` straight from the project, so the dependency + // groups follow the actual TargetFrameworks instead of a hand-maintained nuspec. Target CreatePackage => _ => _ - .DependsOn(CopyFiles) + .DependsOn(Compile) .Executes(() => { - var nuspec = NugetDirectory / $"{TargetProjectName}.nuspec"; - - NuGetPack(_ => _ - .SetTargetPath(nuspec) - .SetVersion(Version) - .SetOutputDirectory(NugetDirectory) - .SetSymbols(true) - .SetSymbolPackageFormat("snupkg") - .AddProperty("Configuration", Configuration) - ); + DotNetPack(s => + { + var settings = s + .SetProject(TargetProject) + .SetConfiguration(Configuration) + .SetVersion(Version) + .SetOutputDirectory(NugetDirectory) + .SetContinuousIntegrationBuild(IsServerBuild) + .EnableNoRestore() + .EnableNoBuild(); + + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } + + return settings; + }); }); Target PublishPackage => _ => _ @@ -191,9 +205,10 @@ protected override void OnBuildInitialized() throw new BuildAbortedException("Could not resolve the NuGet API key."); } - foreach (var nupkg in GlobFiles(NugetDirectory, "*.nupkg")) + // Pushing the .nupkg also uploads the matching .snupkg next to it. + foreach (var nupkg in NugetDirectory.GlobFiles("*.nupkg")) { - NuGetPush(s => s + DotNetNuGetPush(s => s .SetTargetPath(nupkg) .SetSource("https://api.nuget.org/v3/index.json") .SetApiKey(apiKey)); @@ -223,7 +238,7 @@ protected override void OnBuildInitialized() var credentials = new Credentials(gitHubToken); GitHubTasks.GitHubClient = new GitHubClient( - new ProductHeaderValue(nameof(NukeBuild)), + new ProductHeaderValue(nameof(FalloutBuild)), new InMemoryCredentialStore(credentials)); GitHubTasks.GitHubClient.Repository.Release @@ -259,7 +274,7 @@ protected override void OnBuildInitialized() var credentials = new Credentials(gitHubToken); GitHubTasks.GitHubClient = new GitHubClient( - new ProductHeaderValue(nameof(NukeBuild)), + new ProductHeaderValue(nameof(FalloutBuild)), new InMemoryCredentialStore(credentials)); GitHubTasks.GitHubClient.Repository.Release diff --git a/nuke/Configuration.cs b/build/Configuration.cs similarity index 93% rename from nuke/Configuration.cs rename to build/Configuration.cs index 9c08b1a..75c63ea 100644 --- a/nuke/Configuration.cs +++ b/build/Configuration.cs @@ -1,7 +1,7 @@ using System; using System.ComponentModel; using System.Linq; -using Nuke.Common.Tooling; +using Fallout.Common.Tooling; [TypeConverter(typeof(TypeConverter))] public class Configuration : Enumeration diff --git a/nuke/Directory.Build.props b/build/Directory.Build.props similarity index 100% rename from nuke/Directory.Build.props rename to build/Directory.Build.props diff --git a/nuke/Directory.Build.targets b/build/Directory.Build.targets similarity index 100% rename from nuke/Directory.Build.targets rename to build/Directory.Build.targets diff --git a/nuke/Extensions/StringExtensions.cs b/build/Extensions/StringExtensions.cs similarity index 100% rename from nuke/Extensions/StringExtensions.cs rename to build/Extensions/StringExtensions.cs diff --git a/nuke/ReleaseNotes.cs b/build/ReleaseNotes.cs similarity index 100% rename from nuke/ReleaseNotes.cs rename to build/ReleaseNotes.cs diff --git a/nuke/ReleaseNotesParser.cs b/build/ReleaseNotesParser.cs similarity index 93% rename from nuke/ReleaseNotesParser.cs rename to build/ReleaseNotesParser.cs index 4b00a07..d911c90 100644 --- a/nuke/ReleaseNotesParser.cs +++ b/build/ReleaseNotesParser.cs @@ -17,16 +17,6 @@ /// public sealed class ReleaseNotesParser { - private readonly Regex _versionRegex; - - /// - /// Initializes a new instance of the class. - /// - public ReleaseNotesParser() - { - _versionRegex = new Regex(@"(?\d+(\s*\.\s*\d+){0,3})(?-[a-z][0-9a-z-]*)?"); - } - /// /// Parses all release notes. /// diff --git a/nuke/SemVersion.cs b/build/SemVersion.cs similarity index 100% rename from nuke/SemVersion.cs rename to build/SemVersion.cs diff --git a/build/_build.csproj b/build/_build.csproj new file mode 100644 index 0000000..647ac26 --- /dev/null +++ b/build/_build.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + CS0649;CS0169 + .. + .. + 1 + + + + + + + + + diff --git a/docs/README.md b/docs/README.md index d76fadb..b65134f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,4 +2,9 @@ We have more detailed information regarding the following subjects: +- [Getting Started](general/01-Basics.md) +- [DOM Objects and Helpers](general/02-Dom-Objects-and-Helpers.md) - [API Documentation](tutorials/01-API.md) +- [Examples](tutorials/02-Examples.md) +- [Common Workflows](tutorials/03-Common-Workflows.md) +- [Frequently Asked Questions](tutorials/04-Questions.md) diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index b14694b..7238962 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -30,6 +30,26 @@ You can also use the graphical library package manager ("Manage NuGet Packages f To use AngleSharp.Io you need to add it to your `Configuration` coming from AngleSharp itself. +### All-In-One Setup + +If you want to register all core AngleSharp.Io services in one step, use `WithIo` with `IoOptions`: + +```cs +var options = new IoOptions +{ + // optional; defaults are already provided + ClipboardPlatform = myClipboardPlatform, + GeolocationPlatform = myGeolocationPlatform, +}; + +var config = Configuration.Default + .WithIo(options) + .WithDefaultLoader(); +``` + +`WithIo` wires navigator, cookies, requesters, storage, IndexedDB, and cache in one call. +Clipboard and geolocation are only enabled when platforms are provided. + ### Requesters If you just want to use *all* available requesters provided by AngleSharp.Io you can do the following: @@ -92,6 +112,296 @@ var config = Configuration.Default Alternatively, the new overloads for the `WithCookies` extension method can be used. +### Storage + +AngleSharp.Io contains a single `Storage` implementation that is exposed as +`localStorage` and `sessionStorage` depending on the configured handler: + +- local storage uses persistent per-origin files +- session storage uses in-memory buckets scoped to the top-level browsing context and origin + +Register storage by providing a storage provider factory: + +```cs +var syncDirectoryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "anglesharp.storage"); +var factory = new StorageProviderFactory(); + +factory.EnableLocalStorage(syncDirectoryPath); +factory.EnableSessionStorage(); + +var config = Configuration.Default.WithStorageProviderFactory(factory); +``` + +Use the services from a browsing context: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); +var localStorage = document.DefaultView.LocalStorage(); +var sessionStorage = document.DefaultView.SessionStorage(); + +localStorage["persisted"] = "yes"; +sessionStorage["transient"] = "yes"; +``` + +### IndexedDB + +AngleSharp.Io exposes an in-memory IndexedDB surface that is shared per origin: + +- `indexedDB` using in-memory databases and object stores scoped to the origin +- versioned open with upgrade callback support +- explicit transactions (`ReadOnly` / `ReadWrite`) with commit and abort +- request-style open wrappers (`OpenRequest(...)`) +- object store record methods (`Add`, `Put`, `Get`, `Delete`, `Count`) +- key-range queries (`IndexedDbKeyRange`) +- indexes with optional uniqueness (`CreateIndex`, `GetIndex`, `DeleteIndex`) +- cursors for store and index iteration (`OpenCursor`) +- request state (`Pending` / `Success` / `Error` / `Blocked`) and close lifecycle +- version-change notifications on open connections +- optional auto-unblock open requests (`OpenRequest(..., waitForUnblock: true)`) +- request-based delete with optional auto-unblock (`DeleteDatabaseRequest(..., waitForUnblock: true)`) +- transaction lifecycle state/events (`IsCompleted`, `IsAborted`, `Completed`, `Aborted`) +- single active read-write transaction per database + +Register IndexedDB by providing a provider factory: + +```cs +var factory = new IndexedDbProviderFactory(); +var config = Configuration.Default.WithIndexedDbProviderFactory(factory); +``` + +Use the service from a browsing context: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); +var database = document.DefaultView.IndexedDb().Open("app", 1, tx => +{ + tx.CreateObjectStore("records"); +}); + +using (var transaction = database.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) +{ + var store = transaction.GetObjectStore("records"); + var byType = store.CreateIndex("byType", "type"); + + store.Add("persisted", "yes"); + store.Put("persisted", "yes-updated"); + + var notes = byType.GetAll("note"); + var range = store.GetAll(IndexedDbKeyRange.Bound("a", "m")); + + transaction.Commit(); +} + +var openRequest = document.DefaultView.IndexedDb().OpenRequest("app", 2, tx => +{ + tx.CreateObjectStore("events"); +}); + +var upgraded = await openRequest.WaitAsync(); + +var cursor = byType.OpenCursor(IndexedDbKeyRange.Bound("note", "task")); + +while (cursor != null) +{ + Console.WriteLine(cursor.Value); + cursor = cursor.Continue(); +} + +database.Close(); + +var waitingRequest = document.DefaultView.IndexedDb().OpenRequest( + "app", + 3, + tx => tx.CreateObjectStore("logs"), + waitForUnblock: true); + +var upgradedAgain = await waitingRequest.WaitAsync(); + +var deleteRequest = document.DefaultView.IndexedDb().DeleteDatabaseRequest("app", waitForUnblock: true); +var deleted = await deleteRequest.WaitAsync(); + +using (var writeTx = upgradedAgain.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) +{ + writeTx.Completed += () => Console.WriteLine("transaction committed"); + writeTx.Aborted += () => Console.WriteLine("transaction aborted"); + writeTx.GetObjectStore("records").Put("k", "v"); + writeTx.Commit(); +} +``` + +### Cache Storage + +AngleSharp.Io also exposes a lightweight Cache Storage surface that is shared per origin: + +- `caches` using in-memory caches and response snapshots scoped to the origin + +Register Cache Storage by providing a provider factory: + +```cs +var factory = new CacheProviderFactory(); +var config = Configuration.Default.WithCacheProviderFactory(factory); +``` + +Use the service from a browsing context: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); +var cache = document.DefaultView.Caches().Open("app"); + +cache.Put("https://example.org/data", new DefaultResponse()); +``` + +### BroadcastChannel + +AngleSharp.Io also provides a lightweight BroadcastChannel surface that is shared per origin: + +- `BroadcastChannel` delivering `message` events to same-origin channels with the same name + +Create a channel from a DOM window: + +```cs +var context = BrowsingContext.New(); +var document = await context.OpenAsync("https://example.org"); + +using var channel = new BroadcastChannel(document.DefaultView, "app"); +channel.Message += (s, e) => Console.WriteLine(((MessageEvent)e).Data); +channel.PostMessage("hello"); +``` + +### Web Locks + +AngleSharp.Io also provides a lightweight Web Locks surface that serializes requests per origin and lock name: + +- `locks` using in-memory request queues scoped to the origin + +Create a lock manager from a navigator implementation: + +```cs +var config = Configuration.Default.WithNavigator(); +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); +var navigator = document.DefaultView.Navigator; +var locks = navigator.Locks(); + +await locks.Request("app", async _ => +{ + Console.WriteLine("inside the lock"); + await Task.CompletedTask; +}); +``` + +### Clipboard + +AngleSharp.Io provides a navigator clipboard surface backed by a user-supplied platform implementation: + +- `clipboard` exposing `readText` / `writeText` + +Register a platform implementation during configuration: + +```cs +public sealed class MyClipboardPlatform : IClipboardPlatform +{ + public Task ReadTextAsync() => Task.FromResult("sample"); + + public Task WriteTextAsync(string text) => Task.CompletedTask; +} + +var config = Configuration.Default.WithClipboard(new MyClipboardPlatform()); +``` + +To expose `navigator`, include navigator registration in the configuration: + +```cs +var config = Configuration.Default + .WithNavigator() + .WithClipboard(new MyClipboardPlatform()); +``` + +Resolve it from `INavigator`: + +```cs +var navigator = document.DefaultView.Navigator; +var clipboard = navigator.Clipboard(); + +await clipboard.WriteText("hello"); +var value = await clipboard.ReadText(); +``` + +### Geolocation + +AngleSharp.Io provides a navigator geolocation surface backed by a user-supplied platform implementation: + +- `geolocation` exposing `getCurrentPosition` + +Register a platform implementation during configuration: + +```cs +public sealed class MyGeolocationPlatform : IGeolocationPlatform +{ + public Task GetCurrentPositionAsync(GeolocationOptions options) + { + return Task.FromResult(new GeolocationReading + { + Latitude = 52.52, + Longitude = 13.405, + Accuracy = 8.0, + }); + } +} + +var config = Configuration.Default.WithGeolocation(new MyGeolocationPlatform()); +``` + +To expose `navigator`, include navigator registration in the configuration: + +```cs +var config = Configuration.Default + .WithNavigator() + .WithGeolocation(new MyGeolocationPlatform()); +``` + +Resolve it from `INavigator`: + +```cs +var navigator = document.DefaultView.Navigator; +var geolocation = navigator.Geolocation(); +var position = await geolocation.GetCurrentPosition(); +``` + +### Fetch + +AngleSharp.Io also exposes a `fetch` method on `window`: + +- `fetch` delegates to the configured `IDocumentLoader` + +Configure requesters and a default loader: + +```cs +var config = Configuration.Default + .WithRequesters() + .WithDefaultLoader(); +``` + +Use fetch from a DOM window: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); + +var response = await document.DefaultView.Fetch("/api/data", new FetchOptions +{ + Method = "POST", + Headers = new Dictionary + { + ["X-Requested-With"] = "AngleSharp.Io" + }, + Body = "payload" +}); +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. diff --git a/docs/general/02-Dom-Objects-and-Helpers.md b/docs/general/02-Dom-Objects-and-Helpers.md new file mode 100644 index 0000000..37333e8 --- /dev/null +++ b/docs/general/02-Dom-Objects-and-Helpers.md @@ -0,0 +1,124 @@ +--- +title: "DOM Objects and Helpers" +section: "AngleSharp.Io" +--- +# DOM Objects and Helpers + +AngleSharp.Io extends AngleSharp with additional DOM-related building blocks and convenience methods. This page summarizes what they are and when to use them. + +## Overview + +The package adds the following DOM-oriented types: + +- `InputFile` in `AngleSharp.Io.Dom` +- `WebSocket` in `AngleSharp.Io.Dom` +- `IStorage` interface in `AngleSharp.Io.Dom` +- `ElementExtensions` helper methods in `AngleSharp.Io.Dom` + +## InputFile + +`InputFile` implements `IFile` and is meant for programmatic file input scenarios, e.g., test automation for ``. + +### Constructors + +- `InputFile(String fileName, String type, Stream content, DateTime modified)` +- `InputFile(String fileName, String type, Stream content)` +- `InputFile(String fileName, String type, Byte[] content)` + +### Key members + +- `Name`: the file name as exposed to DOM consumers +- `Type`: MIME type +- `Length`: stream length +- `LastModified`: last modification timestamp +- `Body`: underlying content stream +- `Slice(...)`: returns an `IBlob` over a selected byte range + +### Notes + +- `InputFile` uses the provided stream directly. Ownership and disposal rules should be considered in tests and long-running automation. +- The `Slice` method returns a new `InputFile`-backed blob over copied bytes. + +## ElementExtensions + +`ElementExtensions` provides utility methods for `IHtmlInputElement` and URL-capable DOM elements. + +### File attachment helpers + +You can attach files to a file input in multiple ways: + +- `AppendFile(input, InputFile file)` +- `AppendFile(input, String filePath)` +- `AppendFile(input, String fileName, Stream content, String mimeType = null)` + +Example: + +```cs +var input = document.QuerySelector("input[type=file]"); + +input + .AppendFile("avatar.png", new MemoryStream(imageBytes), "image/png") + .AppendFile("contract.pdf"); +``` + +If the input element is not of type `file`, no file is appended. + +### Download helper + +`DownloadAsync` allows direct download from a link-like element (`IUrlUtilities` + `IElement`) via the configured loader. + +```cs +var link = document.QuerySelector("a.download"); +var response = await link.DownloadAsync(); +``` + +Requirements: + +- The element must belong to a browsing context. +- A document loader must be registered (usually via `WithDefaultLoader`). + +## WebSocket + +`WebSocket` is a DOM-facing wrapper around `ClientWebSocket` with familiar browser-style events. + +### Events + +- `Opened` (`open`) +- `Message` (`message`) +- `Error` (`error`) +- `Closed` (`close`) + +### Methods + +- `Send(String data)` +- `Close()` + +### Properties + +- `Url` +- `ReadyState` +- `Protocol` +- `Buffered` + +Example: + +```cs +var ws = new WebSocket(document.DefaultView, "wss://echo.websocket.events/"); + +ws.Opened += (s, e) => ws.Send("hello"); +ws.Message += (s, e) => Console.WriteLine(e.Type); +ws.Error += (s, e) => Console.WriteLine("socket error"); +ws.Closed += (s, e) => Console.WriteLine("socket closed"); +``` + +## IStorage + +`IStorage` models the Web Storage API shape (`Storage`) and exposes: + +- `Length` +- `Key(Int32 index)` +- indexer `this[String key]` +- `Remove(String key)` +- `Clear()` + +It exists as a DOM contract abstraction, suitable for integration and host-specific implementations. diff --git a/docs/tutorials/03-Common-Workflows.md b/docs/tutorials/03-Common-Workflows.md new file mode 100644 index 0000000..a54b0d6 --- /dev/null +++ b/docs/tutorials/03-Common-Workflows.md @@ -0,0 +1,137 @@ +--- +title: "Common Workflows" +section: "AngleSharp.Io" +--- +# Common Workflows + +This tutorial collects practical, copy-paste-friendly workflows for common AngleSharp.Io scenarios. + +## 1. Register all core Io services in one call + +Use this when you want a compact setup with sensible defaults and optional platform integrations. + +```cs +var options = new IoOptions +{ + ClipboardPlatform = myClipboardPlatform, // optional + GeolocationPlatform = myGeolocationPlatform, // optional +}; + +var config = Configuration.Default + .WithIo(options) + .WithDefaultLoader(); +``` + +Why this helps: + +- You configure navigator, cookies, requesters, storage, IndexedDB, and cache together. +- You can still override any dependency through `IoOptions`. + +## 2. Use custom HttpClient behavior with requesters + +Use this when you need proxying, custom decompression, headers, or controlled redirect behavior. + +```cs +var handler = new HttpClientHandler +{ + Proxy = new WebProxy("http://my-proxy.local:8080", false), + PreAuthenticate = true, + UseDefaultCredentials = false, + AutomaticDecompression = DecompressionMethods.Brotli | + DecompressionMethods.GZip | + DecompressionMethods.Deflate, +}; + +var config = Configuration.Default + .WithRequesters(handler) + .WithDefaultLoader(); + +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://httpbingo.org/html"); +``` + +Why this helps: + +- You keep a single place for transport behavior. +- You still use AngleSharp's standard loading pipeline. + +## 3. Persist cookies across runs + +Use this for crawl sessions or login flows that should survive process restarts. + +```cs +var cookiePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "anglesharp.cookies"); + +var config = Configuration.Default + .WithPersistentCookies(cookiePath) + .WithRequesters() + .WithDefaultLoader(); + +var context = BrowsingContext.New(config); +await context.OpenAsync("https://httpbingo.org/cookies/set?k1=v1"); +``` + +If you only need in-memory cookies for a single run, use `WithTemporaryCookies()`. + +## 4. Simulate file upload input in tests + +Use this when you need to test form/file handling without manual browser interaction. + +```cs +var config = Configuration.Default; +var context = BrowsingContext.New(config); +var document = await context.OpenAsync(req => req.Content("")); + +var input = document.QuerySelector("#f"); +input.AppendFile("avatar.png", new MemoryStream(imageBytes), "image/png"); + +var selected = input.Files[0]; +Console.WriteLine($"{selected.Name} ({selected.Type})"); +``` + +Alternative: + +- `AppendFile("/absolute/path/to/avatar.png")` to attach directly from disk. + +## 5. Download linked resources directly + +Use this if you want resource stream access from an anchor/link-like element. + +```cs +var config = Configuration.Default + .WithRequesters() + .WithDefaultLoader(); + +var context = BrowsingContext.New(config); +var document = await context.OpenAsync(req => req.Content("Download")); +var link = document.QuerySelector("a"); + +var response = await link.DownloadAsync(); +using var target = File.Create("file.bin"); +await response.Content.CopyToAsync(target); +``` + +You can also combine this with `WithStandardDownload(...)` if you want callback-based download routing. + +## 6. Open a WebSocket from a DOM context + +Use this for integration tests or headless workflows that need live message channels. + +```cs +var context = BrowsingContext.New(); +var document = await context.OpenNewAsync("https://example.org"); + +var closed = new TaskCompletionSource(); +var ws = new WebSocket(document.DefaultView, "wss://echo.websocket.events/"); + +ws.Opened += (s, e) => ws.Send("hello"); +ws.Message += (s, e) => ws.Close(); +ws.Closed += (s, e) => closed.TrySetResult(true); +ws.Error += (s, e) => closed.TrySetResult(false); + +await closed.Task; +``` + +Tip: for tests that depend on external endpoints, use explicit timeouts to avoid indefinite waits. diff --git a/docs/tutorials/03-Questions.md b/docs/tutorials/04-Questions.md similarity index 100% rename from docs/tutorials/03-Questions.md rename to docs/tutorials/04-Questions.md diff --git a/nuke/_build.csproj b/nuke/_build.csproj deleted file mode 100644 index 7521e1b..0000000 --- a/nuke/_build.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - Exe - net6.0 - - CS0649;CS0169 - .. - .. - 1 - - - - - - - - - - - diff --git a/src/AngleSharp.Io.Docs/package.json b/src/AngleSharp.Io.Docs/package.json index 7f4f014..e0eed31 100644 --- a/src/AngleSharp.Io.Docs/package.json +++ b/src/AngleSharp.Io.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/io", - "version": "0.17.0", + "version": "1.1.0", "preview": true, "description": "The doclet for the AngleSharp.Io documentation.", "keywords": [ diff --git a/src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj b/src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj index eb013d1..c0b9e20 100644 --- a/src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj +++ b/src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj @@ -1,6 +1,6 @@  - net6.0 + net8.0 true Key.snk false @@ -17,7 +17,6 @@ - diff --git a/src/AngleSharp.Io.Tests/BroadcastChannel/BroadcastChannelTests.cs b/src/AngleSharp.Io.Tests/BroadcastChannel/BroadcastChannelTests.cs new file mode 100644 index 0000000..d5d7a0a --- /dev/null +++ b/src/AngleSharp.Io.Tests/BroadcastChannel/BroadcastChannelTests.cs @@ -0,0 +1,76 @@ +namespace AngleSharp.Io.Tests.BroadcastChannel +{ + using AngleSharp; + using AngleSharp.Dom; + using AngleSharp.Dom.Events; + using AngleSharp.Io.Dom; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + [TestFixture] + public class BroadcastChannelTests + { + [Test] + public async Task BroadcastChannelCanBeCreatedFromADocumentWindow() + { + var context = BrowsingContext.New(); + var window = await OpenWindowAsync(context, "https://broadcast.example/"); + + Assert.IsNotNull(new BroadcastChannel(window, "app")); + } + + [Test] + public async Task BroadcastChannelDeliversMessagesToOtherSameOriginChannels() + { + var contextA = BrowsingContext.New(); + var contextB = BrowsingContext.New(); + + var windowA = await OpenWindowAsync(contextA, "https://broadcast.example/"); + var windowB = await OpenWindowAsync(contextB, "https://broadcast.example/other"); + + using var channelA = new BroadcastChannel(windowA, "app"); + using var channelB = new BroadcastChannel(windowB, "app"); + + var received = new TaskCompletionSource(); + var sourceEvents = 0; + + channelA.Message += (_, __) => sourceEvents++; + channelB.Message += (_, ev) => received.TrySetResult(((MessageEvent)ev).Data?.ToString()); + + channelA.PostMessage("hello"); + + Assert.AreEqual("hello", await received.Task); + Assert.AreEqual(0, sourceEvents); + } + + [Test] + public async Task BroadcastChannelDoesNotCrossOrigins() + { + var contextA = BrowsingContext.New(); + var contextB = BrowsingContext.New(); + + var windowA = await OpenWindowAsync(contextA, "https://broadcast.example/"); + var windowB = await OpenWindowAsync(contextB, "https://other.example/"); + + using var channelA = new BroadcastChannel(windowA, "app"); + using var channelB = new BroadcastChannel(windowB, "app"); + + var received = new TaskCompletionSource(); + + channelB.Message += (_, __) => received.TrySetResult(true); + + channelA.PostMessage("hello"); + + var completed = await Task.WhenAny(received.Task, Task.Delay(200)); + Assert.AreNotEqual(received.Task, completed); + } + + private static async Task OpenWindowAsync(IBrowsingContext context, String address) + { + var document = await context.OpenAsync(res => + res.Address(address).Content("broadcast")); + return document.DefaultView; + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io.Tests/Cache/CacheTests.cs b/src/AngleSharp.Io.Tests/Cache/CacheTests.cs new file mode 100644 index 0000000..22ad34b --- /dev/null +++ b/src/AngleSharp.Io.Tests/Cache/CacheTests.cs @@ -0,0 +1,102 @@ +namespace AngleSharp.Io.Tests.Cache +{ + using AngleSharp; + using AngleSharp.Dom; + using AngleSharp.Io.Cache; + using AngleSharp.Io.Dom; + using NUnit.Framework; + using System; + using System.IO; + using System.Net; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class CacheTests + { + [Test] + public async Task ConfigurationCanRegisterCacheStorage() + { + var config = CreateConfigWithCache(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://cache.example/"); + + Assert.IsNotNull(window.Caches()); + } + + [Test] + public async Task CacheSharesStateAcrossSameOriginWindows() + { + var config = CreateConfigWithCache(); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + + var windowA = await OpenWindowAsync(contextA, "https://cache.example/"); + var windowB = await OpenWindowAsync(contextB, "https://cache.example/other"); + + var cacheA = windowA.Caches().Open("app"); + var cacheB = windowB.Caches().Open("app"); + + cacheA.Put("https://cache.example/data", CreateResponse("https://cache.example/data", "hello")); + + Assert.AreEqual("hello", ReadContent(cacheB.Match("https://cache.example/data"))); + } + + [Test] + public async Task CacheIsOriginBound() + { + var config = CreateConfigWithCache(); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + + var windowA = await OpenWindowAsync(contextA, "https://cache.example/"); + var windowB = await OpenWindowAsync(contextB, "https://other.example/"); + + var cacheA = windowA.Caches().Open("app"); + var cacheB = windowB.Caches().Open("app"); + + cacheA.Put("https://cache.example/data", CreateResponse("https://cache.example/data", "hello")); + + Assert.AreEqual("hello", ReadContent(cacheA.Match("https://cache.example/data"))); + Assert.AreEqual(null, cacheB.Match("https://cache.example/data")); + } + + private static IConfiguration CreateConfigWithCache() + { + var factory = new CacheProviderFactory(); + return Configuration.Default.WithCacheProviderFactory(factory); + } + + private static IResponse CreateResponse(String address, String content) + { + return new AngleSharp.Io.DefaultResponse + { + Address = new Url(address), + StatusCode = HttpStatusCode.OK, + Headers = new System.Collections.Generic.Dictionary(), + Content = new MemoryStream(Encoding.UTF8.GetBytes(content ?? String.Empty)), + }; + } + + private static String ReadContent(IResponse response) + { + if (response?.Content == null) + { + return null; + } + + using (response) + using (var reader = new StreamReader(response.Content, Encoding.UTF8, true, 1024, true)) + { + return reader.ReadToEnd(); + } + } + + private static async Task OpenWindowAsync(IBrowsingContext context, String address) + { + var document = await context.OpenAsync(res => + res.Address(address).Content("cache")); + return document.DefaultView; + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io.Tests/Clipboard/ClipboardTests.cs b/src/AngleSharp.Io.Tests/Clipboard/ClipboardTests.cs new file mode 100644 index 0000000..df9fd4e --- /dev/null +++ b/src/AngleSharp.Io.Tests/Clipboard/ClipboardTests.cs @@ -0,0 +1,86 @@ +namespace AngleSharp.Io.Tests.Clipboard +{ + using AngleSharp; + using AngleSharp.Browser.Dom; + using AngleSharp.Io.Dom; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + [TestFixture] + public class ClipboardTests + { + [Test] + public async Task ClipboardCanBeResolvedFromNavigatorWhenRegistered() + { + var platform = new FakeClipboardPlatform(); + var context = await OpenContextAsync(Configuration.Default.WithNavigator().WithClipboard(platform), "https://clipboard.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); + + Assert.IsNotNull(navigator.Clipboard()); + } + + [Test] + public async Task ClipboardWriteTextUsesPlatformImplementation() + { + var platform = new FakeClipboardPlatform(); + var context = await OpenContextAsync(Configuration.Default.WithNavigator().WithClipboard(platform), "https://clipboard.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); + var clipboard = navigator.Clipboard(); + + await clipboard.WriteText("copied").ConfigureAwait(false); + + Assert.AreEqual("copied", platform.LastWrittenText); + } + + [Test] + public async Task ClipboardReadTextUsesPlatformImplementation() + { + var platform = new FakeClipboardPlatform + { + CurrentText = "from-platform" + }; + + var context = await OpenContextAsync(Configuration.Default.WithNavigator().WithClipboard(platform), "https://clipboard.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); + var clipboard = navigator.Clipboard(); + + var value = await clipboard.ReadText().ConfigureAwait(false); + + Assert.AreEqual("from-platform", value); + } + + private static async Task OpenContextAsync(IConfiguration configuration, String address) + { + var context = BrowsingContext.New(configuration); + await context.OpenAsync(res => res.Address(address).Content("clipboard")).ConfigureAwait(false); + return context; + } + + private static INavigator GetNavigator(IBrowsingContext context) + { + var navigator = context?.Active?.DefaultView?.Navigator; + Assert.IsNotNull(navigator); + return navigator; + } + + private sealed class FakeClipboardPlatform : IClipboardPlatform + { + public String CurrentText { get; set; } = String.Empty; + + public String LastWrittenText { get; private set; } = String.Empty; + + public Task ReadTextAsync() + { + return Task.FromResult(CurrentText); + } + + public Task WriteTextAsync(String text) + { + LastWrittenText = text; + CurrentText = text; + return Task.CompletedTask; + } + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io.Tests/Cookie/ClassicTests.cs b/src/AngleSharp.Io.Tests/Cookie/ClassicTests.cs index 93e9155..c52a7db 100644 --- a/src/AngleSharp.Io.Tests/Cookie/ClassicTests.cs +++ b/src/AngleSharp.Io.Tests/Cookie/ClassicTests.cs @@ -4,8 +4,10 @@ namespace AngleSharp.Io.Tests.Cookie using AngleSharp.Dom; using AngleSharp.Html.Dom; using AngleSharp.Io.Tests.Mocks; + using Newtonsoft.Json.Linq; using NUnit.Framework; using System; + using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; @@ -136,8 +138,10 @@ public async Task SettingMultipleExpiredCookieInRequestDoNotAppearInDocument() [Test] public async Task SettingOneExpiredCookieAndAFutureCookieInRequestDoAppearInDocument() { + var year = DateTime.Today.Year + 1; + var dayOfWeek = new DateTime(year, 1, 28).ToString("ddd", CultureInfo.InvariantCulture); var cookie = await LoadDocumentWithCookie( - "cookie=expiring; Expires=Tue, 10 Nov 2009 23:00:00 GMT, foo=bar; Expires=Tue, 28 Jan 2025 13:37:00 GMT"); + $"cookie=expiring; Expires=Tue, 10 Nov 2009 23:00:00 GMT, foo=bar; Expires={dayOfWeek}, 28 Jan {year} 13:37:00 GMT"); Assert.AreEqual("foo=bar", cookie); } @@ -243,15 +247,11 @@ public async Task SettingThreeCookiesInOneRequestAreTransportedToNextRequest() await context.OpenAsync(url); var document = await context.OpenAsync(baseUrl); - var expected = @"{ - ""foo"": ""bar"", - ""k1"": ""v1"", - ""k2"": ""v2"", - ""test"": ""baz"" -} -".Replace("\r\n", "\n"); - - Assert.AreEqual(expected, document.Body.TextContent); + AssertCookies(document.Body.TextContent, + new KeyValuePair("foo", "bar"), + new KeyValuePair("k1", "v1"), + new KeyValuePair("k2", "v2"), + new KeyValuePair("test", "baz")); } } @@ -267,10 +267,8 @@ public async Task SettingCookieIsPreservedViaRedirect() await context.OpenAsync(cookieUrl); var document = await context.OpenAsync(redirectUrl); - Assert.AreEqual(@"{ - ""test"": ""baz"" -} -".Replace("\r\n", "\n"), document.Body.TextContent); + AssertCookies(document.Body.TextContent, + new KeyValuePair("test", "baz")); } } @@ -476,5 +474,19 @@ private static async Task LoadDocumentWithCookie(String cookieValue) var document = await LoadDocumentAloneWithCookie(cookieValue); return document.Cookie; } + + private static void AssertCookies(String body, params KeyValuePair[] expected) + { + var parsed = JObject.Parse(body); + var source = parsed["cookies"] as JObject ?? parsed; + var actual = source.Properties().ToDictionary(m => m.Name, m => m.Value.ToString()); + + CollectionAssert.AreEquivalent(expected.Select(m => m.Key), actual.Keys); + + foreach (var pair in expected) + { + Assert.AreEqual(pair.Value, actual[pair.Key]); + } + } } } diff --git a/src/AngleSharp.Io.Tests/Cookie/ParsingTests.cs b/src/AngleSharp.Io.Tests/Cookie/ParsingTests.cs index 38c7acc..bd50712 100644 --- a/src/AngleSharp.Io.Tests/Cookie/ParsingTests.cs +++ b/src/AngleSharp.Io.Tests/Cookie/ParsingTests.cs @@ -96,6 +96,32 @@ public void SettingSubPathCookieOnSuperDomain() Assert.AreEqual("/subpath", cookie.Path); } + [Test] + public void GettingDottedDomainCookieForSubDomain() + { + var container = CreateContainerWithSetup(c => + { + var url = new Url("http://example.com/index.html"); + c.SetCookie(url, "a=b; Domain=.example.com; Path=/"); + }); + var cookieHeader = ((ICookieProvider)container).GetCookie(new Url("http://www.example.com/home")); + + Assert.AreEqual("a=b", cookieHeader); + } + + [Test] + public void DomainCookieDoesNotMatchSuffixLookalike() + { + var container = CreateContainerWithSetup(c => + { + var url = new Url("http://example.com/index.html"); + c.SetCookie(url, "a=b; Domain=.example.com; Path=/"); + }); + var cookieHeader = ((ICookieProvider)container).GetCookie(new Url("http://notexample.com/home")); + + Assert.AreEqual(String.Empty, cookieHeader); + } + [Test] public void SimpleCookie() { diff --git a/src/AngleSharp.Io.Tests/Geolocation/GeolocationTests.cs b/src/AngleSharp.Io.Tests/Geolocation/GeolocationTests.cs new file mode 100644 index 0000000..2a01a1d --- /dev/null +++ b/src/AngleSharp.Io.Tests/Geolocation/GeolocationTests.cs @@ -0,0 +1,116 @@ +namespace AngleSharp.Io.Tests.Geolocation +{ + using AngleSharp; + using AngleSharp.Browser.Dom; + using AngleSharp.Io.Dom; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + [TestFixture] + public class GeolocationTests + { + [Test] + public async Task GeolocationCanBeResolvedFromNavigatorWhenRegistered() + { + var platform = new FakeGeolocationPlatform(); + var context = await OpenContextAsync(Configuration.Default.WithNavigator().WithGeolocation(platform), "https://geo.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); + + Assert.IsNotNull(navigator.Geolocation()); + } + + [Test] + public async Task GeolocationMapsPlatformReadingToDomTypes() + { + var platform = new FakeGeolocationPlatform + { + NextReading = new GeolocationReading + { + Latitude = 52.52, + Longitude = 13.405, + Accuracy = 8.0, + Altitude = 34.5, + AltitudeAccuracy = 1.2, + Heading = 90.0, + Speed = 2.5, + Timestamp = 1234567890, + } + }; + + var context = await OpenContextAsync(Configuration.Default.WithNavigator().WithGeolocation(platform), "https://geo.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); + var geolocation = navigator.Geolocation(); + + var position = await geolocation.GetCurrentPosition().ConfigureAwait(false); + + Assert.AreEqual(52.52, position.Coordinates.Latitude); + Assert.AreEqual(13.405, position.Coordinates.Longitude); + Assert.AreEqual(8.0, position.Coordinates.Accuracy); + Assert.AreEqual(34.5, position.Coordinates.Altitude); + Assert.AreEqual(1.2, position.Coordinates.AltitudeAccuracy); + Assert.AreEqual(90.0, position.Coordinates.Heading); + Assert.AreEqual(2.5, position.Coordinates.Speed); + Assert.AreEqual(1234567890, position.Timestamp); + } + + [Test] + public async Task GeolocationForwardsRequestOptionsToPlatform() + { + var platform = new FakeGeolocationPlatform + { + NextReading = new GeolocationReading + { + Latitude = 1.0, + Longitude = 2.0, + Accuracy = 3.0, + } + }; + + var options = new GeolocationOptions + { + EnableHighAccuracy = true, + Timeout = 1500, + MaximumAge = 200, + }; + + var context = await OpenContextAsync(Configuration.Default.WithNavigator().WithGeolocation(platform), "https://geo.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); + var geolocation = navigator.Geolocation(); + + await geolocation.GetCurrentPosition(options).ConfigureAwait(false); + + Assert.IsNotNull(platform.LastOptions); + Assert.AreEqual(true, platform.LastOptions.EnableHighAccuracy); + Assert.AreEqual(1500, platform.LastOptions.Timeout); + Assert.AreEqual(200, platform.LastOptions.MaximumAge); + } + + private static async Task OpenContextAsync(IConfiguration configuration, String address) + { + var context = BrowsingContext.New(configuration); + await context.OpenAsync(res => res.Address(address).Content("geolocation")).ConfigureAwait(false); + return context; + } + + private static INavigator GetNavigator(IBrowsingContext context) + { + var navigator = context?.Active?.DefaultView?.Navigator; + Assert.IsNotNull(navigator); + return navigator; + } + + private sealed class FakeGeolocationPlatform : IGeolocationPlatform + { + public GeolocationReading NextReading { get; set; } = GeolocationReading.Empty; + + public GeolocationOptions LastOptions { get; private set; } + + public Task GetCurrentPositionAsync(GeolocationOptions options) + { + LastOptions = options; + return Task.FromResult(NextReading); + } + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io.Tests/IndexedDb/IndexedDbTests.cs b/src/AngleSharp.Io.Tests/IndexedDb/IndexedDbTests.cs new file mode 100644 index 0000000..79ac8d9 --- /dev/null +++ b/src/AngleSharp.Io.Tests/IndexedDb/IndexedDbTests.cs @@ -0,0 +1,724 @@ +namespace AngleSharp.Io.Tests.IndexedDb +{ + using AngleSharp; + using AngleSharp.Dom; + using AngleSharp.Io.Dom; + using AngleSharp.Io.IndexedDb; + using NUnit.Framework; + using System.Collections.Generic; + using System; + using System.Linq; + using System.Threading.Tasks; + + [TestFixture] + public class IndexedDbTests + { + [Test] + public async Task ConfigurationCanRegisterIndexedDb() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + + Assert.IsNotNull(window.IndexedDb()); + } + + [Test] + public async Task IndexedDbSharesStateAcrossSameOriginWindows() + { + var config = CreateConfigWithIndexedDb(); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + + var windowA = await OpenWindowAsync(contextA, "https://indexeddb.example/"); + var windowB = await OpenWindowAsync(contextB, "https://indexeddb.example/other"); + + var storeA = windowA.IndexedDb().Open("app").CreateObjectStore("records"); + var storeB = windowB.IndexedDb().Open("app").CreateObjectStore("records"); + + storeA["token"] = "abc"; + + Assert.AreEqual("abc", storeB["token"]); + } + + [Test] + public async Task IndexedDbIsOriginBound() + { + var config = CreateConfigWithIndexedDb(); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + + var windowA = await OpenWindowAsync(contextA, "https://indexeddb.example/"); + var windowB = await OpenWindowAsync(contextB, "https://other.example/"); + + var storeA = windowA.IndexedDb().Open("app").CreateObjectStore("records"); + var storeB = windowB.IndexedDb().Open("app").CreateObjectStore("records"); + + storeA["token"] = "abc"; + + Assert.AreEqual("abc", storeA["token"]); + Assert.AreEqual(null, storeB["token"]); + } + + [Test] + public async Task OpenWithVersionRunsUpgradeAndSetsVersion() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + + var db = factory.Open("app", 3, tx => + { + var store = tx.CreateObjectStore("records"); + store["hello"] = "world"; + }); + + Assert.AreEqual(3, db.Version); + Assert.AreEqual("world", db.CreateObjectStore("records")["hello"]); + } + + [Test] + public async Task OpenWithLowerVersionThrows() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + + factory.Open("app", 2, tx => tx.CreateObjectStore("records")); + + Assert.Throws(() => factory.Open("app", 1)); + } + + [Test] + public async Task ReadWriteTransactionCommitPersistsChanges() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + store["token"] = "abc"; + transaction.Commit(); + } + + Assert.AreEqual("abc", db.CreateObjectStore("records")["token"]); + } + + [Test] + public async Task ReadWriteTransactionAbortDiscardsChanges() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + store["token"] = "abc"; + transaction.Abort(); + } + + Assert.AreEqual(null, db.CreateObjectStore("records")["token"]); + } + + [Test] + public async Task ReadOnlyTransactionRejectsWrites() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => + { + var store = tx.CreateObjectStore("records"); + store["token"] = "abc"; + }); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadOnly)) + { + var store = transaction.GetObjectStore("records"); + Assert.Throws(() => store["token"] = "updated"); + transaction.Commit(); + } + + Assert.AreEqual("abc", db.CreateObjectStore("records")["token"]); + } + + [Test] + public async Task OpenRequestCompletesWithDatabase() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + + var request = window.IndexedDb().OpenRequest("app", 1, tx => tx.CreateObjectStore("records")); + var db = await request.WaitAsync(); + + Assert.IsTrue(request.IsCompleted); + Assert.AreEqual(false, request.HasError); + Assert.IsNotNull(db); + Assert.AreEqual(1, db.Version); + } + + [Test] + public async Task OpenRequestCapturesVersionError() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + + factory.Open("app", 2, tx => tx.CreateObjectStore("records")); + var request = factory.OpenRequest("app", 1); + + Assert.ThrowsAsync(Is.InstanceOf(), async () => await request.WaitAsync()); + Assert.IsTrue(request.IsCompleted); + Assert.IsTrue(request.HasError); + Assert.IsNotNull(request.Exception); + } + + [Test] + public async Task ObjectStoreAddPutDeleteCountBehavesAsExpected() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + store.Add("k1", "v1"); + store.Put("k2", "v2"); + store.Put("k2", "v2b"); + + Assert.AreEqual(2, store.Count()); + Assert.AreEqual("v1", store.Get("k1")); + Assert.AreEqual("v2b", store.Get("k2")); + Assert.AreEqual(true, store.Delete("k1")); + Assert.AreEqual(false, store.Delete("missing")); + Assert.AreEqual(1, store.Count()); + + transaction.Commit(); + } + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadOnly)) + { + var store = transaction.GetObjectStore("records"); + Assert.AreEqual(1, store.Count()); + Assert.AreEqual(null, store.Get("k1")); + Assert.AreEqual("v2b", store.Get("k2")); + transaction.Commit(); + } + } + + [Test] + public async Task ObjectStoreCanQueryWithKeyRanges() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + store.Put("a", "id=1;type=note"); + store.Put("b", "id=2;type=task"); + store.Put("c", "id=3;type=note"); + store.Put("d", "id=4;type=note"); + transaction.Commit(); + } + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadOnly)) + { + var store = transaction.GetObjectStore("records"); + var all = store.GetAll(IndexedDbKeyRange.Bound("b", "d")); + var openUpper = store.GetAll(IndexedDbKeyRange.Bound("b", "d", false, true)); + + CollectionAssert.AreEqual(new[] { "id=2;type=task", "id=3;type=note", "id=4;type=note" }, all.ToArray()); + CollectionAssert.AreEqual(new[] { "id=2;type=task", "id=3;type=note" }, openUpper.ToArray()); + Assert.AreEqual(2, store.Count(IndexedDbKeyRange.Bound("b", "d", false, true))); + transaction.Commit(); + } + } + + [Test] + public async Task ObjectStoreIndexesCanQueryAndCount() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + var index = store.CreateIndex("byType", "type"); + + store.Put("a", "id=1;type=note"); + store.Put("b", "id=2;type=task"); + store.Put("c", "id=3;type=note"); + + Assert.AreEqual("id=1;type=note", index.Get("note")); + CollectionAssert.AreEqual(new[] { "id=1;type=note", "id=3;type=note" }, index.GetAll("note").ToArray()); + Assert.AreEqual(2, index.Count("note")); + CollectionAssert.AreEquivalent(new[] { "id=1;type=note", "id=3;type=note", "id=2;type=task" }, index.GetAll(IndexedDbKeyRange.Bound("note", "task")).ToArray()); + + transaction.Commit(); + } + } + + [Test] + public async Task UniqueIndexRejectsDuplicateProjectedValues() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + store.CreateIndex("byEmail", "email", unique: true); + store.Put("a", "email=alice@example.com;type=user"); + + Assert.Throws(() => store.Put("b", "email=alice@example.com;type=user")); + + transaction.Commit(); + } + } + + [Test] + public async Task ObjectStoreCursorIteratesByKeyDirectionAndRange() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + store.Put("a", "v1"); + store.Put("b", "v2"); + store.Put("c", "v3"); + store.Put("d", "v4"); + transaction.Commit(); + } + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadOnly)) + { + var store = transaction.GetObjectStore("records"); + + var next = ReadCursorValues(store.OpenCursor(IndexedDbKeyRange.Bound("b", "d"))); + CollectionAssert.AreEqual(new[] { "v2", "v3", "v4" }, next); + + var prev = ReadCursorValues(store.OpenCursor(IndexedDbKeyRange.Bound("b", "d"), IndexedDbCursorDirection.Prev)); + CollectionAssert.AreEqual(new[] { "v4", "v3", "v2" }, prev); + + transaction.Commit(); + } + } + + [Test] + public async Task IndexCursorIteratesByIndexKeyDirectionAndRange() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + var index = store.CreateIndex("byType", "type"); + + store.Put("a", "id=1;type=note"); + store.Put("b", "id=2;type=task"); + store.Put("c", "id=3;type=memo"); + store.Put("d", "id=4;type=task"); + + var next = ReadCursorValues(index.OpenCursor(IndexedDbKeyRange.Bound("memo", "task"))); + CollectionAssert.AreEqual(new[] { "id=3;type=memo", "id=1;type=note", "id=2;type=task", "id=4;type=task" }, next); + + var prev = ReadCursorValues(index.OpenCursor(IndexedDbKeyRange.Bound("memo", "task"), IndexedDbCursorDirection.Prev)); + CollectionAssert.AreEqual(new[] { "id=4;type=task", "id=2;type=task", "id=1;type=note", "id=3;type=memo" }, prev); + + transaction.Commit(); + } + } + + [Test] + public async Task OpenRequestReportsBlockedWhenUpgradeHasOpenConnections() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + + var db = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + var request = factory.OpenRequest("app", 2, tx => tx.CreateObjectStore("events")); + + Assert.AreEqual(IndexedDbRequestState.Blocked, request.State); + Assert.AreEqual(true, request.HasError); + Assert.IsNotNull(request.Exception); + Assert.ThrowsAsync(Is.InstanceOf(), async () => await request.WaitAsync()); + + db.Close(); + + var retry = factory.OpenRequest("app", 2, tx => tx.CreateObjectStore("events")); + var upgraded = await retry.WaitAsync(); + + Assert.AreEqual(IndexedDbRequestState.Success, retry.State); + Assert.AreEqual(2, upgraded.Version); + } + + [Test] + public async Task OpenRequestCanWaitForUnblockAndResolveAutomatically() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + + var db = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + var request = factory.OpenRequest("app", 2, tx => tx.CreateObjectStore("events"), waitForUnblock: true); + + Assert.AreEqual(IndexedDbRequestState.Blocked, request.State); + Assert.AreEqual(false, request.IsCompleted); + + db.Close(); + + var upgraded = await request.WaitAsync(); + + Assert.AreEqual(IndexedDbRequestState.Success, request.State); + Assert.AreEqual(2, upgraded.Version); + } + + [Test] + public async Task OpenConnectionReceivesVersionChangeNotificationWhenUpgradeIsBlocked() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + var db = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + + var notifications = 0; + var oldVersion = 0L; + var newVersion = 0L; + + db.VersionChangeRequested += (oldV, newV) => + { + notifications++; + oldVersion = oldV; + newVersion = newV; + }; + + var blocked = factory.OpenRequest("app", 2, tx => tx.CreateObjectStore("events")); + + Assert.AreEqual(IndexedDbRequestState.Blocked, blocked.State); + Assert.AreEqual(1, notifications); + Assert.AreEqual(1, oldVersion); + Assert.AreEqual(2, newVersion); + + db.Close(); + } + + [Test] + public async Task ClosingDatabasePreventsFurtherUsage() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + db.Close(); + + Assert.AreEqual(true, db.IsClosed); + Assert.Throws(() => db.CreateObjectStore("other")); + Assert.Throws(() => db.BeginTransaction("records", IndexedDbTransactionMode.ReadOnly)); + } + + [Test] + public async Task DeleteDatabaseRequestReportsBlockedWhenConnectionsAreOpen() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + var db = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + + var blocked = factory.DeleteDatabaseRequest("app"); + + Assert.AreEqual(IndexedDbRequestState.Blocked, blocked.State); + Assert.AreEqual(true, blocked.HasError); + Assert.ThrowsAsync(Is.InstanceOf(), async () => await blocked.WaitAsync()); + + db.Close(); + + var deleted = await factory.DeleteDatabaseRequest("app").WaitAsync(); + Assert.AreEqual(true, deleted); + } + + [Test] + public async Task DeleteDatabaseRequestCanWaitForUnblockAndResolveAutomatically() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + var db = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + + var request = factory.DeleteDatabaseRequest("app", waitForUnblock: true); + + Assert.AreEqual(IndexedDbRequestState.Blocked, request.State); + Assert.AreEqual(false, request.IsCompleted); + + db.Close(); + + var deleted = await request.WaitAsync(); + Assert.AreEqual(true, deleted); + Assert.AreEqual(IndexedDbRequestState.Success, request.State); + } + + [Test] + public async Task TransactionLifecycleFlagsAndEventsAreReported() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + var completedRaised = false; + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + transaction.Completed += () => completedRaised = true; + var store = transaction.GetObjectStore("records"); + store.Put("k1", "v1"); + transaction.Commit(); + + Assert.AreEqual(true, transaction.IsCompleted); + Assert.AreEqual(false, transaction.IsAborted); + Assert.AreEqual(true, completedRaised); + } + + var abortedRaised = false; + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + transaction.Aborted += () => abortedRaised = true; + transaction.Abort(); + + Assert.AreEqual(true, transaction.IsCompleted); + Assert.AreEqual(true, transaction.IsAborted); + Assert.AreEqual(true, abortedRaised); + } + } + + [Test] + public async Task OnlyOneReadWriteTransactionCanBeActiveAtATime() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + var first = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite); + + Assert.Throws(() => db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)); + + first.Abort(); + + using (var second = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = second.GetObjectStore("records"); + store.Put("k2", "v2"); + second.Commit(); + } + + Assert.AreEqual("v2", db.CreateObjectStore("records")["k2"]); + } + + [Test] + public async Task OpenRequestSuccessCallbackIsInvoked() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + + var callbackCount = 0; + IIndexedDbDatabase callbackDb = null; + + var request = window.IndexedDb().OpenRequest("app", 1, tx => tx.CreateObjectStore("records")); + request.Succeeded += db => + { + callbackCount++; + callbackDb = db; + }; + + var opened = await request.WaitAsync(); + + Assert.AreEqual(IndexedDbRequestState.Success, request.State); + Assert.AreEqual(1, callbackCount); + Assert.AreSame(opened, callbackDb); + } + + [Test] + public async Task OpenRequestWaitForUnblockRaisesBlockedThenSucceeded() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + var hold = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + + var blockedCount = 0; + var failedCount = 0; + var successCount = 0; + + var request = factory.OpenRequest("app", 2, tx => tx.CreateObjectStore("events"), waitForUnblock: true); + request.Blocked += () => blockedCount++; + request.Failed += _ => failedCount++; + request.Succeeded += _ => successCount++; + + Assert.AreEqual(IndexedDbRequestState.Blocked, request.State); + Assert.AreEqual(1, blockedCount); + Assert.AreEqual(1, failedCount); + Assert.AreEqual(0, successCount); + + hold.Close(); + + var opened = await request.WaitAsync(); + + Assert.IsNotNull(opened); + Assert.AreEqual(IndexedDbRequestState.Success, request.State); + Assert.AreEqual(1, blockedCount); + Assert.AreEqual(1, failedCount); + Assert.AreEqual(1, successCount); + } + + [Test] + public async Task DeleteRequestWaitForUnblockRaisesBlockedThenSucceeded() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + var hold = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + + var blockedCount = 0; + var failedCount = 0; + var successCount = 0; + + var request = factory.DeleteDatabaseRequest("app", waitForUnblock: true); + request.Blocked += () => blockedCount++; + request.Failed += _ => failedCount++; + request.Succeeded += _ => successCount++; + + Assert.AreEqual(IndexedDbRequestState.Blocked, request.State); + Assert.AreEqual(1, blockedCount); + Assert.AreEqual(1, failedCount); + Assert.AreEqual(0, successCount); + + hold.Close(); + + var deleted = await request.WaitAsync(); + + Assert.AreEqual(true, deleted); + Assert.AreEqual(IndexedDbRequestState.Success, request.State); + Assert.AreEqual(1, blockedCount); + Assert.AreEqual(1, failedCount); + Assert.AreEqual(1, successCount); + } + + [Test] + public async Task DisposingUncommittedReadWriteTransactionReleasesWriteLock() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + IIndexedDbTransaction abandoned; + + using (abandoned = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + abandoned.GetObjectStore("records").Put("k1", "v1"); + } + + Assert.AreEqual(true, abandoned.IsCompleted); + Assert.AreEqual(true, abandoned.IsAborted); + + using (var next = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + next.GetObjectStore("records").Put("k2", "v2"); + next.Commit(); + } + + Assert.AreEqual(null, db.CreateObjectStore("records")["k1"]); + Assert.AreEqual("v2", db.CreateObjectStore("records")["k2"]); + } + + [Test] + public async Task ClosedConnectionDoesNotReceiveVersionChangeNotification() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + + var closedConnection = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + var activeConnection = factory.Open("app", 1); + + var closedNotifications = 0; + var activeNotifications = 0; + + closedConnection.VersionChangeRequested += (_, _) => closedNotifications++; + activeConnection.VersionChangeRequested += (_, _) => activeNotifications++; + + closedConnection.Close(); + + var blocked = factory.OpenRequest("app", 2, tx => tx.CreateObjectStore("events")); + + Assert.AreEqual(IndexedDbRequestState.Blocked, blocked.State); + Assert.AreEqual(0, closedNotifications); + Assert.AreEqual(1, activeNotifications); + + activeConnection.Close(); + } + + private static List ReadCursorValues(IIndexedDbCursor cursor) + { + var values = new List(); + + while (cursor != null) + { + values.Add(cursor.Value); + cursor = cursor.Continue(); + } + + return values; + } + + private static IConfiguration CreateConfigWithIndexedDb() + { + var factory = new IndexedDbProviderFactory(); + return Configuration.Default.WithIndexedDbProviderFactory(factory); + } + + private static async Task OpenWindowAsync(IBrowsingContext context, String address) + { + var document = await context.OpenAsync(res => + res.Address(address).Content("indexeddb")); + return document.DefaultView; + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io.Tests/IoConfigurationExtensionsTests.cs b/src/AngleSharp.Io.Tests/IoConfigurationExtensionsTests.cs new file mode 100644 index 0000000..71b4a32 --- /dev/null +++ b/src/AngleSharp.Io.Tests/IoConfigurationExtensionsTests.cs @@ -0,0 +1,118 @@ +namespace AngleSharp.Io.Tests +{ + using AngleSharp.Browser.Dom; + using AngleSharp.Io.Cache; + using AngleSharp.Io.Cookie; + using AngleSharp.Io.Dom; + using AngleSharp.Io.IndexedDb; + using AngleSharp.Io.Storage; + using NUnit.Framework; + using System; + using System.Linq; + using System.Net.Http; + using System.Threading.Tasks; + + [TestFixture] + public sealed class IoConfigurationExtensionsTests + { + [Test] + public void WithIoRegistersCoreServices() + { + var options = new IoOptions + { + CookieHandler = new MemoryFileHandler(), + HttpHandler = new HttpClientHandler(), + StorageFactory = StorageProviderFactory.CreateTemporary(), + IndexedDbFactory = IndexedDbProviderFactory.CreateTemporary(), + CacheFactory = CacheProviderFactory.CreateTemporary(), + }; + + var configuration = Configuration.Default.WithIo(options); + var requesters = configuration.Services.OfType().ToArray(); + + Assert.IsNotNull(configuration.Services.OfType().FirstOrDefault()); + Assert.AreSame(options.StorageFactory, configuration.Services.OfType().FirstOrDefault()); + Assert.AreSame(options.IndexedDbFactory, configuration.Services.OfType().FirstOrDefault()); + Assert.AreSame(options.CacheFactory, configuration.Services.OfType().FirstOrDefault()); + Assert.IsTrue(requesters.Any(m => m.SupportsProtocol("http"))); + Assert.IsTrue(requesters.Any(m => m.SupportsProtocol("data"))); + Assert.IsTrue(requesters.Any(m => m.SupportsProtocol("ftp"))); + Assert.IsTrue(requesters.Any(m => m.SupportsProtocol("file"))); + Assert.IsTrue(requesters.Any(m => m.SupportsProtocol("about"))); + Assert.AreEqual(false, options.HttpHandler.UseCookies); + Assert.AreEqual(false, options.HttpHandler.AllowAutoRedirect); + } + + [Test] + public void WithIoAcceptsNullOptions() + { + var configuration = Configuration.Default.WithIo(null); + + Assert.IsNotNull(configuration.Services.OfType().FirstOrDefault()); + Assert.IsNotNull(configuration.Services.OfType().FirstOrDefault()); + Assert.IsNotNull(configuration.Services.OfType().FirstOrDefault()); + Assert.IsNotNull(configuration.Services.OfType().FirstOrDefault()); + } + + [Test] + public async Task WithIoRegistersOptionalNavigatorApisWhenPlatformsAreProvided() + { + var options = new IoOptions + { + ClipboardPlatform = new FakeClipboardPlatform(), + GeolocationPlatform = new FakeGeolocationPlatform(), + }; + + var context = await OpenContextAsync(Configuration.Default.WithIo(options), "https://io.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); + + Assert.IsNotNull(navigator.Clipboard()); + Assert.IsNotNull(navigator.Geolocation()); + } + + [Test] + public async Task WithIoLeavesOptionalNavigatorApisUnavailableByDefault() + { + var context = await OpenContextAsync(Configuration.Default.WithIo(new IoOptions()), "https://io.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); + + Assert.IsNull(navigator.Clipboard()); + Assert.IsNull(navigator.Geolocation()); + } + + private static async Task OpenContextAsync(IConfiguration configuration, String address) + { + var context = BrowsingContext.New(configuration); + await context.OpenAsync(res => res.Address(address).Content("with-io")).ConfigureAwait(false); + return context; + } + + private static INavigator GetNavigator(IBrowsingContext context) + { + var navigator = context?.Active?.DefaultView?.Navigator; + Assert.IsNotNull(navigator); + return navigator; + } + + private sealed class FakeClipboardPlatform : IClipboardPlatform + { + public Task ReadTextAsync() + { + return Task.FromResult(String.Empty); + } + + public Task WriteTextAsync(String text) + { + return Task.CompletedTask; + } + } + + private sealed class FakeGeolocationPlatform : IGeolocationPlatform + { + public Task GetCurrentPositionAsync(GeolocationOptions options) + { + return Task.FromResult(GeolocationReading.Empty); + } + } + } +} diff --git a/src/AngleSharp.Io.Tests/Network/CookieHandlingTests.cs b/src/AngleSharp.Io.Tests/Network/CookieHandlingTests.cs index 2d41e18..663e4a1 100644 --- a/src/AngleSharp.Io.Tests/Network/CookieHandlingTests.cs +++ b/src/AngleSharp.Io.Tests/Network/CookieHandlingTests.cs @@ -1,8 +1,11 @@ namespace AngleSharp.Io.Tests.Network { using AngleSharp.Dom; + using Newtonsoft.Json.Linq; using NUnit.Framework; using System; + using System.Collections.Generic; + using System.Linq; using System.Threading.Tasks; [TestFixture] @@ -14,7 +17,7 @@ public async Task SettingOneCookiesInOneRequestAppearsInDocument() if (Helper.IsNetworkAvailable()) { var url = "https://httpbingo.org/cookies/set?k1=v1"; - var config = Configuration.Default.WithCookies().WithRequesters().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); var document = await context.OpenAsync(url); @@ -28,7 +31,7 @@ public async Task SettingTwoCookiesInOneRequestAppearsInDocument() if (Helper.IsNetworkAvailable()) { var url = "https://httpbingo.org/cookies/set?k2=v2&k1=v1"; - var config = Configuration.Default.WithCookies().WithRequesters().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); var document = await context.OpenAsync(url); var cookies = document.Cookie.Split(new[] { "; " }, StringSplitOptions.RemoveEmptyEntries); @@ -42,7 +45,7 @@ public async Task SettingThreeCookiesInOneRequestAppearsInDocument() if (Helper.IsNetworkAvailable()) { var url = "https://httpbingo.org/cookies/set?test=baz&k2=v2&k1=v1&foo=bar"; - var config = Configuration.Default.WithCookies().WithRequesters().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); var document = await context.OpenAsync(url); var cookies = document.Cookie.Split(new[] { "; " }, StringSplitOptions.RemoveEmptyEntries); @@ -57,18 +60,16 @@ public async Task SettingThreeCookiesInOneRequestAreTransportedToNextRequest() { var baseUrl = "https://httpbingo.org/cookies"; var url = baseUrl + "/set?test=baz&k2=v2&k1=v1&foo=bar"; - var config = Configuration.Default.WithCookies().WithRequesters().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); await context.OpenAsync(url); var document = await context.OpenAsync(baseUrl); - Assert.AreEqual(@"{ - ""foo"": ""bar"", - ""k1"": ""v1"", - ""k2"": ""v2"", - ""test"": ""baz"" -} -".Replace("\r\n", "\n"), document.Body.TextContent); + AssertCookies(document.Body.TextContent, + new KeyValuePair("foo", "bar"), + new KeyValuePair("k1", "v1"), + new KeyValuePair("k2", "v2"), + new KeyValuePair("test", "baz")); } } @@ -79,15 +80,13 @@ public async Task SettingCookieIsPreservedViaRedirect() { var cookieUrl = "https://httpbingo.org/cookies/set?test=baz"; var redirectUrl = "https://httpbingo.org/redirect-to?url=https%3A%2F%2Fhttpbingo.org%2Fcookies"; - var config = Configuration.Default.WithCookies().WithRequesters().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); await context.OpenAsync(cookieUrl); var document = await context.OpenAsync(redirectUrl); - Assert.AreEqual(@"{ - ""test"": ""baz"" -} -".Replace("\r\n", "\n"), document.Body.TextContent); + AssertCookies(document.Body.TextContent, + new KeyValuePair("test", "baz")); } } @@ -98,15 +97,26 @@ public async Task SettingCookieIsPreservedViaRedirectToDifferentProtocol() { var cookieUrl = "https://httpbingo.org/cookies/set?test=baz"; var redirectUrl = "http://httpbingo.org/redirect-to?url=http%3A%2F%2Fhttpbingo.org%2Fcookies"; - var config = Configuration.Default.WithCookies().WithRequesters().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); await context.OpenAsync(cookieUrl); var document = await context.OpenAsync(redirectUrl); - Assert.AreEqual(@"{ - ""test"": ""baz"" -} -".Replace("\r\n", "\n"), document.Body.TextContent); + AssertCookies(document.Body.TextContent); + } + } + + private static void AssertCookies(String body, params KeyValuePair[] expected) + { + var parsed = JObject.Parse(body); + var source = parsed["cookies"] as JObject ?? parsed; + var actual = source.Properties().ToDictionary(m => m.Name, m => m.Value.ToString()); + + CollectionAssert.AreEquivalent(expected.Select(m => m.Key), actual.Keys); + + foreach (var pair in expected) + { + Assert.AreEqual(pair.Value, actual[pair.Key]); } } } diff --git a/src/AngleSharp.Io.Tests/Network/FetchTests.cs b/src/AngleSharp.Io.Tests/Network/FetchTests.cs new file mode 100644 index 0000000..c22c362 --- /dev/null +++ b/src/AngleSharp.Io.Tests/Network/FetchTests.cs @@ -0,0 +1,186 @@ +namespace AngleSharp.Io.Tests.Network +{ + using AngleSharp; + using AngleSharp.Dom; + using AngleSharp.Html; + using AngleSharp.Io; + using AngleSharp.Io.Dom; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class FetchTests + { + [Test] + public async Task FetchShouldUseGetByDefaultAndResolveRelativeUrl() + { + var loader = new TestDocumentLoader(request => + new TestDownload(Task.FromResult(CreateResponse("https://example.org/api/data", "ok")))); + + var window = await OpenWindowAsync(loader, "https://example.org/base/page").ConfigureAwait(false); + var response = await window.Fetch("/api/data").ConfigureAwait(false); + + Assert.IsNotNull(response); + Assert.IsNotNull(loader.LastRequest); + Assert.AreEqual(HttpMethod.Get, loader.LastRequest.Method); + Assert.AreEqual("https://example.org/api/data", loader.LastRequest.Target.Href); + } + + [Test] + public async Task FetchShouldApplyMethodHeadersAndBody() + { + var loader = new TestDocumentLoader(request => + new TestDownload(Task.FromResult(CreateResponse("https://example.org/api", "ok")))); + + var window = await OpenWindowAsync(loader, "https://example.org/base/page").ConfigureAwait(false); + var options = new FetchOptions + { + Method = "POST", + Headers = new Dictionary + { + ["X-Test"] = "abc" + }, + Body = "payload", + }; + + await window.Fetch("/api", options).ConfigureAwait(false); + + Assert.AreEqual(HttpMethod.Post, loader.LastRequest.Method); + Assert.AreEqual("abc", loader.LastRequest.Headers["X-Test"]); + + using (var reader = new StreamReader(loader.LastRequest.Body, Encoding.UTF8, true, 1024, true)) + { + Assert.AreEqual("payload", reader.ReadToEnd()); + } + } + + [Test] + public async Task FetchShouldSetMultipartContentTypeForFormDataWhenMissing() + { + var loader = new TestDocumentLoader(request => + new TestDownload(Task.FromResult(CreateResponse("https://example.org/form", "ok")))); + + var window = await OpenWindowAsync(loader, "https://example.org/base/page").ConfigureAwait(false); + var formData = new FormDataSet(); + formData.Append("name", "Ada", "text/plain"); + + await window.Fetch("/form", new FetchOptions + { + Method = "POST", + Body = formData, + }).ConfigureAwait(false); + + Assert.IsTrue(loader.LastRequest.Headers.ContainsKey(HeaderNames.ContentType)); + Assert.IsTrue(loader.LastRequest.Headers[HeaderNames.ContentType].StartsWith("multipart/form-data; boundary=")); + } + + [Test] + public async Task FetchShouldNotOverrideExplicitContentType() + { + var loader = new TestDocumentLoader(request => + new TestDownload(Task.FromResult(CreateResponse("https://example.org/form", "ok")))); + + var window = await OpenWindowAsync(loader, "https://example.org/base/page").ConfigureAwait(false); + var formData = new FormDataSet(); + formData.Append("name", "Ada", "text/plain"); + + await window.Fetch("/form", new FetchOptions + { + Method = "POST", + Body = formData, + Headers = new Dictionary + { + [HeaderNames.ContentType] = "application/custom" + } + }).ConfigureAwait(false); + + Assert.AreEqual("application/custom", loader.LastRequest.Headers[HeaderNames.ContentType]); + } + + [Test] + public void FetchShouldThrowWhenWindowIsNull() + { + Assert.ThrowsAsync(async () => await WindowExtensions.Fetch(null, "https://example.org")); + } + + [Test] + public async Task FetchShouldThrowWhenLoaderIsMissing() + { + var context = BrowsingContext.New(); + var document = await context.OpenAsync(req => req.Address("https://example.org/").Content("fetch")).ConfigureAwait(false); + + Assert.ThrowsAsync(async () => await document.DefaultView.Fetch("https://example.org/any").ConfigureAwait(false)); + } + + private static async Task OpenWindowAsync(IDocumentLoader loader, String address) + { + var config = Configuration.Default.WithOnly(_ => loader); + var context = BrowsingContext.New(config); + var document = await context.OpenAsync(req => req.Address(address).Content("fetch")).ConfigureAwait(false); + return document.DefaultView; + } + + private static DefaultResponse CreateResponse(String address, String body) + { + return new DefaultResponse + { + Address = Url.Create(address), + StatusCode = System.Net.HttpStatusCode.OK, + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase), + Content = new MemoryStream(Encoding.UTF8.GetBytes(body ?? String.Empty)), + }; + } + + private sealed class TestDocumentLoader : IDocumentLoader + { + private readonly Func _createDownload; + + public TestDocumentLoader(Func createDownload) + { + _createDownload = createDownload; + } + + public DocumentRequest LastRequest { get; private set; } + + public IDownload FetchAsync(DocumentRequest request) + { + LastRequest = request; + return _createDownload.Invoke(request); + } + + public IEnumerable GetDownloads() + { + yield break; + } + } + + private sealed class TestDownload : IDownload + { + private readonly Task _task; + + public TestDownload(Task task) + { + _task = task; + Target = Url.Create("https://example.org/"); + } + + public Url Target { get; } + + public Object Source => null; + + public Task Task => _task; + + public Boolean IsCompleted => _task.IsCompleted; + + public Boolean IsRunning => !_task.IsCompleted; + + public void Cancel() + { + } + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io.Tests/Network/HttpClientRequesterTests.cs b/src/AngleSharp.Io.Tests/Network/HttpClientRequesterTests.cs index eb10ce9..8244f46 100644 --- a/src/AngleSharp.Io.Tests/Network/HttpClientRequesterTests.cs +++ b/src/AngleSharp.Io.Tests/Network/HttpClientRequesterTests.cs @@ -6,6 +6,7 @@ namespace AngleSharp.Io.Tests.Network using FluentAssertions; using NUnit.Framework; using System; + using System.Net; using System.IO; using System.Linq; using System.Net.Http; @@ -34,7 +35,9 @@ public async Task RequestWithContent() ts.HttpRequestMessage.Content.Headers.Select(p => p.Key).Should().BeEquivalentTo(new[] {"Content-Type", "Content-Length"}); ts.HttpRequestMessage.Content.Headers.ContentType.ToString().Should().Be("application/json"); ts.HttpRequestMessage.Content.Headers.ContentLength.Should().Be(9); + #pragma warning disable CS0618 ts.HttpRequestMessage.Properties.Should().BeEmpty(); + #pragma warning restore CS0618 ts.HttpRequestMessage.Headers.Select(p => p.Key).Should().BeEquivalentTo(new[] {"User-Agent", "Cookie"}); ts.HttpRequestMessage.Headers.UserAgent.ToString().Should().Be("Foo/2.0"); ts.HttpRequestMessage.Headers.Single(p => p.Key == "Cookie").Value.Should().BeEquivalentTo(new[] {"foo=bar"}); @@ -53,7 +56,9 @@ public async Task RequestWithoutContent() ts.HttpRequestMessage.Method.Should().Be(NetHttpMethod.Get); ts.HttpRequestMessage.RequestUri.Should().Be(new Uri("http://example/path?query=value")); ts.HttpRequestMessage.Content.Should().BeNull(); + #pragma warning disable CS0618 ts.HttpRequestMessage.Properties.Should().BeEmpty(); + #pragma warning restore CS0618 ts.HttpRequestMessage.Headers.Select(p => p.Key).Should().BeEquivalentTo(new[] {"User-Agent", "Cookie"}); ts.HttpRequestMessage.Headers.UserAgent.ToString().Should().Be("Foo/2.0"); ts.HttpRequestMessage.Headers.Single(p => p.Key == "Cookie").Value.Should().BeEquivalentTo(new[] {"foo=bar"}); @@ -118,18 +123,22 @@ public async Task EndToEnd() if (Helper.IsNetworkAvailable()) { // ARRANGE - var httpClient = new HttpClient(); + var httpClientHandler = new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.Brotli | DecompressionMethods.GZip | DecompressionMethods.Deflate, + }; + var httpClient = new HttpClient(httpClientHandler); var requester = new HttpClientRequester(httpClient); var configuration = Configuration.Default.With(requester).WithDefaultLoader(); var context = BrowsingContext.New(configuration); - var request = DocumentRequest.Get(Url.Create("http://httpbingo.org/html")); + var request = DocumentRequest.Get(Url.Create("https://httpbingo.org/html")); // ACT var response = await context.GetService().FetchAsync(request).Task; var document = await context.OpenAsync(response, CancellationToken.None); // ASSERT - document.QuerySelector("h1").ToHtml().Should().Be("

Herman Melville - Moby-Dick

"); + document.QuerySelector("h1")?.TextContent.Should().Be("Herman Melville - Moby-Dick"); } } } diff --git a/src/AngleSharp.Io.Tests/Network/WebSocketTests.cs b/src/AngleSharp.Io.Tests/Network/WebSocketTests.cs index a3b1efa..b710107 100644 --- a/src/AngleSharp.Io.Tests/Network/WebSocketTests.cs +++ b/src/AngleSharp.Io.Tests/Network/WebSocketTests.cs @@ -21,8 +21,8 @@ public async Task ConnectToWebSocketEcho() var haserror = false; var messages = new List(); var closed = new TaskCompletionSource(); - var document = await BrowsingContext.New().OpenNewAsync("https://echo.websocket.events/.ws"); - var ws = new WebSocket(document.DefaultView, "wss://echo.websocket.events/"); + var document = await BrowsingContext.New().OpenNewAsync("https://echo.websocket.org/"); + var ws = new WebSocket(document.DefaultView, "wss://echo.websocket.org/"); // ACT ws.Opened += (s, ev) => ws.Send(message); @@ -50,7 +50,7 @@ public async Task ConnectToWebSocketEcho() // ASSERT haserror.Should().BeFalse(); messages.Count.Should().Be(2); - messages[0].Should().Be("echo.websocket.events sponsored by Lob.com"); + messages[0].Should().StartWith("Request served by "); messages[1].Should().Be(message); } } diff --git a/src/AngleSharp.Io.Tests/Network/XmlHttpRequesterTests.cs b/src/AngleSharp.Io.Tests/Network/XmlHttpRequesterTests.cs new file mode 100644 index 0000000..09329f3 --- /dev/null +++ b/src/AngleSharp.Io.Tests/Network/XmlHttpRequesterTests.cs @@ -0,0 +1,416 @@ +namespace AngleSharp.Io.Tests.Network +{ + using AngleSharp.Browser; + using AngleSharp.Common; + using AngleSharp.Dom; + using AngleSharp.Html; + using AngleSharp.Io; + using AngleSharp.Io.Dom; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.IO; + using System.Net; + using System.Reflection; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class XmlHttpRequesterTests + { + [Test] + public async Task OpenAndSendShouldCreateRequestWithHeadersAndBody() + { + var loader = new TestDocumentLoader(request => + { + var response = CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.Accepted); + return new TestDownload(request.Target, Task.FromResult(response)); + }); + + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + xhr.Open("POST", "https://api.example.test/endpoint"); + xhr.SetRequestHeader("X-Test", "abc"); + xhr.Send("payload"); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.IsNotNull(loader.LastRequest); + Assert.AreEqual(HttpMethod.Post, loader.LastRequest.Method); + Assert.AreEqual("https://api.example.test/endpoint", loader.LastRequest.Target.Href); + Assert.AreEqual("abc", loader.LastRequest.Headers["X-Test"]); + + using (var reader = new StreamReader(loader.LastRequest.Body, Encoding.UTF8, true, 1024, true)) + { + Assert.AreEqual("payload", reader.ReadToEnd()); + } + } + + [Test] + public async Task SendWithFormDataShouldSetMultipartContentTypeWhenMissing() + { + var loader = new TestDocumentLoader(request => + { + var response = CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.OK); + return new TestDownload(request.Target, Task.FromResult(response)); + }); + + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + var formData = new FormDataSet(); + formData.Append("foo", "bar", "text/plain"); + + xhr.Open("POST", "https://api.example.test/endpoint"); + xhr.Send(formData); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.IsTrue(loader.LastRequest.Headers.ContainsKey(HeaderNames.ContentType)); + Assert.IsTrue(loader.LastRequest.Headers[HeaderNames.ContentType].StartsWith("multipart/form-data; boundary=")); + } + + [Test] + public async Task SendShouldNotOverrideExplicitContentType() + { + var loader = new TestDocumentLoader(request => + { + var response = CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.OK); + return new TestDownload(request.Target, Task.FromResult(response)); + }); + + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + var formData = new FormDataSet(); + formData.Append("foo", "bar", "text/plain"); + + xhr.Open("POST", "https://api.example.test/endpoint"); + xhr.SetRequestHeader(HeaderNames.ContentType, "application/custom"); + xhr.Send(formData); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.AreEqual("application/custom", loader.LastRequest.Headers[HeaderNames.ContentType]); + } + + [Test] + public async Task SendShouldPopulateResponseStatusTextHeadersAndBody() + { + var response = CreateResponse("https://api.example.test/endpoint", "{\"result\":true}", HttpStatusCode.Created); + response.Headers["X-Result"] = "ok"; + + var loader = new TestDocumentLoader(request => + new TestDownload(request.Target, Task.FromResult(response))); + + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + xhr.Open("GET", "https://api.example.test/endpoint"); + xhr.Send(); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.AreEqual((Int32)HttpStatusCode.Created, xhr.StatusCode); + Assert.AreEqual(HttpStatusCode.Created.ToString(), xhr.StatusText); + Assert.AreEqual("https://api.example.test/endpoint", xhr.ResponseUrl); + Assert.AreEqual("{\"result\":true}", xhr.ResponseText); + Assert.AreEqual("ok", xhr.GetResponseHeader("X-Result")); + Assert.IsTrue(xhr.GetAllResponseHeaders().Contains("X-Result: ok")); + } + + [Test] + public async Task ReadyStateShouldTransitionInExpectedOrderForSuccessfulRequest() + { + var loader = new TestDocumentLoader(request => + { + var response = CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.OK); + return new TestDownload(request.Target, Task.FromResult(response)); + }); + + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + var states = new List(); + + xhr.ReadyStateChanged += (_, __) => states.Add(xhr.ReadyState); + + xhr.Open("GET", "https://api.example.test/endpoint"); + xhr.Send(); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + CollectionAssert.AreEqual( + new[] { RequesterState.Opened, RequesterState.HeadersReceived, RequesterState.Loading, RequesterState.Done }, + states); + } + + [Test] + public async Task SendShouldRaiseTimeoutEventWhenDownloadTaskIsCanceled() + { + var canceled = Task.FromCanceled(new CancellationToken(canceled: true)); + var loader = new TestDocumentLoader(request => new TestDownload(request.Target, canceled)); + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + + var timedOut = false; + xhr.Timedout += (_, __) => timedOut = true; + + xhr.Open("GET", "https://api.example.test/endpoint"); + xhr.Send(); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.IsTrue(timedOut); + Assert.AreEqual(0, xhr.StatusCode); + } + + [Test] + public async Task SendShouldRaiseErrorEventWhenDownloadTaskFails() + { + var failed = Task.FromException(new InvalidOperationException("boom")); + var loader = new TestDocumentLoader(request => new TestDownload(request.Target, failed)); + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + + var errored = false; + xhr.Error += (_, __) => errored = true; + + xhr.Open("GET", "https://api.example.test/endpoint"); + xhr.Send(); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.IsTrue(errored); + Assert.AreEqual(0, xhr.StatusCode); + } + + [Test] + public async Task AbortShouldCancelDownloadAndRaiseAbortEventWhenLoading() + { + var loader = new TestDocumentLoader(request => + new TestDownload(request.Target, Task.FromResult(CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.OK)))); + + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + var aborted = false; + xhr.Aborted += (_, __) => aborted = true; + + xhr.Open("GET", "https://api.example.test/endpoint"); + + var download = new TestDownload(Url.Create("https://api.example.test/endpoint"), Task.FromResult(CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.OK))); + + typeof(XmlHttpRequest).GetField("_download", BindingFlags.Instance | BindingFlags.NonPublic) + ?.SetValue(xhr, download); + + typeof(XmlHttpRequest).GetProperty("ReadyState", BindingFlags.Instance | BindingFlags.Public) + ?.SetValue(xhr, RequesterState.Loading); + + xhr.Abort(); + + Assert.IsTrue(aborted); + Assert.IsTrue(download.Canceled); + } + + [Test] + public async Task ResponseHeadersShouldBeUnavailableBeforeResponseIsReceived() + { + var pending = new TaskCompletionSource(); + var loader = new TestDocumentLoader(request => new TestDownload(request.Target, pending.Task)); + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + + xhr.Open("GET", "https://api.example.test/endpoint"); + xhr.Send(); + + Assert.AreEqual(String.Empty, xhr.GetResponseHeader("X-Anything")); + Assert.AreEqual(String.Empty, xhr.GetAllResponseHeaders()); + + var response = CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.OK); + response.Headers["X-Anything"] = "value"; + pending.TrySetResult(response); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.AreEqual("value", xhr.GetResponseHeader("X-Anything")); + } + + [Test] + public void SerializeFormDataSetBodyShouldProduceMultipartContent() + { + var formData = new FormDataSet(); + formData.Append("name", "Ada Lovelace", "text/plain"); + formData.Append("role", "Mathematician", "text/plain"); + + var method = typeof(XmlHttpRequest).GetMethod("Serialize", BindingFlags.Static | BindingFlags.NonPublic); + var serialized = method.Invoke(null, new Object[] { formData }); + var serializedType = serialized.GetType(); + var content = (Stream)serializedType.GetProperty("Content").GetValue(serialized); + var contentType = (String)serializedType.GetProperty("ContentType").GetValue(serialized); + + String body; + + using (var reader = new StreamReader(content, Encoding.UTF8, true, 1024, true)) + { + body = reader.ReadToEnd(); + } + + Assert.IsTrue(body.Contains("name=\"name\""), body); + Assert.IsTrue(body.Contains("Ada Lovelace"), body); + Assert.IsTrue(body.Contains("name=\"role\""), body); + Assert.IsTrue(body.Contains("Mathematician"), body); + Assert.IsTrue(contentType.StartsWith("multipart/form-data; boundary="), contentType); + } + + [Test] + public void SerializeUrlSearchParamsShouldUseWwwFormUrlEncodedContentType() + { + var searchParams = new UrlSearchParams("a=1&b=2"); + var method = typeof(XmlHttpRequest).GetMethod("Serialize", BindingFlags.Static | BindingFlags.NonPublic); + var serialized = method.Invoke(null, new Object[] { searchParams }); + var serializedType = serialized.GetType(); + var content = (Stream)serializedType.GetProperty("Content").GetValue(serialized); + var contentType = (String)serializedType.GetProperty("ContentType").GetValue(serialized); + + String body; + + using (var reader = new StreamReader(content, Encoding.UTF8, true, 1024, true)) + { + body = reader.ReadToEnd(); + } + + Assert.AreEqual("a=1&b=2", body); + Assert.AreEqual("application/x-www-form-urlencoded; charset=UTF-8", contentType); + } + + [Test] + public void SerializeNullShouldProduceEmptyBodyWithoutContentType() + { + var method = typeof(XmlHttpRequest).GetMethod("Serialize", BindingFlags.Static | BindingFlags.NonPublic); + var serialized = method.Invoke(null, new Object[] { null }); + var serializedType = serialized.GetType(); + var content = (Stream)serializedType.GetProperty("Content").GetValue(serialized); + var contentType = (String)serializedType.GetProperty("ContentType").GetValue(serialized); + + Assert.AreEqual(String.Empty, new StreamReader(content, Encoding.UTF8, true, 1024, true).ReadToEnd()); + Assert.IsNull(contentType); + } + + private static async Task CreateRequesterAsync(IDocumentLoader loader, String address) + { + var config = Configuration.Default + .WithOnly(_ => loader) + .WithOnly(_ => new ImmediateEventLoop()); + + var context = BrowsingContext.New(config); + var document = await context.OpenAsync(req => req.Address(address).Content("xhr")).ConfigureAwait(false); + return new XmlHttpRequest(document.DefaultView); + } + + private static async Task WaitForReadyStateAsync(XmlHttpRequest xhr, RequesterState state) + { + for (var i = 0; i < 200; i++) + { + if (xhr.ReadyState == state) + { + return; + } + + await Task.Delay(5).ConfigureAwait(false); + } + + Assert.Fail("Expected ready state {0}, but was {1}.", state, xhr.ReadyState); + } + + private static DefaultResponse CreateResponse(String address, String body, HttpStatusCode statusCode) + { + return new DefaultResponse + { + Address = Url.Create(address), + StatusCode = statusCode, + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase), + Content = new MemoryStream(Encoding.UTF8.GetBytes(body ?? String.Empty)), + }; + } + + private sealed class TestDocumentLoader : IDocumentLoader + { + private readonly Func _createDownload; + + public TestDocumentLoader(Func createDownload) + { + _createDownload = createDownload ?? throw new ArgumentNullException(nameof(createDownload)); + } + + public DocumentRequest LastRequest { get; private set; } + + public TestDownload LastDownload { get; private set; } + + public IDownload FetchAsync(DocumentRequest request) + { + LastRequest = request; + var download = _createDownload.Invoke(request); + + if (download is TestDownload testDownload) + { + LastDownload = testDownload; + } + + return download; + } + + public IEnumerable GetDownloads() + { + if (LastDownload != null) + { + yield return LastDownload; + } + } + } + + private sealed class TestDownload : IDownload + { + private readonly Task _task; + + public TestDownload(Url target, Task task, Object source = null) + { + Target = target; + Source = source; + _task = task ?? throw new ArgumentNullException(nameof(task)); + } + + public Url Target { get; } + + public Object Source { get; } + + public Task Task => _task; + + public Boolean IsCompleted => _task.IsCompleted; + + public Boolean IsRunning => !_task.IsCompleted; + + public Boolean Canceled { get; private set; } + + public void Cancel() + { + Canceled = true; + } + } + + private sealed class ImmediateEventLoop : IEventLoop + { + public ICancellable Enqueue(Action action, TaskPriority priority) + { + action(CancellationToken.None); + return new CompletedCancellable(); + } + + public void CancelAll() + { + } + + public void Spin() + { + } + } + + private sealed class CompletedCancellable : ICancellable + { + public Boolean IsCompleted => true; + + public Boolean IsRunning => false; + + public void Cancel() + { + } + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io.Tests/Properties/AssemblyInfo.cs b/src/AngleSharp.Io.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index 07a9d40..0000000 --- a/src/AngleSharp.Io.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyCopyright("Copyright © AngleSharp, 2013-2019.")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: ComVisible(false)] -[assembly: Guid("863a9ca5-a39b-42a9-967f-301d5fb8e630")] diff --git a/src/AngleSharp.Io.Tests/Storage/StorageTests.cs b/src/AngleSharp.Io.Tests/Storage/StorageTests.cs new file mode 100644 index 0000000..1787a3d --- /dev/null +++ b/src/AngleSharp.Io.Tests/Storage/StorageTests.cs @@ -0,0 +1,406 @@ +namespace AngleSharp.Io.Tests.Storage +{ + using AngleSharp; + using AngleSharp.Browser; + using AngleSharp.Dom; + using AngleSharp.Io.Dom; + using AngleSharp.Io.Storage; + using NUnit.Framework; + using System; + using System.Linq; + using System.IO; + using System.Threading.Tasks; + + [TestFixture] + public class StorageTests + { + [Test] + public void StorageUsesCurrentOriginBucket() + { + var origin = "https://a.example"; + var buckets = new System.Collections.Generic.Dictionary(StringComparer.Ordinal); + var storage = new Storage(() => origin, o => GetOrCreateBucket(buckets, o), (o, b) => { }); + + storage["a"] = "1"; + storage["b"] = "2"; + + Assert.AreEqual(2, storage.Length); + Assert.AreEqual("1", storage["a"]); + Assert.AreEqual("a", storage.Key(0)); + Assert.AreEqual("b", storage.Key(1)); + + origin = "https://b.example"; + Assert.AreEqual(0, storage.Length); + Assert.AreEqual(null, storage["a"]); + } + + [Test] + public void StoragePersistsViaHandlerOnMutation() + { + var handler = new MemoryStorageHandler(); + var buckets = new System.Collections.Generic.Dictionary(StringComparer.Ordinal); + var storage = new Storage(() => "https://a.example", o => + { + if (!buckets.TryGetValue(o, out var bucket)) + { + bucket = new StorageBucket(handler.Read(o)); + buckets[o] = bucket; + } + + return bucket; + }, (o, b) => handler.Write(o, b.ToEntries())); + + storage["a"] = "1"; + storage["b"] = "2"; + storage.Remove("a"); + storage.Clear(); + + Assert.IsFalse(handler.Read("https://a.example").Any()); + } + + [Test] + public void MemoryStorageHandlerReadsAndWritesPerOrigin() + { + var handler = new MemoryStorageHandler(); + handler.Write("https://a.example", new[] + { + new System.Collections.Generic.KeyValuePair("foo", "bar"), + }); + + handler.Write("https://b.example", new[] + { + new System.Collections.Generic.KeyValuePair("foo", "baz"), + }); + + var a = handler.Read("https://a.example").Single(); + var b = handler.Read("https://b.example").Single(); + + Assert.AreEqual("bar", a.Value); + Assert.AreEqual("baz", b.Value); + } + + [Test] + public void PersistenceStorageHandlerStoresFilesPerOrigin() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var handler = new PersistenceStorageHandler(directory); + handler.Write("https://a.example", new[] + { + new System.Collections.Generic.KeyValuePair("foo", "bar"), + }); + handler.Write("https://b.example", new[] + { + new System.Collections.Generic.KeyValuePair("foo", "baz"), + }); + + var files = Directory.GetFiles(directory, "*.storage"); + Assert.AreEqual(2, files.Length); + Assert.AreEqual("bar", handler.Read("https://a.example").Single().Value); + Assert.AreEqual("baz", handler.Read("https://b.example").Single().Value); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task ConfigurationCanRegisterLocalAndSessionStorage() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://storage.example/"); + + Assert.IsNotNull(window.LocalStorage()); + Assert.IsNotNull(window.SessionStorage()); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task LocalStorageIsOriginDependentAcrossWindows() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + var contextC = BrowsingContext.New(config); + var windowA = await OpenWindowAsync(contextA, "https://storage.example/"); + var windowB = await OpenWindowAsync(contextB, "https://other.example/"); + var windowC = await OpenWindowAsync(contextC, "https://storage.example/again"); + + windowA.LocalStorage()["token"] = "x"; + Assert.AreEqual("x", windowA.LocalStorage()["token"]); + Assert.AreEqual(null, windowB.LocalStorage()["token"]); + Assert.AreEqual("x", windowC.LocalStorage()["token"]); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task LocalStorageSharesStateWithinTheSameBrowsingContextTree() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var parentContext = BrowsingContext.New(config); + var childContext = parentContext.CreateChild("child", Sandboxes.None); + var parallelContext = BrowsingContext.New(config); + + var parentWindow = await OpenWindowAsync(parentContext, "https://alpha.example/"); + var childWindow = await OpenWindowAsync(childContext, "https://alpha.example/frame"); + var parallelWindow = await OpenWindowAsync(parallelContext, "https://alpha.example/"); + + var localStorage = parentWindow.LocalStorage(); + localStorage["token"] = "x"; + + Assert.AreEqual("x", childWindow.LocalStorage()["token"]); + Assert.AreEqual("x", parallelWindow.LocalStorage()["token"]); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task LocalStorageFiresStorageEventToOtherSameOriginWindows() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var parentContext = BrowsingContext.New(config); + var childContext = parentContext.CreateChild("child", Sandboxes.None); + var parallelContext = BrowsingContext.New(config); + + var parentWindow = await OpenWindowAsync(parentContext, "https://alpha.example/"); + var childWindow = await OpenWindowAsync(childContext, "https://alpha.example/frame"); + var parallelWindow = await OpenWindowAsync(parallelContext, "https://alpha.example/"); + + var parentStorage = parentWindow.LocalStorage(); + childWindow.LocalStorage(); + parallelWindow.LocalStorage(); + + var parentEvents = 0; + var childEvents = 0; + var parallelEvents = 0; + + parentWindow.AddEventListener(EventNames.Storage, (_, __) => parentEvents++, false); + childWindow.AddEventListener(EventNames.Storage, (_, __) => childEvents++, false); + parallelWindow.AddEventListener(EventNames.Storage, (_, __) => parallelEvents++, false); + + parentStorage["token"] = "x"; + + Assert.AreEqual(0, parentEvents); + Assert.AreEqual(1, childEvents); + Assert.AreEqual(1, parallelEvents); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task SessionStorageIsIsolatedAcrossParallelBrowsingContexts() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + + var windowA = await OpenWindowAsync(contextA, "https://alpha.example/"); + var windowB = await OpenWindowAsync(contextB, "https://alpha.example/"); + + var sessionA = windowA.SessionStorage(); + var sessionB = windowB.SessionStorage(); + + sessionA["sid"] = "1"; + + Assert.AreEqual("1", sessionA["sid"]); + Assert.AreEqual(null, sessionB["sid"]); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task SessionStorageFiresStorageEventOnlyWithinTheSameBrowsingContextTree() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var parentContext = BrowsingContext.New(config); + var childContext = parentContext.CreateChild("child", Sandboxes.None); + var parallelContext = BrowsingContext.New(config); + + var parentWindow = await OpenWindowAsync(parentContext, "https://alpha.example/"); + var childWindow = await OpenWindowAsync(childContext, "https://alpha.example/frame"); + var parallelWindow = await OpenWindowAsync(parallelContext, "https://alpha.example/"); + + var parentStorage = parentWindow.SessionStorage(); + childWindow.SessionStorage(); + parallelWindow.SessionStorage(); + + var parentEvents = 0; + var childEvents = 0; + var parallelEvents = 0; + + parentWindow.AddEventListener(EventNames.Storage, (_, __) => parentEvents++, false); + childWindow.AddEventListener(EventNames.Storage, (_, __) => childEvents++, false); + parallelWindow.AddEventListener(EventNames.Storage, (_, __) => parallelEvents++, false); + + parentStorage["sid"] = "1"; + + Assert.AreEqual(0, parentEvents); + Assert.AreEqual(1, childEvents); + Assert.AreEqual(0, parallelEvents); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task SessionStorageSharesWithinTheSameBrowsingContextTree() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var parentContext = BrowsingContext.New(config); + var childContext = parentContext.CreateChild("child", Sandboxes.None); + var parallelContext = BrowsingContext.New(config); + + var parentWindow = await OpenWindowAsync(parentContext, "https://alpha.example/"); + var childWindow = await OpenWindowAsync(childContext, "https://alpha.example/frame"); + var parallelWindow = await OpenWindowAsync(parallelContext, "https://alpha.example/"); + + var parentSession = parentWindow.SessionStorage(); + var childSession = childWindow.SessionStorage(); + var parallelSession = parallelWindow.SessionStorage(); + + parentSession["sid"] = "1"; + + Assert.AreEqual("1", parentSession["sid"]); + Assert.AreEqual("1", childSession["sid"]); + Assert.AreEqual(null, parallelSession["sid"]); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + private static StorageBucket GetOrCreateBucket(System.Collections.Generic.Dictionary buckets, String origin) + { + origin = origin ?? String.Empty; + + if (!buckets.TryGetValue(origin, out var bucket)) + { + bucket = new StorageBucket(); + buckets[origin] = bucket; + } + + return bucket; + } + + private static IConfiguration CreateConfigWithStorages(String directory) + { + var factory = new StorageProviderFactory(); + factory.EnableLocalStorage(directory); + factory.EnableSessionStorage(); + return Configuration.Default.WithStorageProviderFactory(factory); + } + + private static async Task OpenWindowAsync(IBrowsingContext context, String address) + { + var document = await context.OpenAsync(res => + res.Address(address).Content("storage")); + return document.DefaultView; + } + + [TearDown] + public void CleanupTempStorageFiles() + { + var tmp = Path.GetTempPath(); + + foreach (var file in Directory.GetFiles(tmp, "anglesharp-io-storage-*.txt")) + { + try + { + File.Delete(file); + } + catch + { + // Best effort cleanup for temporary test files. + } + } + + foreach (var directory in Directory.GetDirectories(tmp, "anglesharp-io-storage-*") ) + { + try + { + Directory.Delete(directory, true); + } + catch + { + // Best effort cleanup for temporary test directories. + } + } + } + } +} diff --git a/src/AngleSharp.Io.Tests/WebLocks/WebLocksTests.cs b/src/AngleSharp.Io.Tests/WebLocks/WebLocksTests.cs new file mode 100644 index 0000000..c02a184 --- /dev/null +++ b/src/AngleSharp.Io.Tests/WebLocks/WebLocksTests.cs @@ -0,0 +1,111 @@ +namespace AngleSharp.Io.Tests.WebLocks +{ + using AngleSharp; + using AngleSharp.Browser.Dom; + using AngleSharp.Io.Dom; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + [TestFixture] + public class WebLocksTests + { + [Test] + public async Task NavigatorLocksCanBeCreatedFromADocumentWindow() + { + var context = await OpenContextAsync("https://locks.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); + + Assert.IsNotNull(navigator.Locks()); + } + + [Test] + public async Task NavigatorLocksSerializeSameOriginRequests() + { + var contextA = await OpenContextAsync("https://locks.example/").ConfigureAwait(false); + var contextB = await OpenContextAsync("https://locks.example/other").ConfigureAwait(false); + var locksA = GetNavigator(contextA).Locks(); + var locksB = GetNavigator(contextB).Locks(); + + var enteredA = new TaskCompletionSource(); + var enteredB = new TaskCompletionSource(); + var releaseA = new TaskCompletionSource(); + + var requestA = locksA.Request("resource", async _ => + { + enteredA.TrySetResult(true); + await releaseA.Task.ConfigureAwait(false); + }); + + await enteredA.Task.ConfigureAwait(false); + + var requestB = locksB.Request("resource", async _ => + { + enteredB.TrySetResult(true); + await Task.CompletedTask; + }); + + var blocked = await Task.WhenAny(enteredB.Task, Task.Delay(200)).ConfigureAwait(false); + Assert.AreNotEqual(enteredB.Task, blocked); + + releaseA.TrySetResult(true); + + var completed = await Task.WhenAny(enteredB.Task, Task.Delay(1000)).ConfigureAwait(false); + Assert.AreEqual(enteredB.Task, completed); + + await Task.WhenAll(requestA, requestB).ConfigureAwait(false); + } + + [Test] + public async Task NavigatorLocksDoNotCrossOrigins() + { + var contextA = await OpenContextAsync("https://locks.example/").ConfigureAwait(false); + var contextB = await OpenContextAsync("https://other.example/").ConfigureAwait(false); + var locksA = GetNavigator(contextA).Locks(); + var locksB = GetNavigator(contextB).Locks(); + + var enteredA = new TaskCompletionSource(); + var enteredB = new TaskCompletionSource(); + var releaseA = new TaskCompletionSource(); + + var requestA = locksA.Request("resource", async _ => + { + enteredA.TrySetResult(true); + await releaseA.Task.ConfigureAwait(false); + }); + + await enteredA.Task.ConfigureAwait(false); + + var requestB = locksB.Request("resource", async _ => + { + enteredB.TrySetResult(true); + await Task.CompletedTask; + }); + + var completed = await Task.WhenAny(enteredB.Task, Task.Delay(500)).ConfigureAwait(false); + Assert.AreEqual(enteredB.Task, completed); + + releaseA.TrySetResult(true); + + await Task.WhenAll(requestA, requestB).ConfigureAwait(false); + } + + private static async Task OpenContextAsync(String address) + { + var context = BrowsingContext.New(Configuration.Default.WithNavigator()); + + var document = await context.OpenAsync(res => + res.Address(address).Content("locks")).ConfigureAwait(false); + + Assert.IsNotNull(document.DefaultView); + return context; + } + + private static INavigator GetNavigator(IBrowsingContext context) + { + var navigator = context?.Active?.DefaultView?.Navigator; + Assert.IsNotNull(navigator); + return navigator; + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io.nuspec b/src/AngleSharp.Io.nuspec deleted file mode 100644 index 2806336..0000000 --- a/src/AngleSharp.Io.nuspec +++ /dev/null @@ -1,21 +0,0 @@ - - - - AngleSharp.Io - $version$ - AngleSharp - Florian Rappl - MIT - https://anglesharp.github.io - logo.png - README.md - false - Providers additional requesters and IO helpers for AngleSharp. - https://github.com/AngleSharp/AngleSharp.Io/blob/main/CHANGELOG.md - Copyright 2016-2023, AngleSharp - html html5 css css3 dom requester http https io filesystem storage httpclient cache - - - - - diff --git a/src/AngleSharp.Io.sln b/src/AngleSharp.Io.sln index 7051e5e..f433e0f 100644 --- a/src/AngleSharp.Io.sln +++ b/src/AngleSharp.Io.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AngleSharp.Io", "AngleSharp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AngleSharp.Io.Tests", "AngleSharp.Io.Tests\AngleSharp.Io.Tests.csproj", "{18B0B97B-8795-4DC2-A1E7-8070255BE718}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "..\build\_build.csproj", "{07DA52AA-8F6F-4F43-B09E-6AE899E95E43}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/src/AngleSharp.Io/AngleSharp.Io.csproj b/src/AngleSharp.Io/AngleSharp.Io.csproj index 3e07a2c..3a93671 100644 --- a/src/AngleSharp.Io/AngleSharp.Io.csproj +++ b/src/AngleSharp.Io/AngleSharp.Io.csproj @@ -2,18 +2,25 @@ AngleSharp.Io AngleSharp.Io - netstandard2.0;net6.0;net7.0 - netstandard2.0;net461;net472;net6.0;net7.0 - true - Key.snk + netstandard2.0;net8.0;net10.0 + $(TargetFrameworks);net462;net472 true - 9 + + + + https://anglesharp.github.io + MIT + logo.png + README.md + html;html5;css;css3;dom;requester;http;https;io;filesystem;storage;httpclient;cache;anglesharp;angle + https://github.com/AngleSharp/AngleSharp.Io/blob/main/CHANGELOG.md https://github.com/AngleSharp/AngleSharp.Io git true true true snupkg + 1.5.0 @@ -21,10 +28,22 @@ - + + + + + + + + + + + + <_Parameter1>false + - + diff --git a/src/AngleSharp.Io/Cache/CacheProviderFactory.cs b/src/AngleSharp.Io/Cache/CacheProviderFactory.cs new file mode 100644 index 0000000..60bbff1 --- /dev/null +++ b/src/AngleSharp.Io/Cache/CacheProviderFactory.cs @@ -0,0 +1,268 @@ +namespace AngleSharp.Io.Cache +{ + using AngleSharp.Dom; + using AngleSharp.Io.Dom; + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net; + using System.Runtime.CompilerServices; + + /// + /// Represents the default Cache Storage provider factory with origin-dependent views. + /// + public class CacheProviderFactory : ICacheProviderFactory + { + private readonly ConditionalWeakTable _storages; + private readonly Dictionary _bucketsByOriginAndName; + + /// + /// Creates a new Cache Storage provider factory with temporary local storage and session storage enabled. + /// + /// The new Cache Storage provider factory. + public static ICacheProviderFactory CreateTemporary() + { + var factory = new CacheProviderFactory(); + return factory; + } + + /// + /// Creates a new Cache Storage provider factory. + /// + public CacheProviderFactory() + { + _storages = new ConditionalWeakTable(); + _bucketsByOriginAndName = new Dictionary(StringComparer.Ordinal); + } + + /// + public ICacheStorage GetCaches(IWindow window) + { + if (window == null) + { + return null; + } + + return _storages.GetValue(window, CreateStorage); + } + + private CacheStorage CreateStorage(IWindow window) + { + return new CacheStorage( + () => GetOrigin(window), + (origin, name) => GetOrCreateBucket(origin, name), + (origin, name) => DeleteBucket(origin, name), + origin => GetNames(origin)); + } + + private CacheBucket GetOrCreateBucket(String origin, String name) + { + var key = GetBucketKey(origin, name); + + if (!_bucketsByOriginAndName.TryGetValue(key, out var bucket)) + { + bucket = new CacheBucket(name); + _bucketsByOriginAndName[key] = bucket; + } + + return bucket; + } + + private Boolean DeleteBucket(String origin, String name) + { + return _bucketsByOriginAndName.Remove(GetBucketKey(origin, name)); + } + + private String[] GetNames(String origin) + { + origin = origin ?? String.Empty; + var prefix = origin + "|"; + + return _bucketsByOriginAndName.Keys + .Where(key => key.StartsWith(prefix, StringComparison.Ordinal)) + .Select(key => key.Substring(prefix.Length)) + .ToArray(); + } + + private static String GetBucketKey(String origin, String name) + { + return (origin ?? String.Empty) + "|" + (name ?? String.Empty); + } + + private static String GetOrigin(IWindow window) + { + var href = window?.Location?.Href; + + if (String.IsNullOrEmpty(href)) + { + return String.Empty; + } + + var url = new Url(href); + + if (url.IsInvalid || url.IsRelative) + { + return String.Empty; + } + + return url.Origin ?? String.Empty; + } + } + + internal sealed class CacheStorage : ICacheStorage + { + private readonly Func _getOrigin; + private readonly Func _getBucket; + private readonly Func _deleteBucket; + private readonly Func _getNames; + + public CacheStorage(Func getOrigin, Func getBucket, Func deleteBucket, Func getNames) + { + _getOrigin = getOrigin ?? throw new ArgumentNullException(nameof(getOrigin)); + _getBucket = getBucket ?? throw new ArgumentNullException(nameof(getBucket)); + _deleteBucket = deleteBucket ?? throw new ArgumentNullException(nameof(deleteBucket)); + _getNames = getNames ?? throw new ArgumentNullException(nameof(getNames)); + } + + public ICache Open(String name) + { + name = name ?? String.Empty; + var origin = _getOrigin.Invoke(); + return new Cache(name, _getBucket.Invoke(origin, name)); + } + + public Boolean Delete(String name) + { + name = name ?? String.Empty; + return _deleteBucket.Invoke(_getOrigin.Invoke(), name); + } + + public String[] Keys() => _getNames.Invoke(_getOrigin.Invoke()); + } + + internal sealed class Cache : ICache + { + private readonly CacheBucket _bucket; + + public Cache(String name, CacheBucket bucket) + { + _bucket = bucket ?? throw new ArgumentNullException(nameof(bucket)); + } + + public void Put(String requestAddress, IResponse response) + { + if (String.IsNullOrEmpty(requestAddress) || response == null) + { + return; + } + + _bucket.Put(requestAddress, CacheEntry.FromResponse(response)); + } + + public IResponse Match(String requestAddress) + { + if (String.IsNullOrEmpty(requestAddress)) + { + return null; + } + + return _bucket.Match(requestAddress)?.ToResponse(); + } + + public Boolean Delete(String requestAddress) + { + if (String.IsNullOrEmpty(requestAddress)) + { + return false; + } + + return _bucket.Delete(requestAddress); + } + + public String[] Keys() => _bucket.Keys(); + + public void Clear() => _bucket.Clear(); + } + + internal sealed class CacheBucket + { + private readonly Dictionary _entries; + + public CacheBucket(String name) + { + Name = name ?? String.Empty; + _entries = new Dictionary(StringComparer.Ordinal); + } + + public String Name { get; } + + public void Put(String requestAddress, CacheEntry entry) + { + _entries[requestAddress ?? String.Empty] = entry; + } + + public CacheEntry Match(String requestAddress) + { + return _entries.TryGetValue(requestAddress ?? String.Empty, out var entry) ? entry : null; + } + + public Boolean Delete(String requestAddress) + { + return _entries.Remove(requestAddress ?? String.Empty); + } + + public String[] Keys() => _entries.Keys.ToArray(); + + public void Clear() => _entries.Clear(); + } + + internal sealed class CacheEntry + { + public HttpStatusCode StatusCode { get; set; } + + public Url Address { get; set; } + + public Dictionary Headers { get; set; } + + public Byte[] Content { get; set; } + + public static CacheEntry FromResponse(IResponse response) + { + var content = Array.Empty(); + + if (response.Content != null) + { + using (var memory = new MemoryStream()) + { + if (response.Content.CanSeek) + { + response.Content.Position = 0; + } + + response.Content.CopyTo(memory); + content = memory.ToArray(); + } + } + + return new CacheEntry + { + Address = response.Address, + StatusCode = response.StatusCode, + Headers = response.Headers == null ? new Dictionary(StringComparer.OrdinalIgnoreCase) : new Dictionary(response.Headers, StringComparer.OrdinalIgnoreCase), + Content = content, + }; + } + + public IResponse ToResponse() + { + return new DefaultResponse + { + Address = Address, + StatusCode = StatusCode, + Headers = Headers == null ? new Dictionary(StringComparer.OrdinalIgnoreCase) : new Dictionary(Headers, StringComparer.OrdinalIgnoreCase), + Content = new MemoryStream(Content ?? Array.Empty()), + }; + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Clipboard/ClipboardProviderFactory.cs b/src/AngleSharp.Io/Clipboard/ClipboardProviderFactory.cs new file mode 100644 index 0000000..30c8b4f --- /dev/null +++ b/src/AngleSharp.Io/Clipboard/ClipboardProviderFactory.cs @@ -0,0 +1,37 @@ +namespace AngleSharp.Io.ClipboardApi +{ + using AngleSharp.Browser.Dom; + using AngleSharp.Io.Dom; + using System; + using System.Runtime.CompilerServices; + + /// + /// Represents the default Clipboard provider factory. + /// + public sealed class ClipboardProviderFactory : IClipboardProviderFactory + { + private readonly IClipboardPlatform _platform; + private readonly ConditionalWeakTable _clipboards; + + /// + /// Creates a new Clipboard provider factory. + /// + /// The platform clipboard implementation. + public ClipboardProviderFactory(IClipboardPlatform platform) + { + _platform = platform ?? throw new ArgumentNullException(nameof(platform)); + _clipboards = new ConditionalWeakTable(); + } + + /// + public Clipboard GetClipboard(INavigator navigator) + { + if (navigator == null) + { + return null; + } + + return _clipboards.GetValue(navigator, _ => new Clipboard(_platform)); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Cookie/AdvancedCookieProvider.cs b/src/AngleSharp.Io/Cookie/AdvancedCookieProvider.cs index 883a26e..7118a03 100644 --- a/src/AngleSharp.Io/Cookie/AdvancedCookieProvider.cs +++ b/src/AngleSharp.Io/Cookie/AdvancedCookieProvider.cs @@ -40,7 +40,7 @@ String ICookieProvider.GetCookie(Url url) var path = String.IsNullOrEmpty(url.Path) ? "/" : $"/{url.Path}"; var secure = url.Scheme.IsOneOf(ProtocolNames.Https, ProtocolNames.Wss); var now = DateTime.UtcNow; - var cookies = FindCookies(host, path) + var cookies = _cookies .ToArray() .Where(c => { diff --git a/src/AngleSharp.Io/Dom/BroadcastChannel.cs b/src/AngleSharp.Io/Dom/BroadcastChannel.cs new file mode 100644 index 0000000..e321772 --- /dev/null +++ b/src/AngleSharp.Io/Dom/BroadcastChannel.cs @@ -0,0 +1,245 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using AngleSharp.Dom; + using AngleSharp.Dom.Events; + using System; + using System.Collections.Generic; + + /// + /// Represents the BroadcastChannel interface. + /// + [DomName("BroadcastChannel")] + [DomExposed("Window")] + [DomExposed("Worker")] + public sealed class BroadcastChannel : EventTarget, IDisposable + { + private readonly String _name; + private readonly String _origin; + private readonly IWindow _window; + + private Boolean _isClosed; + + /// + /// Creates a new broadcast channel. + /// + /// The parent window. + /// The channel name. + [DomConstructor] + public BroadcastChannel(IWindow window, String name) + { + _window = window ?? throw new ArgumentNullException(nameof(window)); + _name = String.IsNullOrEmpty(name) ? String.Empty : name; + _origin = GetOrigin(window); + + BroadcastChannelRegistry.Register(this); + _window.Unloaded += OnUnload; + } + + /// + /// Gets the channel name. + /// + [DomName("name")] + public String Name => _name; + + /// + /// Adds or removes the handler for the message event. + /// + [DomName("onmessage")] + public event DomEventHandler Message + { + add { AddEventListener(EventNames.Message, value, false); } + remove { RemoveEventListener(EventNames.Message, value, false); } + } + + /// + /// Posts a message to other channels with the same name and origin. + /// + /// The message to broadcast. + [DomName("postMessage")] + public void PostMessage(Object message) + { + if (_isClosed) + { + return; + } + + BroadcastChannelRegistry.PostMessage(this, message); + } + + /// + /// Closes the channel and releases its registration. + /// + [DomName("close")] + public void Close() + { + if (_isClosed) + { + return; + } + + _isClosed = true; + BroadcastChannelRegistry.Unregister(this); + RemoveEventListeners(); + } + + void IDisposable.Dispose() + { + Close(); + } + + internal String Origin => _origin; + + internal Boolean IsClosed => _isClosed; + + internal void Receive(Object message, BroadcastChannel source) + { + if (_isClosed) + { + return; + } + + var evt = new MessageEvent(EventNames.Message, false, false, message, _origin, String.Empty, source?._window, Array.Empty()); + Dispatch(evt); + } + + private void OnUnload(Object sender, Event ev) + { + Close(); + } + + private static String GetOrigin(IWindow window) + { + var href = window?.Location?.Href; + + if (String.IsNullOrEmpty(href)) + { + return String.Empty; + } + + var url = new Url(href); + + if (url.IsInvalid || url.IsRelative) + { + return String.Empty; + } + + return url.Origin ?? String.Empty; + } + } + + internal static class BroadcastChannelRegistry + { + private static readonly Object SyncRoot = new Object(); + private static readonly Dictionary>> ChannelsByOriginAndName = new Dictionary>>(StringComparer.Ordinal); + + public static void Register(BroadcastChannel channel) + { + if (channel == null) + { + return; + } + + lock (SyncRoot) + { + var key = GetKey(channel.Origin, channel.Name); + + if (!ChannelsByOriginAndName.TryGetValue(key, out var channels)) + { + channels = new List>(); + ChannelsByOriginAndName[key] = channels; + } + + Cleanup(channels); + channels.Add(new WeakReference(channel)); + } + } + + public static void Unregister(BroadcastChannel channel) + { + if (channel == null) + { + return; + } + + lock (SyncRoot) + { + var key = GetKey(channel.Origin, channel.Name); + + if (!ChannelsByOriginAndName.TryGetValue(key, out var channels)) + { + return; + } + + Cleanup(channels); + + for (var i = channels.Count - 1; i >= 0; i--) + { + if (!channels[i].TryGetTarget(out var target) || ReferenceEquals(target, channel)) + { + channels.RemoveAt(i); + } + } + + if (channels.Count == 0) + { + ChannelsByOriginAndName.Remove(key); + } + } + } + + public static void PostMessage(BroadcastChannel source, Object message) + { + if (source == null) + { + return; + } + + List targets = null; + + lock (SyncRoot) + { + var key = GetKey(source.Origin, source.Name); + + if (!ChannelsByOriginAndName.TryGetValue(key, out var channels)) + { + return; + } + + Cleanup(channels); + targets = new List(); + + for (var i = 0; i < channels.Count; i++) + { + if (!channels[i].TryGetTarget(out var target) || target.IsClosed || ReferenceEquals(target, source)) + { + continue; + } + + targets.Add(target); + } + } + + for (var i = 0; i < targets.Count; i++) + { + targets[i].Receive(message, source); + } + } + + private static void Cleanup(List> channels) + { + for (var i = channels.Count - 1; i >= 0; i--) + { + if (!channels[i].TryGetTarget(out var target) || target.IsClosed) + { + channels.RemoveAt(i); + } + } + } + + private static String GetKey(String origin, String name) + { + return (origin ?? String.Empty) + "|" + (name ?? String.Empty); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/Clipboard.cs b/src/AngleSharp.Io/Dom/Clipboard.cs new file mode 100644 index 0000000..9db1126 --- /dev/null +++ b/src/AngleSharp.Io/Dom/Clipboard.cs @@ -0,0 +1,45 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + using System.Threading.Tasks; + + /// + /// Represents the Clipboard API. + /// + [DomName("Clipboard")] + public sealed class Clipboard + { + private readonly IClipboardPlatform _platform; + + /// + /// Creates a new clipboard wrapper using the provided platform implementation. + /// + /// The platform clipboard implementation. + public Clipboard(IClipboardPlatform platform) + { + _platform = platform ?? throw new ArgumentNullException(nameof(platform)); + } + + /// + /// Reads text from the clipboard. + /// + /// The clipboard text. + [DomName("readText")] + public Task ReadText() + { + return _platform.ReadTextAsync(); + } + + /// + /// Writes text to the clipboard. + /// + /// The text to write. + /// The task representing the asynchronous operation. + [DomName("writeText")] + public Task WriteText(String text) + { + return _platform.WriteTextAsync(text ?? String.Empty); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/Geolocation.cs b/src/AngleSharp.Io/Dom/Geolocation.cs new file mode 100644 index 0000000..6b7e1e7 --- /dev/null +++ b/src/AngleSharp.Io/Dom/Geolocation.cs @@ -0,0 +1,228 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + using System.Threading.Tasks; + + /// + /// Represents the Geolocation API. + /// + [DomName("Geolocation")] + public sealed class Geolocation + { + private readonly IGeolocationPlatform _platform; + + /// + /// Creates a new geolocation wrapper using the provided platform implementation. + /// + /// The platform geolocation implementation. + public Geolocation(IGeolocationPlatform platform) + { + _platform = platform ?? throw new ArgumentNullException(nameof(platform)); + } + + /// + /// Gets the current position with default options. + /// + /// The current position. + [DomName("getCurrentPosition")] + public Task GetCurrentPosition() + { + return GetCurrentPosition(new GeolocationOptions()); + } + + /// + /// Gets the current position with the provided options. + /// + /// The position request options. + /// The current position. + [DomName("getCurrentPosition")] + public async Task GetCurrentPosition(GeolocationOptions options) + { + var reading = await _platform.GetCurrentPositionAsync(options ?? new GeolocationOptions()).ConfigureAwait(false); + return GeolocationPosition.FromReading(reading ?? GeolocationReading.Empty); + } + } + + /// + /// Represents geolocation request options. + /// + [DomName("PositionOptions")] + public sealed class GeolocationOptions + { + /// + /// Gets or sets if high accuracy should be preferred. + /// + [DomName("enableHighAccuracy")] + public Boolean EnableHighAccuracy { get; set; } + + /// + /// Gets or sets the timeout in milliseconds. + /// + [DomName("timeout")] + public Int64 Timeout { get; set; } + + /// + /// Gets or sets the maximum age in milliseconds. + /// + [DomName("maximumAge")] + public Int64 MaximumAge { get; set; } + } + + /// + /// Represents a geolocation reading returned by the platform implementation. + /// + public sealed class GeolocationReading + { + /// + /// Gets an empty geolocation reading. + /// + public static GeolocationReading Empty { get; } = new GeolocationReading(); + + /// + /// Gets or sets the latitude. + /// + public Double Latitude { get; set; } + + /// + /// Gets or sets the longitude. + /// + public Double Longitude { get; set; } + + /// + /// Gets or sets the accuracy in meters. + /// + public Double Accuracy { get; set; } + + /// + /// Gets or sets the altitude in meters. + /// + public Double? Altitude { get; set; } + + /// + /// Gets or sets the altitude accuracy in meters. + /// + public Double? AltitudeAccuracy { get; set; } + + /// + /// Gets or sets the heading in degrees. + /// + public Double? Heading { get; set; } + + /// + /// Gets or sets the speed in meters per second. + /// + public Double? Speed { get; set; } + + /// + /// Gets or sets the timestamp in milliseconds since Unix epoch. + /// + public Int64 Timestamp { get; set; } + } + + /// + /// Represents a geolocation position. + /// + [DomName("GeolocationPosition")] + public sealed class GeolocationPosition + { + internal GeolocationPosition(GeolocationCoordinates coordinates, Int64 timestamp) + { + Coordinates = coordinates; + Timestamp = timestamp; + } + + /// + /// Gets the coordinates. + /// + [DomName("coords")] + public GeolocationCoordinates Coordinates { get; } + + /// + /// Gets the timestamp in milliseconds since Unix epoch. + /// + [DomName("timestamp")] + public Int64 Timestamp { get; } + + internal static GeolocationPosition FromReading(GeolocationReading reading) + { + var timestamp = reading.Timestamp; + + if (timestamp <= 0) + { + timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + + var coordinates = new GeolocationCoordinates( + reading.Latitude, + reading.Longitude, + reading.Accuracy, + reading.Altitude, + reading.AltitudeAccuracy, + reading.Heading, + reading.Speed); + + return new GeolocationPosition(coordinates, timestamp); + } + } + + /// + /// Represents geolocation coordinates. + /// + [DomName("GeolocationCoordinates")] + public sealed class GeolocationCoordinates + { + internal GeolocationCoordinates(Double latitude, Double longitude, Double accuracy, Double? altitude, Double? altitudeAccuracy, Double? heading, Double? speed) + { + Latitude = latitude; + Longitude = longitude; + Accuracy = accuracy; + Altitude = altitude; + AltitudeAccuracy = altitudeAccuracy; + Heading = heading; + Speed = speed; + } + + /// + /// Gets the latitude. + /// + [DomName("latitude")] + public Double Latitude { get; } + + /// + /// Gets the longitude. + /// + [DomName("longitude")] + public Double Longitude { get; } + + /// + /// Gets the accuracy in meters. + /// + [DomName("accuracy")] + public Double Accuracy { get; } + + /// + /// Gets the altitude in meters. + /// + [DomName("altitude")] + public Double? Altitude { get; } + + /// + /// Gets the altitude accuracy in meters. + /// + [DomName("altitudeAccuracy")] + public Double? AltitudeAccuracy { get; } + + /// + /// Gets the heading in degrees. + /// + [DomName("heading")] + public Double? Heading { get; } + + /// + /// Gets the speed in meters per second. + /// + [DomName("speed")] + public Double? Speed { get; } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/InputFile.cs b/src/AngleSharp.Io/Dom/InputFile.cs index 996710b..9fb7c74 100644 --- a/src/AngleSharp.Io/Dom/InputFile.cs +++ b/src/AngleSharp.Io/Dom/InputFile.cs @@ -95,9 +95,24 @@ public IBlob Slice(Int32 start = 0, Int32 end = Int32.MaxValue, String contentTy { var ms = new MemoryStream(); _content.Position = start; - var buffer = new Byte[Math.Max(0, Math.Min(end, _content.Length) - start)]; - _content.Read(buffer, 0, buffer.Length); - ms.Write(buffer, 0, buffer.Length); + var length = Math.Max(0, (Int32)Math.Min((Int64)end, _content.Length) - start); + var buffer = new Byte[length]; + var read = 0; + + while (read < length) + { + var count = _content.Read(buffer, read, length - read); + + if (count == 0) + { + break; + } + + read += count; + } + + ms.Write(buffer, 0, read); + ms.Position = 0; _content.Position = 0; return new InputFile(_fileName, _type, ms); } diff --git a/src/AngleSharp.Io/Dom/LockManager.cs b/src/AngleSharp.Io/Dom/LockManager.cs new file mode 100644 index 0000000..06405b0 --- /dev/null +++ b/src/AngleSharp.Io/Dom/LockManager.cs @@ -0,0 +1,149 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp; + using AngleSharp.Attributes; + using AngleSharp.Dom; + using System; + using System.Collections.Concurrent; + using System.Globalization; + using System.Runtime.CompilerServices; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Represents the Web Locks API. + /// + [DomName("LockManager")] + public sealed class LockManager + { + private readonly String _scope; + + internal LockManager(String scope) + { + _scope = scope ?? String.Empty; + } + + /// + /// Requests a lock with the given name. + /// + /// The lock name. + /// The callback to run while the lock is held. + /// The task representing the lock request. + [DomName("request")] + public Task Request(String name, Func callback) + { + return RequestInternal(name, async lockInfo => + { + await callback.Invoke(lockInfo).ConfigureAwait(false); + return true; + }); + } + + /// + /// Requests a lock with the given name. + /// + /// The callback result type. + /// The lock name. + /// The callback to run while the lock is held. + /// The task representing the lock request. + [DomName("request")] + public async Task Request(String name, Func> callback) + { + return await RequestInternal(name, callback).ConfigureAwait(false); + } + + internal static String GetScopeKey(IBrowsingContext context) + { + if (context == null) + { + return String.Empty; + } + + var window = context.Active?.DefaultView; + var origin = GetOrigin(window); + + if (!String.IsNullOrEmpty(origin)) + { + return origin; + } + + return "context:" + RuntimeHelpers.GetHashCode(context).ToString(CultureInfo.InvariantCulture); + } + + private async Task RequestInternal(String name, Func> callback) + { + if (callback == null) + { + throw new ArgumentNullException(nameof(callback)); + } + + var scope = _scope; + var normalizedName = String.IsNullOrEmpty(name) ? String.Empty : name; + var gate = LockRegistry.GetGate(scope, normalizedName); + + await gate.WaitAsync().ConfigureAwait(false); + + try + { + return await callback.Invoke(new WebLock(scope, normalizedName)).ConfigureAwait(false); + } + finally + { + gate.Release(); + } + } + + private static String GetOrigin(IWindow window) + { + var href = window?.Location?.Href; + + if (String.IsNullOrEmpty(href)) + { + return String.Empty; + } + + var url = new Url(href); + + if (url.IsInvalid || url.IsRelative) + { + return String.Empty; + } + + return url.Origin ?? String.Empty; + } + } + + /// + /// Represents a granted Web Lock. + /// + [DomName("Lock")] + public sealed class WebLock + { + private readonly String _scope; + + internal WebLock(String scope, String name) + { + _scope = scope ?? String.Empty; + Name = name ?? String.Empty; + } + + /// + /// Gets the lock name. + /// + [DomName("name")] + public String Name { get; } + + internal String Scope => _scope; + } + + internal static class LockRegistry + { + private static readonly ConcurrentDictionary> Scopes = new ConcurrentDictionary>(StringComparer.Ordinal); + + public static SemaphoreSlim GetGate(String scope, String name) + { + var locks = Scopes.GetOrAdd(scope ?? String.Empty, _ => new ConcurrentDictionary(StringComparer.Ordinal)); + return locks.GetOrAdd(name ?? String.Empty, _ => new SemaphoreSlim(1, 1)); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/Navigator.cs b/src/AngleSharp.Io/Dom/Navigator.cs new file mode 100644 index 0000000..27d899e --- /dev/null +++ b/src/AngleSharp.Io/Dom/Navigator.cs @@ -0,0 +1,53 @@ +namespace AngleSharp.Io.Dom; + +using AngleSharp.Browser.Dom; +using System; +using System.Net.NetworkInformation; + +sealed class Navigator : INavigator +{ + private readonly IBrowsingContext _context; + private readonly String _userAgent; + + public Navigator(IBrowsingContext context, NavigatorOptions options) + { + _context = context; + _userAgent = options.UserAgent; + } + + public IBrowsingContext Context => _context; + + public String Name => "Netscape"; + + public String Platform => String.Empty; + + public String UserAgent => _userAgent; + + public String Version => "1.0.0"; + + public Boolean IsContentHandlerRegistered(String mimeType, String url) => false; + + public Boolean IsProtocolHandlerRegistered(String scheme, String url) => false; + + public void RegisterContentHandler(String mimeType, String url, String title) + { + } + + public void RegisterProtocolHandler(String scheme, String url, String title) + { + } + + public void UnregisterContentHandler(String mimeType, String url) + { + } + + public void UnregisterProtocolHandler(String scheme, String url) + { + } + + public void WaitForStorageUpdates() + { + } + + public Boolean IsOnline => NetworkInterface.GetIsNetworkAvailable(); +} diff --git a/src/AngleSharp.Io/Dom/NavigatorExtensions.cs b/src/AngleSharp.Io/Dom/NavigatorExtensions.cs new file mode 100644 index 0000000..3b83e14 --- /dev/null +++ b/src/AngleSharp.Io/Dom/NavigatorExtensions.cs @@ -0,0 +1,60 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Browser.Dom; + using AngleSharp.Attributes; + using System; + + /// + /// Defines a set of extensions for the navigator object. + /// + [DomExposed("Navigator")] + public static class NavigatorExtensions + { + /// + /// Gets the clipboard object. + /// + [DomName("clipboard")] + [DomAccessor(Accessors.Getter)] + public static Clipboard Clipboard(this INavigator navigator) + { + var context = GetContext(navigator); + var factory = context?.GetService(); + return factory?.GetClipboard(navigator); + } + + /// + /// Gets the geolocation object. + /// + [DomName("geolocation")] + [DomAccessor(Accessors.Getter)] + public static Geolocation Geolocation(this INavigator navigator) + { + var context = GetContext(navigator); + var factory = context?.GetService(); + return factory?.GetGeolocation(navigator); + } + + /// + /// Gets the locks object. + /// + [DomName("locks")] + [DomAccessor(Accessors.Getter)] + public static LockManager Locks(this INavigator navigator) + { + var context = GetContext(navigator); + + if (context == null) + { + return null; + } + + var scope = LockManager.GetScopeKey(context); + return String.IsNullOrEmpty(scope) ? null : new LockManager(scope); + } + + private static AngleSharp.IBrowsingContext GetContext(INavigator navigator) + { + return (navigator as Navigator)?.Context; + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/RequesterState.cs b/src/AngleSharp.Io/Dom/RequesterState.cs new file mode 100644 index 0000000..d6c62a4 --- /dev/null +++ b/src/AngleSharp.Io/Dom/RequesterState.cs @@ -0,0 +1,36 @@ +namespace AngleSharp.Io.Dom; + +using AngleSharp.Attributes; + +/// +/// Defines the states of the requester. +/// +[DomName("XmlHttpRequest")] +public enum RequesterState : ushort +{ + /// + /// Nothing has been sent yet. + /// + [DomName("UNSENT")] + Unsent = 0, + /// + /// The communication channel is open. + /// + [DomName("OPENED")] + Opened = 1, + /// + /// The response headers have been received. + /// + [DomName("HEADERS_RECEIVED")] + HeadersReceived = 2, + /// + /// The body is still incoming. + /// + [DomName("LOADING")] + Loading = 3, + /// + /// The response has been received. + /// + [DomName("DONE")] + Done = 4 +} diff --git a/src/AngleSharp.Io/Dom/WindowExtensions.cs b/src/AngleSharp.Io/Dom/WindowExtensions.cs new file mode 100644 index 0000000..a29f8d6 --- /dev/null +++ b/src/AngleSharp.Io/Dom/WindowExtensions.cs @@ -0,0 +1,270 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp; + using AngleSharp.Html; + using AngleSharp.Io; + using AngleSharp.Dom; + using AngleSharp.Attributes; + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading.Tasks; + + /// + /// Defines a set of extensions for the window object. + /// + [DomExposed("Window")] + public static class WindowExtensions + { + /// + /// Gets the localStorage object. + /// + [DomName("localStorage")] + [DomAccessor(Accessors.Getter)] + public static ILocalStorage LocalStorage(this IWindow window) + { + var factory = window?.Document?.Context?.GetService(); + return factory?.GetStorages(window)?.Local; + } + + /// + /// Gets the sessionStorage object. + /// + [DomName("sessionStorage")] + [DomAccessor(Accessors.Getter)] + public static ISessionStorage SessionStorage(this IWindow window) + { + var factory = window?.Document?.Context?.GetService(); + return factory?.GetStorages(window)?.Session; + } + + /// + /// Gets the indexedDB object. + /// + [DomName("indexedDB")] + [DomAccessor(Accessors.Getter)] + public static IIndexedDbFactory IndexedDb(this IWindow window) + { + var factory = window?.Document?.Context?.GetService(); + return factory?.GetIndexedDb(window); + } + + /// + /// Gets the caches object. + /// + [DomName("caches")] + [DomAccessor(Accessors.Getter)] + public static ICacheStorage Caches(this IWindow window) + { + var factory = window?.Document?.Context?.GetService(); + return factory?.GetCaches(window); + } + + /// + /// Performs a request using the configured document loader. + /// + /// The originating window. + /// The request URL. + /// The fetched response. + [DomName("fetch")] + public static Task Fetch(this IWindow window, String url) + { + return Fetch(window, url, null); + } + + /// + /// Performs a request using the configured document loader. + /// + /// The originating window. + /// The request URL. + /// The optional request options. + /// The fetched response. + [DomName("fetch")] + public static async Task Fetch(this IWindow window, String url, FetchOptions options) + { + if (window == null) + { + throw new ArgumentNullException(nameof(window)); + } + + if (String.IsNullOrEmpty(url)) + { + throw new ArgumentNullException(nameof(url)); + } + + var context = window.Document?.Context; + var loader = context?.GetService(); + + if (loader == null) + { + throw new InvalidOperationException("No document loader service is registered."); + } + + var absoluteUrl = new Url(new Url(window.Document.Url), url); + var request = CreateRequest(window, absoluteUrl, options); + var download = loader.FetchAsync(request); + + return await download.Task.ConfigureAwait(false); + } + + private static DocumentRequest CreateRequest(IWindow window, Url url, FetchOptions options) + { + var requestOptions = options ?? new FetchOptions(); + var serializedBody = SerializeBody(requestOptions.Body); + var request = new DocumentRequest(url) + { + Body = serializedBody.Content, + Method = ParseMethod(requestOptions.Method), + MimeType = requestOptions.MimeType, + Referer = window.Document?.DocumentUri, + }; + + if (requestOptions.Headers != null) + { + foreach (var pair in requestOptions.Headers) + { + request.Headers[pair.Key] = pair.Value; + } + } + + if (!String.IsNullOrEmpty(serializedBody.ContentType) && !HasHeaderValue(request.Headers, HeaderNames.ContentType)) + { + request.Headers[HeaderNames.ContentType] = serializedBody.ContentType; + } + + return request; + } + + private static HttpMethod ParseMethod(String method) + { + if (String.IsNullOrEmpty(method)) + { + return HttpMethod.Get; + } + + return Enum.TryParse(method, true, out HttpMethod parsed) ? parsed : HttpMethod.Get; + } + + private static SerializedBody SerializeBody(Object body) + { + if (body == null) + { + return SerializedBody.Empty; + } + + switch (body) + { + case Stream stream: + if (stream.CanSeek) + { + stream.Seek(0, SeekOrigin.Begin); + } + + return new SerializedBody(stream); + case Byte[] bytes: + return new SerializedBody(new MemoryStream(bytes)); + case ArraySegment segment when segment.Array != null: + return new SerializedBody(new MemoryStream(segment.Array, segment.Offset, segment.Count, false)); + case FormDataSet formDataSet: + var formDataBody = formDataSet.AsMultipart(null, Encoding.UTF8); + var formDataType = String.Concat("multipart/form-data; boundary=", formDataSet.Boundary); + return new SerializedBody(formDataBody, formDataType); + case UrlSearchParams searchParams: + var query = searchParams.ToString(); + var queryBytes = Encoding.UTF8.GetBytes(query); + return new SerializedBody(new MemoryStream(queryBytes), "application/x-www-form-urlencoded; charset=UTF-8"); + case IBlob blob: + var blobBody = blob.Body; + + if (blobBody != null) + { + if (blobBody.CanSeek) + { + blobBody.Seek(0, SeekOrigin.Begin); + } + + return new SerializedBody(blobBody, blob.Type); + } + + break; + } + + var content = body.ToString(); + var contentBytes = Encoding.UTF8.GetBytes(content); + return new SerializedBody(new MemoryStream(contentBytes)); + } + + private static Boolean ContainsHeader(Dictionary headers, String name) + { + foreach (var pair in headers) + { + if (String.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static Boolean HasHeaderValue(Dictionary headers, String name) + { + foreach (var pair in headers) + { + if (String.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + { + return !String.IsNullOrEmpty(pair.Value); + } + } + + return false; + } + + private readonly struct SerializedBody + { + public static readonly SerializedBody Empty = new SerializedBody(Stream.Null); + + public SerializedBody(Stream content, String contentType = null) + { + Content = content ?? Stream.Null; + ContentType = contentType; + } + + public Stream Content { get; } + + public String ContentType { get; } + } + } + + /// + /// Represents request options for fetch. + /// + [DomName("RequestInit")] + public sealed class FetchOptions + { + /// + /// Gets or sets the HTTP method. + /// + [DomName("method")] + public String Method { get; set; } + + /// + /// Gets or sets custom request headers. + /// + [DomName("headers")] + public Dictionary Headers { get; set; } + + /// + /// Gets or sets the request body. + /// + [DomName("body")] + public Object Body { get; set; } + + /// + /// Gets or sets an explicit mime type for the request. + /// + [DomName("mimeType")] + public String MimeType { get; set; } + } +} diff --git a/src/AngleSharp.Io/Dom/XmlHttpRequest.cs b/src/AngleSharp.Io/Dom/XmlHttpRequest.cs new file mode 100644 index 0000000..574c990 --- /dev/null +++ b/src/AngleSharp.Io/Dom/XmlHttpRequest.cs @@ -0,0 +1,472 @@ +namespace AngleSharp.Io.Dom; + +using AngleSharp.Attributes; +using AngleSharp.Browser; +using AngleSharp.Dom; +using AngleSharp.Dom.Events; +using AngleSharp.Html; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +/// +/// Defines the XHR. For more information see: +/// https://xhr.spec.whatwg.org/#interface-xmlhttprequest +/// +[DomName("XMLHttpRequest")] +[DomExposed("Window")] +[DomExposed("DedicatedWorker")] +[DomExposed("SharedWorker")] +public sealed class XmlHttpRequest : XmlHttpRequestEventTarget +{ + #region Fields + + private const Int32 BufferSize = 16384; + + private readonly Dictionary _headers; + private readonly IWindow _window; + + private RequesterState _readyState; + private Int32 _timeout; + private Boolean _credentials; + private HttpMethod _method; + private Url _url; + private Boolean _async; + private String _mime; + private HttpStatusCode _responseStatus; + private String _responseUrl; + private String _responseText; + private IDownload _download; + + #endregion + + #region ctor + + /// + /// Creates a new XHR. + /// + [DomConstructor] + public XmlHttpRequest(IWindow window) + { + _window = window; + _async = true; + _method = HttpMethod.Get; + _headers = new Dictionary(); + _url = null; + _mime = null; + _responseUrl = String.Empty; + _responseText = String.Empty; + _readyState = RequesterState.Unsent; + _responseStatus = (HttpStatusCode)0; + _credentials = false; + _timeout = 45000; + } + + #endregion + + #region Properties + + /// + /// Gets if response headers are accessible. + /// + public Boolean HasResponseHeaders => _readyState == RequesterState.Loading || _readyState == RequesterState.Done; + + /// + /// Adds or removes the handler for the readystatechange event. + /// + [DomName("onreadystatechange")] + public event DomEventHandler ReadyStateChanged + { + add { AddEventListener(ReadyStateChangeEvent, value, false); } + remove { RemoveEventListener(ReadyStateChangeEvent, value, false); } + } + + /// + /// Gets the current ready state. + /// + [DomName("readyState")] + public RequesterState ReadyState + { + get => _readyState; + private set + { + _readyState = value; + Fire(ReadyStateChangeEvent); + } + } + + /// + /// Gets or sets the timeout of the request in milliseconds. + /// + [DomName("timeout")] + public Int32 Timeout + { + get => _timeout; + set => _timeout = value; + } + + /// + /// Gets the associated upload process, if any. + /// + [DomName("upload")] + public XmlHttpRequestUpload Upload => null; + + /// + /// Gets or sets if credentials should be used for the request. + /// + [DomName("withCredentials")] + public Boolean WithCredentials + { + get => _credentials; + set => _credentials = value; + } + + /// + /// Gets the determined response type. + /// + [DomName("responseType")] + public XmlHttpRequestResponseType ResponseType => XmlHttpRequestResponseType.None; + + /// + /// Gets the url of the response. + /// + [DomName("responseURL")] + public String ResponseUrl => _responseUrl; + + /// + /// Gets the status code of the response. + /// + [DomName("status")] + public Int32 StatusCode => (Int32)_responseStatus; + + /// + /// Gets the status text of the response. + /// + [DomName("statusText")] + public String StatusText => StatusCode != 0 ? _responseStatus.ToString() : String.Empty; + + /// + /// Gets the resulting response object. + /// + [DomName("response")] + public Object Response => null; + + /// + /// Gets the body text of the response. + /// + [DomName("responseText")] + public String ResponseText => _responseText; + + /// + /// Gets the XML document of the response, if any. + /// + [DomName("responseXML")] + public IDocument ResponseXml => null; + + #endregion + + #region Methods + + /// + /// Aborts the request. + /// + [DomName("abort")] + public void Abort() + { + if (_readyState == RequesterState.Loading) + { + _download.Cancel(); + Fire(AbortEvent); + } + } + + /// + /// Opens a new request with the provided method and URL. + /// + /// The method to use. + /// The URL to send to request to. + /// Should the request be send async? + /// Should a username be used? + /// Should a password be used? + [DomName("open")] + public void Open(String method, String url, Boolean async = true, String username = null, String password = null) + { + if (_readyState == RequesterState.Unsent) + { + ReadyState = RequesterState.Opened; + + if (!Enum.TryParse(method, true, out _method)) + { + _method = HttpMethod.Get; + } + + _url = new Url(new Url(_window.Document.Url), url); + _async = async; + _url.UserName = username; + _url.Password = password; + } + } + + /// + /// Sends the created request with the potentially provided object. + /// + /// The body to send (e.g., for forms POST). + [DomName("send")] + public void Send(Object body = null) + { + if (_readyState == RequesterState.Opened) + { + var requestBody = Serialize(body); + + if (!String.IsNullOrEmpty(requestBody.ContentType) && !ContainsHeader(_headers, HeaderNames.ContentType)) + { + _headers[HeaderNames.ContentType] = requestBody.ContentType; + } + + var loader = GetLoader(); + + if (loader != null) + { + var request = new DocumentRequest(_url) + { + Body = requestBody.Content, + Method = _method, + MimeType = default, + Referer = _window.Document.DocumentUri, + }; + + foreach (var header in _headers) + { + request.Headers[header.Key] = header.Value; + } + + _headers.Clear(); + + Fire(LoadStartEvent); + ReadyState = RequesterState.HeadersReceived; + var connection = Receive(loader, request); + + if (!_async) + { + connection.Wait(); + } + } + } + } + + /// + /// Sets the request header. + /// + /// The name of the field. + /// The value of the field. + [DomName("setRequestHeader")] + public void SetRequestHeader(String name, String value) + { + if (_readyState == RequesterState.Opened) + { + _headers[name] = value; + } + } + + /// + /// Gets the response header. + /// + /// The name of the field. + /// The value of the field. + [DomName("getResponseHeader")] + public String GetResponseHeader(String name) + { + if (HasResponseHeaders && _headers.TryGetValue(name, out string value)) + { + return value; + } + + return String.Empty; + } + + /// + /// Gets all response headers. + /// + /// The name and values. + [DomName("getAllResponseHeaders")] + public String GetAllResponseHeaders() + { + if (HasResponseHeaders) + { + var headers = _headers; + var lines = new String[headers.Count]; + var index = 0; + + foreach (var header in headers) + { + lines[index] = String.Concat(header.Key, ": ", header.Value); + index++; + } + + return String.Join(Environment.NewLine, lines); + } + + return String.Empty; + } + + /// + /// Overrides the returned mime-type to force a specific mode. + /// + /// The mime-type to use. + [DomName("overrideMimeType")] + public void OverrideMimeType(String mime) + { + if (_readyState == RequesterState.Opened) + { + _mime = mime; + } + } + + #endregion + + #region Helpers + + private async Task Receive(IDocumentLoader loader, DocumentRequest request) + { + var eventName = ErrorEvent; + + try + { + _download = loader.FetchAsync(request); + + using (var response = await _download.Task.ConfigureAwait(false)) + { + if (response != null) + { + eventName = LoadEvent; + + foreach (var header in response.Headers) + { + _headers[header.Key] = header.Value; + } + + _responseUrl = response.Address.Href; + _responseStatus = response.StatusCode; + ReadyState = RequesterState.Loading; + + using (var ms = new MemoryStream()) + { + await response.Content.CopyToAsync(ms, BufferSize).ConfigureAwait(false); + ms.Seek(0, SeekOrigin.Begin); + + using (var reader = new StreamReader(ms)) + { + _responseText = reader.ReadToEnd(); + } + } + + Fire(LoadEndEvent); + } + + ReadyState = RequesterState.Done; + Fire(eventName); + } + } + catch (TaskCanceledException) + { + ReadyState = RequesterState.Done; + Fire(TimeoutEvent); + } + catch (Exception) + { + ReadyState = RequesterState.Done; + Fire(ErrorEvent); + } + } + + private IEventLoop GetEventLoop() => + _window?.Document?.Context.GetService(); + + private IDocumentLoader GetLoader() => + _window?.Document?.Context.GetService(); + + private static SerializedBody Serialize(Object body) + { + if (body == null) + { + return SerializedBody.Empty; + } + + switch (body) + { + case Stream stream: + if (stream.CanSeek) + { + stream.Seek(0, SeekOrigin.Begin); + } + + return new SerializedBody(stream); + case Byte[] bytes: + return new SerializedBody(new MemoryStream(bytes)); + case ArraySegment segment when segment.Array != null: + return new SerializedBody(new MemoryStream(segment.Array, segment.Offset, segment.Count, false)); + case FormDataSet formDataSet: + var formDataBody = formDataSet.AsMultipart(null, Encoding.UTF8); + var formDataType = String.Concat("multipart/form-data; boundary=", formDataSet.Boundary); + return new SerializedBody(formDataBody, formDataType); + case UrlSearchParams searchParams: + var query = searchParams.ToString(); + var queryBytes = Encoding.UTF8.GetBytes(query); + return new SerializedBody(new MemoryStream(queryBytes), "application/x-www-form-urlencoded; charset=UTF-8"); + case IBlob blob: + var blobBody = blob.Body; + + if (blobBody != null) + { + if (blobBody.CanSeek) + { + blobBody.Seek(0, SeekOrigin.Begin); + } + + return new SerializedBody(blobBody, blob.Type); + } + + break; + } + + var content = body.ToString(); + var contentBytes = Encoding.UTF8.GetBytes(content); + return new SerializedBody(new MemoryStream(contentBytes)); + } + + private static Boolean ContainsHeader(Dictionary headers, String name) + { + foreach (var pair in headers) + { + if (String.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private readonly struct SerializedBody + { + public static readonly SerializedBody Empty = new SerializedBody(Stream.Null); + + public SerializedBody(Stream content, String contentType = null) + { + Content = content ?? Stream.Null; + ContentType = contentType; + } + + public Stream Content { get; } + + public String ContentType { get; } + } + + private void Fire(String eventName) => + GetEventLoop().Enqueue(() => Dispatch(new Event(eventName))); + + #endregion +} diff --git a/src/AngleSharp.Io/Dom/XmlHttpRequestEventTarget.cs b/src/AngleSharp.Io/Dom/XmlHttpRequestEventTarget.cs new file mode 100644 index 0000000..4337bfc --- /dev/null +++ b/src/AngleSharp.Io/Dom/XmlHttpRequestEventTarget.cs @@ -0,0 +1,102 @@ +namespace AngleSharp.Io.Dom; + +using AngleSharp.Attributes; +using AngleSharp.Dom; +using System; + +/// +/// Represents the basis for the XHR. +/// +[DomName("XMLHttpRequestEventTarget")] +[DomExposed("Window")] +[DomExposed("DedicatedWorker")] +[DomExposed("SharedWorker")] +public class XmlHttpRequestEventTarget : EventTarget +{ + #region Event Names + + internal static readonly String LoadStartEvent = "loadstart"; + internal static readonly String LoadEndEvent = "loadend"; + internal static readonly String ProgressEvent = "progress"; + internal static readonly String AbortEvent = "abort"; + internal static readonly String ErrorEvent = "error"; + internal static readonly String LoadEvent = "load"; + internal static readonly String TimeoutEvent = "timeout"; + internal static readonly String ReadyStateChangeEvent = "readystatechange"; + + #endregion + + #region Event Handlers + + /// + /// Adds or removes the handler for the loadstart event. + /// + [DomName("onloadstart")] + public event DomEventHandler Start + { + add { AddEventListener(LoadStartEvent, value, false); } + remove { RemoveEventListener(LoadStartEvent, value, false); } + } + + /// + /// Adds or removes the handler for the progress event. + /// + [DomName("onprogress")] + public event DomEventHandler Progress + { + add { AddEventListener(ProgressEvent, value, false); } + remove { RemoveEventListener(ProgressEvent, value, false); } + } + + /// + /// Adds or removes the handler for the abort event. + /// + [DomName("onabort")] + public event DomEventHandler Aborted + { + add { AddEventListener(AbortEvent, value, false); } + remove { RemoveEventListener(AbortEvent, value, false); } + } + + /// + /// Adds or removes the handler for the error event. + /// + [DomName("onerror")] + public event DomEventHandler Error + { + add { AddEventListener(ErrorEvent, value, false); } + remove { RemoveEventListener(ErrorEvent, value, false); } + } + + /// + /// Adds or removes the handler for the load event. + /// + [DomName("onload")] + public event DomEventHandler Loaded + { + add { AddEventListener(LoadEvent, value, false); } + remove { RemoveEventListener(LoadEvent, value, false); } + } + + /// + /// Adds or removes the handler for the timeout event. + /// + [DomName("ontimeout")] + public event DomEventHandler Timedout + { + add { AddEventListener(TimeoutEvent, value, false); } + remove { RemoveEventListener(TimeoutEvent, value, false); } + } + + /// + /// Adds or removes the handler for the loadend event. + /// + [DomName("onloadend")] + public event DomEventHandler End + { + add { AddEventListener(LoadEndEvent, value, false); } + remove { RemoveEventListener(LoadEndEvent, value, false); } + } + + #endregion +} diff --git a/src/AngleSharp.Io/Dom/XmlHttpRequestResponseType.cs b/src/AngleSharp.Io/Dom/XmlHttpRequestResponseType.cs new file mode 100644 index 0000000..fbadf40 --- /dev/null +++ b/src/AngleSharp.Io/Dom/XmlHttpRequestResponseType.cs @@ -0,0 +1,42 @@ +namespace AngleSharp.Io.Dom; + +using AngleSharp.Attributes; + +/// +/// The various response type options. +/// +[DomLiterals] +[DomName("XMLHttpRequestResponseType")] +public enum XmlHttpRequestResponseType +{ + /// + /// No response given. + /// + [DomName("")] + None, + /// + /// A plain array buffer. + /// + [DomName("arraybuffer")] + ArrayBuffer, + /// + /// Some binary large object. + /// + [DomName("blob")] + Blob, + /// + /// An (XML) document. + /// + [DomName("document")] + Document, + /// + /// A JSON object. + /// + [DomName("json")] + Json, + /// + /// Plain text. + /// + [DomName("text")] + Text +} diff --git a/src/AngleSharp.Io/Dom/XmlHttpRequestUpload.cs b/src/AngleSharp.Io/Dom/XmlHttpRequestUpload.cs new file mode 100644 index 0000000..c4c9618 --- /dev/null +++ b/src/AngleSharp.Io/Dom/XmlHttpRequestUpload.cs @@ -0,0 +1,11 @@ +namespace AngleSharp.Io.Dom; + +using AngleSharp.Attributes; + +/// +/// Specialization for requesting upload information. +/// +[DomName("XMLHttpRequestUpload")] +public sealed class XmlHttpRequestUpload : XmlHttpRequestEventTarget +{ +} diff --git a/src/AngleSharp.Io/Geolocation/GeolocationProviderFactory.cs b/src/AngleSharp.Io/Geolocation/GeolocationProviderFactory.cs new file mode 100644 index 0000000..ad4b9aa --- /dev/null +++ b/src/AngleSharp.Io/Geolocation/GeolocationProviderFactory.cs @@ -0,0 +1,37 @@ +namespace AngleSharp.Io.GeolocationApi +{ + using AngleSharp.Browser.Dom; + using AngleSharp.Io.Dom; + using System; + using System.Runtime.CompilerServices; + + /// + /// Represents the default Geolocation provider factory. + /// + public sealed class GeolocationProviderFactory : IGeolocationProviderFactory + { + private readonly IGeolocationPlatform _platform; + private readonly ConditionalWeakTable _geolocations; + + /// + /// Creates a new Geolocation provider factory. + /// + /// The platform geolocation implementation. + public GeolocationProviderFactory(IGeolocationPlatform platform) + { + _platform = platform ?? throw new ArgumentNullException(nameof(platform)); + _geolocations = new ConditionalWeakTable(); + } + + /// + public Geolocation GetGeolocation(INavigator navigator) + { + if (navigator == null) + { + return null; + } + + return _geolocations.GetValue(navigator, _ => new Geolocation(_platform)); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs b/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs new file mode 100644 index 0000000..521dfbc --- /dev/null +++ b/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs @@ -0,0 +1,1450 @@ +namespace AngleSharp.Io.IndexedDb +{ + using AngleSharp.Dom; + using AngleSharp.Io.Dom; + using AngleSharp.Io.Storage; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + + /// + /// Represents the default IndexedDB provider factory with origin-dependent views. + /// + public class IndexedDbProviderFactory : IIndexedDbProviderFactory + { + private readonly ConditionalWeakTable _factories; + private readonly Dictionary _databasesByOriginAndName; + + /// + /// Creates a new IndexedDB provider factory with temporary local storage and session storage enabled. + /// + /// The new IndexedDB provider factory. + public static IIndexedDbProviderFactory CreateTemporary() + { + var factory = new IndexedDbProviderFactory(); + return factory; + } + + /// + /// Creates a new IndexedDB provider factory. + /// + public IndexedDbProviderFactory() + { + _factories = new ConditionalWeakTable(); + _databasesByOriginAndName = new Dictionary(StringComparer.Ordinal); + } + + /// + public IIndexedDbFactory GetIndexedDb(IWindow window) + { + if (window == null) + { + return null; + } + + return _factories.GetValue(window, CreateFactory); + } + + private IndexedDbFactory CreateFactory(IWindow window) + { + return new IndexedDbFactory( + () => GetOrigin(window), + (origin, name) => GetOrCreateDatabase(origin, name), + (origin, name) => DeleteDatabase(origin, name)); + } + + private IndexedDbDatabaseBucket GetOrCreateDatabase(String origin, String name) + { + var key = GetDatabaseKey(origin, name); + + if (!_databasesByOriginAndName.TryGetValue(key, out var database)) + { + database = new IndexedDbDatabaseBucket(name); + _databasesByOriginAndName[key] = database; + } + + return database; + } + + private Boolean DeleteDatabase(String origin, String name) + { + return _databasesByOriginAndName.Remove(GetDatabaseKey(origin, name)); + } + + private static String GetDatabaseKey(String origin, String name) + { + return (origin ?? String.Empty) + "|" + (name ?? String.Empty); + } + + private static String GetOrigin(IWindow window) + { + var href = window?.Location?.Href; + + if (String.IsNullOrEmpty(href)) + { + return String.Empty; + } + + var url = new Url(href); + + if (url.IsInvalid || url.IsRelative) + { + return String.Empty; + } + + return url.Origin ?? String.Empty; + } + } + + internal sealed class IndexedDbFactory : IIndexedDbFactory + { + private readonly Func _getOrigin; + private readonly Func _getDatabase; + private readonly Func _deleteDatabase; + + public IndexedDbFactory(Func getOrigin, Func getDatabase, Func deleteDatabase) + { + _getOrigin = getOrigin ?? throw new ArgumentNullException(nameof(getOrigin)); + _getDatabase = getDatabase ?? throw new ArgumentNullException(nameof(getDatabase)); + _deleteDatabase = deleteDatabase ?? throw new ArgumentNullException(nameof(deleteDatabase)); + } + + public IIndexedDbDatabase Open(String name) + { + name = name ?? String.Empty; + var origin = _getOrigin.Invoke(); + var database = _getDatabase.Invoke(origin, name); + + if (database.Version <= 0) + { + database.Version = 1; + } + + var connection = new IndexedDbDatabase(name, database); + database.OpenConnection(connection); + return connection; + } + + public IIndexedDbDatabase Open(String name, Int64 version) + { + return Open(name, version, null); + } + + public IIndexedDbDatabase Open(String name, Int64 version, Action upgrade) + { + if (version <= 0) + { + throw new ArgumentOutOfRangeException(nameof(version)); + } + + name = name ?? String.Empty; + var origin = _getOrigin.Invoke(); + var database = _getDatabase.Invoke(origin, name); + var currentVersion = database.Version; + + if (currentVersion > version) + { + throw new InvalidOperationException("Cannot open IndexedDB with a lower version than the current one."); + } + + if (currentVersion < version) + { + if (database.OpenConnections > 0) + { + database.NotifyVersionChangeRequested(currentVersion, version); + throw new IndexedDbBlockedException("Cannot upgrade IndexedDB while open connections exist. Close existing database connections first."); + } + + var tx = new IndexedDbUpgradeTransaction(database, currentVersion, version); + upgrade?.Invoke(tx); + tx.Commit(); + } + else if (currentVersion == 0) + { + database.Version = version; + } + + var connection = new IndexedDbDatabase(name, database); + database.OpenConnection(connection); + return connection; + } + + public IIndexedDbRequest OpenRequest(String name) + { + return IndexedDbRequest.Run(() => Open(name)); + } + + public IIndexedDbRequest OpenRequest(String name, Int64 version) + { + return IndexedDbRequest.Run(() => Open(name, version)); + } + + public IIndexedDbRequest OpenRequest(String name, Int64 version, Action upgrade) + { + return IndexedDbRequest.Run(() => Open(name, version, upgrade)); + } + + public IIndexedDbRequest OpenRequest(String name, Int64 version, Action upgrade, Boolean waitForUnblock) + { + if (!waitForUnblock) + { + return OpenRequest(name, version, upgrade); + } + + name = name ?? String.Empty; + var origin = _getOrigin.Invoke(); + var database = _getDatabase.Invoke(origin, name); + var request = IndexedDbRequest.CreatePending(); + + void TryOpen() + { + try + { + var opened = Open(name, version, upgrade); + request.SetSuccess(opened); + } + catch (IndexedDbBlockedException ex) + { + request.SetBlocked(ex); + } + catch (Exception ex) + { + request.SetError(ex); + } + } + + void HandleConnectionsChanged() + { + if (request.IsCompleted) + { + database.ConnectionsChanged -= HandleConnectionsChanged; + return; + } + + if (database.OpenConnections == 0) + { + TryOpen(); + + if (request.IsCompleted) + { + database.ConnectionsChanged -= HandleConnectionsChanged; + } + } + } + + TryOpen(); + + if (!request.IsCompleted) + { + database.ConnectionsChanged += HandleConnectionsChanged; + } + + return request; + } + + public Boolean DeleteDatabase(String name) + { + name = name ?? String.Empty; + var origin = _getOrigin.Invoke(); + var database = _getDatabase.Invoke(origin, name); + + if (database.OpenConnections > 0) + { + database.NotifyVersionChangeRequested(database.Version, 0); + throw new IndexedDbBlockedException("Cannot delete IndexedDB while open connections exist. Close existing database connections first."); + } + + return _deleteDatabase.Invoke(origin, name); + } + + public IIndexedDbRequest DeleteDatabaseRequest(String name) + { + return IndexedDbRequest.Run(() => DeleteDatabase(name)); + } + + public IIndexedDbRequest DeleteDatabaseRequest(String name, Boolean waitForUnblock) + { + if (!waitForUnblock) + { + return DeleteDatabaseRequest(name); + } + + name = name ?? String.Empty; + var origin = _getOrigin.Invoke(); + var database = _getDatabase.Invoke(origin, name); + var request = IndexedDbRequest.CreatePending(); + + void TryDelete() + { + try + { + var deleted = DeleteDatabase(name); + request.SetSuccess(deleted); + } + catch (IndexedDbBlockedException ex) + { + request.SetBlocked(ex); + } + catch (Exception ex) + { + request.SetError(ex); + } + } + + void HandleConnectionsChanged() + { + if (request.IsCompleted) + { + database.ConnectionsChanged -= HandleConnectionsChanged; + return; + } + + if (database.OpenConnections == 0) + { + TryDelete(); + + if (request.IsCompleted) + { + database.ConnectionsChanged -= HandleConnectionsChanged; + } + } + } + + TryDelete(); + + if (!request.IsCompleted) + { + database.ConnectionsChanged += HandleConnectionsChanged; + } + + return request; + } + } + + internal sealed class IndexedDbDatabase : IIndexedDbDatabase + { + private readonly String _name; + private readonly IndexedDbDatabaseBucket _database; + + private Boolean _isClosed; + + public event Action VersionChangeRequested; + + public IndexedDbDatabase(String name, IndexedDbDatabaseBucket database) + { + _name = name ?? String.Empty; + _database = database ?? throw new ArgumentNullException(nameof(database)); + } + + public String Name => _name; + + public Int64 Version => _database.Version; + + public Boolean IsClosed => _isClosed; + + public IIndexedDbObjectStore CreateObjectStore(String name) + { + EnsureOpen(); + return new IndexedDbObjectStore(() => _database.GetOrCreateStore(name), true); + } + + public Boolean DeleteObjectStore(String name) + { + EnsureOpen(); + return _database.DeleteStore(name); + } + + public IIndexedDbTransaction BeginTransaction(String storeName, IndexedDbTransactionMode mode) + { + EnsureOpen(); + + if (String.IsNullOrEmpty(storeName)) + { + throw new ArgumentNullException(nameof(storeName)); + } + + return BeginTransaction(new[] { storeName }, mode); + } + + public IIndexedDbTransaction BeginTransaction(String[] storeNames, IndexedDbTransactionMode mode) + { + EnsureOpen(); + return new IndexedDbTransaction(_database, storeNames, mode); + } + + public void Close() + { + if (_isClosed) + { + return; + } + + _isClosed = true; + _database.CloseConnection(this); + } + + internal void NotifyVersionChangeRequested(Int64 oldVersion, Int64 newVersion) + { + if (!_isClosed) + { + VersionChangeRequested?.Invoke(oldVersion, newVersion); + } + } + + private void EnsureOpen() + { + if (_isClosed) + { + throw new InvalidOperationException("IndexedDB database connection is closed."); + } + } + } + + internal sealed class IndexedDbObjectStore : IIndexedDbObjectStore + { + private readonly Func _getStore; + private readonly Boolean _writable; + + public IndexedDbObjectStore(Func getStore, Boolean writable) + { + _getStore = getStore ?? throw new ArgumentNullException(nameof(getStore)); + _writable = writable; + } + + public Int32 Length => CurrentBucket.Length; + + public Int32 Count() => CurrentBucket.Length; + + public Int32 Count(IndexedDbKeyRange range) + { + return GetAll(range).Count; + } + + public String Key(Int32 index) => CurrentBucket.Key(index); + + public String Get(String key) + { + return CurrentBucket.Get(key); + } + + public IReadOnlyList GetAll(IndexedDbKeyRange range) + { + var store = CurrentStore; + var bucket = store.Records; + var matches = new List(); + + for (var i = 0; i < bucket.Length; i++) + { + var key = bucket.Key(i); + + if (range == null || range.Includes(key)) + { + matches.Add(bucket.Get(key)); + } + } + + return matches; + } + + public void Add(String key, String value) + { + EnsureWritable(); + + if (String.IsNullOrEmpty(key)) + { + throw new ArgumentNullException(nameof(key)); + } + + if (CurrentBucket.Get(key) != null) + { + throw new InvalidOperationException(String.Concat("The key already exists: ", key)); + } + + EnsureIndexConstraints(key, value); + + CurrentBucket.Set(key, value); + } + + public void Put(String key, String value) + { + EnsureWritable(); + + if (String.IsNullOrEmpty(key)) + { + throw new ArgumentNullException(nameof(key)); + } + + EnsureIndexConstraints(key, value); + CurrentBucket.Set(key, value); + } + + public Boolean Delete(String key) + { + EnsureWritable(); + + if (String.IsNullOrEmpty(key)) + { + return false; + } + + return CurrentBucket.Remove(key); + } + + public String this[String key] + { + get => CurrentBucket.Get(key); + set => Put(key, value); + } + + public void Remove(String key) + { + EnsureWritable(); + + if (String.IsNullOrEmpty(key)) + { + return; + } + + CurrentBucket.Remove(key); + } + + public void Clear() + { + EnsureWritable(); + CurrentBucket.Clear(); + } + + public IIndexedDbIndex CreateIndex(String name, String keyPath, Boolean unique = false) + { + EnsureWritable(); + var store = CurrentStore; + var index = store.CreateOrGetIndex(name, keyPath, unique); + ValidateExistingRecordsAgainstIndex(index, store.Records); + return new IndexedDbIndex(() => CurrentStore, name); + } + + public IIndexedDbIndex GetIndex(String name) + { + var store = CurrentStore; + + if (!store.TryGetIndex(name, out _)) + { + return null; + } + + return new IndexedDbIndex(() => CurrentStore, name); + } + + public Boolean DeleteIndex(String name) + { + EnsureWritable(); + return CurrentStore.DeleteIndex(name); + } + + public IIndexedDbCursor OpenCursor() + { + return OpenCursor(null, IndexedDbCursorDirection.Next); + } + + public IIndexedDbCursor OpenCursor(IndexedDbKeyRange range, IndexedDbCursorDirection direction = IndexedDbCursorDirection.Next) + { + var items = new List>(); + var bucket = CurrentBucket; + + for (var i = 0; i < bucket.Length; i++) + { + var key = bucket.Key(i); + + if (range == null || range.Includes(key)) + { + items.Add(new KeyValuePair(key, bucket.Get(key))); + } + } + + if (direction == IndexedDbCursorDirection.Prev) + { + items.Reverse(); + } + + return IndexedDbCursor.From(items); + } + + private void EnsureWritable() + { + if (!_writable) + { + throw new InvalidOperationException("Cannot modify data in a read-only IndexedDB transaction."); + } + } + + private void EnsureIndexConstraints(String key, String value) + { + var store = CurrentStore; + var indexes = store.GetIndexes(); + + for (var i = 0; i < indexes.Count; i++) + { + var index = indexes[i]; + + if (!index.Unique) + { + continue; + } + + var indexValue = ExtractIndexValue(value, index.KeyPath); + + if (indexValue == null) + { + continue; + } + + for (var j = 0; j < CurrentBucket.Length; j++) + { + var existingKey = CurrentBucket.Key(j); + + if (String.Equals(existingKey, key, StringComparison.Ordinal)) + { + continue; + } + + var existingValue = CurrentBucket.Get(existingKey); + var existingIndexValue = ExtractIndexValue(existingValue, index.KeyPath); + + if (String.Equals(existingIndexValue, indexValue, StringComparison.Ordinal)) + { + throw new InvalidOperationException(String.Concat("Unique index violation for index: ", index.Name)); + } + } + } + } + + private static void ValidateExistingRecordsAgainstIndex(IndexedDbIndexDefinition index, StorageBucket bucket) + { + if (index == null || !index.Unique) + { + return; + } + + var seen = new HashSet(StringComparer.Ordinal); + + for (var i = 0; i < bucket.Length; i++) + { + var key = bucket.Key(i); + var value = bucket.Get(key); + var indexValue = ExtractIndexValue(value, index.KeyPath); + + if (indexValue == null) + { + continue; + } + + if (!seen.Add(indexValue)) + { + throw new InvalidOperationException(String.Concat("Unique index violation for index: ", index.Name)); + } + } + } + + internal static String ExtractIndexValue(String value, String keyPath) + { + if (String.IsNullOrEmpty(value) || String.IsNullOrEmpty(keyPath)) + { + return null; + } + + var marker = String.Concat(keyPath, "="); + var start = value.IndexOf(marker, StringComparison.Ordinal); + + if (start < 0) + { + return null; + } + + start += marker.Length; + var end = value.IndexOf(';', start); + + if (end < 0) + { + end = value.Length; + } + + var extracted = value.Substring(start, end - start).Trim(); + return extracted.Length == 0 ? null : extracted; + } + + private IndexedDbStoreBucket CurrentStore => _getStore.Invoke(); + + private StorageBucket CurrentBucket => CurrentStore.Records; + } + + internal sealed class IndexedDbIndex : IIndexedDbIndex + { + private readonly Func _getStore; + private readonly String _name; + + public IndexedDbIndex(Func getStore, String name) + { + _getStore = getStore ?? throw new ArgumentNullException(nameof(getStore)); + _name = name ?? String.Empty; + } + + public String Name => Definition.Name; + + public String KeyPath => Definition.KeyPath; + + public Boolean IsUnique => Definition.Unique; + + public String Get(String indexKey) + { + return GetAll(indexKey).FirstOrDefault(); + } + + public IReadOnlyList GetAll(String indexKey) + { + var values = new List(); + + if (String.IsNullOrEmpty(indexKey)) + { + return values; + } + + var store = _getStore.Invoke(); + var bucket = store.Records; + + for (var i = 0; i < bucket.Length; i++) + { + var key = bucket.Key(i); + var value = bucket.Get(key); + var projected = IndexedDbObjectStore.ExtractIndexValue(value, Definition.KeyPath); + + if (String.Equals(projected, indexKey, StringComparison.Ordinal)) + { + values.Add(value); + } + } + + return values; + } + + public IReadOnlyList GetAll(IndexedDbKeyRange range) + { + var values = new List(); + var store = _getStore.Invoke(); + var bucket = store.Records; + + for (var i = 0; i < bucket.Length; i++) + { + var key = bucket.Key(i); + var value = bucket.Get(key); + var projected = IndexedDbObjectStore.ExtractIndexValue(value, Definition.KeyPath); + + if (projected != null && (range == null || range.Includes(projected))) + { + values.Add(value); + } + } + + return values; + } + + public Int32 Count(String indexKey) + { + return GetAll(indexKey).Count; + } + + public IIndexedDbCursor OpenCursor() + { + return OpenCursor(null, IndexedDbCursorDirection.Next); + } + + public IIndexedDbCursor OpenCursor(IndexedDbKeyRange range, IndexedDbCursorDirection direction = IndexedDbCursorDirection.Next) + { + var rows = new List(); + var store = _getStore.Invoke(); + var bucket = store.Records; + var definition = Definition; + + for (var i = 0; i < bucket.Length; i++) + { + var key = bucket.Key(i); + var value = bucket.Get(key); + var projected = IndexedDbObjectStore.ExtractIndexValue(value, definition.KeyPath); + + if (projected == null) + { + continue; + } + + if (range != null && !range.Includes(projected)) + { + continue; + } + + rows.Add(new IndexedDbIndexCursorRow(projected, key, value)); + } + + rows.Sort((a, b) => + { + var compare = StringComparer.Ordinal.Compare(a.IndexKey, b.IndexKey); + + if (compare != 0) + { + return compare; + } + + return StringComparer.Ordinal.Compare(a.PrimaryKey, b.PrimaryKey); + }); + + if (direction == IndexedDbCursorDirection.Prev) + { + rows.Reverse(); + } + + var items = rows.Select(m => new KeyValuePair(m.PrimaryKey, m.Value)).ToList(); + return IndexedDbCursor.From(items); + } + + private IndexedDbIndexDefinition Definition + { + get + { + var store = _getStore.Invoke(); + + if (!store.TryGetIndex(_name, out var definition)) + { + throw new InvalidOperationException(String.Concat("Index does not exist: ", _name)); + } + + return definition; + } + } + } + + internal sealed class IndexedDbRequest : IIndexedDbRequest + { + private readonly TaskCompletionSource _source; + + private T _result; + private Exception _exception; + private IndexedDbRequestState _state; + + private event Action SucceededInternal; + private event Action FailedInternal; + private event Action BlockedInternal; + + private IndexedDbRequest(TaskCompletionSource source, IndexedDbRequestState state) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _state = state; + } + + public IndexedDbRequestState State => _state; + + public Boolean IsCompleted => _source.Task.IsCompleted; + + public Boolean HasError => _state == IndexedDbRequestState.Error || _state == IndexedDbRequestState.Blocked; + + public T Result + { + get + { + return _state == IndexedDbRequestState.Success ? _result : default; + } + } + + public Exception Exception => _exception; + + public event Action Succeeded + { + add + { + SucceededInternal += value; + + if (_state == IndexedDbRequestState.Success) + { + value?.Invoke(_result); + } + } + remove + { + SucceededInternal -= value; + } + } + + public event Action Failed + { + add + { + FailedInternal += value; + + if (_state == IndexedDbRequestState.Error || _state == IndexedDbRequestState.Blocked) + { + value?.Invoke(_exception); + } + } + remove + { + FailedInternal -= value; + } + } + + public event Action Blocked + { + add + { + BlockedInternal += value; + + if (_state == IndexedDbRequestState.Blocked) + { + value?.Invoke(); + } + } + remove + { + BlockedInternal -= value; + } + } + + public Task WaitAsync() + { + return _source.Task; + } + + public void SetSuccess(T value) + { + if (IsCompleted) + { + return; + } + + _state = IndexedDbRequestState.Success; + _result = value; + _exception = null; + _source.TrySetResult(value); + SucceededInternal?.Invoke(value); + } + + public void SetBlocked(Exception exception) + { + if (IsCompleted) + { + return; + } + + _state = IndexedDbRequestState.Blocked; + _exception = exception; + BlockedInternal?.Invoke(); + FailedInternal?.Invoke(exception); + } + + public void SetError(Exception exception) + { + if (IsCompleted) + { + return; + } + + _state = IndexedDbRequestState.Error; + _exception = exception; + _source.TrySetException(exception); + FailedInternal?.Invoke(exception); + } + + public static IIndexedDbRequest Run(Func action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + try + { + var result = action.Invoke(); + var request = new IndexedDbRequest(new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), IndexedDbRequestState.Pending); + request.SetSuccess(result); + return request; + } + catch (IndexedDbBlockedException ex) + { + var request = new IndexedDbRequest(new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), IndexedDbRequestState.Pending); + request.SetBlocked(ex); + request._source.TrySetException(ex); + return request; + } + catch (Exception ex) + { + var request = new IndexedDbRequest(new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), IndexedDbRequestState.Pending); + request.SetError(ex); + return request; + } + } + + public static IndexedDbRequest CreatePending() + { + return new IndexedDbRequest(new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), IndexedDbRequestState.Pending); + } + } + + internal sealed class IndexedDbCursor : IIndexedDbCursor + { + private readonly IReadOnlyList> _items; + private readonly Int32 _index; + + private IndexedDbCursor(IReadOnlyList> items, Int32 index) + { + _items = items ?? throw new ArgumentNullException(nameof(items)); + _index = index; + } + + public String Key => _items[_index].Key; + + public String Value => _items[_index].Value; + + public IIndexedDbCursor Continue() + { + var next = _index + 1; + + if (next >= _items.Count) + { + return null; + } + + return new IndexedDbCursor(_items, next); + } + + public static IIndexedDbCursor From(IReadOnlyList> items) + { + if (items == null || items.Count == 0) + { + return null; + } + + return new IndexedDbCursor(items, 0); + } + } + + internal sealed class IndexedDbIndexCursorRow + { + public IndexedDbIndexCursorRow(String indexKey, String primaryKey, String value) + { + IndexKey = indexKey ?? String.Empty; + PrimaryKey = primaryKey ?? String.Empty; + Value = value; + } + + public String IndexKey { get; } + + public String PrimaryKey { get; } + + public String Value { get; } + } + + internal sealed class IndexedDbTransaction : IIndexedDbTransaction + { + private readonly IndexedDbDatabaseBucket _database; + private readonly Dictionary _stores; + + private Boolean _isCompleted; + private Boolean _isAborted; + private readonly Boolean _holdsWriteLock; + + public IndexedDbTransaction(IndexedDbDatabaseBucket database, String[] storeNames, IndexedDbTransactionMode mode) + { + _database = database ?? throw new ArgumentNullException(nameof(database)); + Mode = mode; + _database.BeginTransaction(mode); + _holdsWriteLock = mode == IndexedDbTransactionMode.ReadWrite; + + if (storeNames == null || storeNames.Length == 0) + { + throw new ArgumentException("At least one object store name is required.", nameof(storeNames)); + } + + _stores = new Dictionary(StringComparer.Ordinal); + + for (var i = 0; i < storeNames.Length; i++) + { + var name = storeNames[i] ?? String.Empty; + + if (_stores.ContainsKey(name)) + { + continue; + } + + var existing = _database.GetStore(name); + + if (existing == null) + { + throw new InvalidOperationException(String.Concat("Object store does not exist: ", name)); + } + + _stores[name] = CloneStore(existing); + } + } + + public IndexedDbTransactionMode Mode { get; } + + public Boolean IsCompleted => _isCompleted; + + public Boolean IsAborted => _isAborted; + + public event Action Completed; + + public event Action Aborted; + + public IIndexedDbObjectStore GetObjectStore(String name) + { + EnsureActive(); + name = name ?? String.Empty; + + if (!_stores.TryGetValue(name, out var store)) + { + throw new InvalidOperationException(String.Concat("Object store is not part of the transaction scope: ", name)); + } + + return new IndexedDbObjectStore(() => store, Mode != IndexedDbTransactionMode.ReadOnly); + } + + public void Commit() + { + EnsureActive(); + + if (Mode != IndexedDbTransactionMode.ReadOnly) + { + foreach (var store in _stores) + { + _database.SetStore(store.Key, CloneStore(store.Value)); + } + } + + _isCompleted = true; + _isAborted = false; + ReleaseLockIfNeeded(); + Completed?.Invoke(); + } + + public void Abort() + { + EnsureActive(); + _isCompleted = true; + _isAborted = true; + ReleaseLockIfNeeded(); + Aborted?.Invoke(); + } + + public void Dispose() + { + if (!_isCompleted) + { + Abort(); + } + } + + private static IndexedDbStoreBucket CloneStore(IndexedDbStoreBucket store) + { + return store?.Clone() ?? new IndexedDbStoreBucket(); + } + + private void EnsureActive() + { + if (_isCompleted) + { + throw new InvalidOperationException("The IndexedDB transaction is already completed."); + } + } + + private void ReleaseLockIfNeeded() + { + if (_holdsWriteLock) + { + _database.EndTransaction(Mode); + } + } + } + + internal sealed class IndexedDbUpgradeTransaction : IIndexedDbUpgradeTransaction + { + private readonly IndexedDbDatabaseBucket _database; + private readonly Dictionary _stores; + + public IndexedDbUpgradeTransaction(IndexedDbDatabaseBucket database, Int64 oldVersion, Int64 newVersion) + { + _database = database ?? throw new ArgumentNullException(nameof(database)); + OldVersion = oldVersion; + NewVersion = newVersion; + _stores = _database.CloneStores(); + } + + public Int64 OldVersion { get; } + + public Int64 NewVersion { get; } + + public IIndexedDbObjectStore CreateObjectStore(String name) + { + name = name ?? String.Empty; + + if (!_stores.TryGetValue(name, out var store)) + { + store = new IndexedDbStoreBucket(); + _stores[name] = store; + } + + return new IndexedDbObjectStore(() => store, true); + } + + public Boolean DeleteObjectStore(String name) + { + return _stores.Remove(name ?? String.Empty); + } + + public IIndexedDbObjectStore GetObjectStore(String name) + { + name = name ?? String.Empty; + + if (_stores.TryGetValue(name, out var store)) + { + return new IndexedDbObjectStore(() => store, true); + } + + return null; + } + + public void Commit() + { + _database.ReplaceStores(_stores); + _database.Version = NewVersion; + } + } + + internal sealed class IndexedDbDatabaseBucket + { + private readonly Dictionary _stores; + private readonly List> _connections; + + private Int32 _openConnections; + private Boolean _hasActiveReadWriteTransaction; + + public IndexedDbDatabaseBucket(String name) + { + Name = name ?? String.Empty; + _stores = new Dictionary(StringComparer.Ordinal); + _connections = new List>(); + } + + public String Name { get; } + + public Int64 Version { get; set; } + + public Int32 OpenConnections => _openConnections; + + public event Action ConnectionsChanged; + + public void BeginTransaction(IndexedDbTransactionMode mode) + { + if (mode == IndexedDbTransactionMode.ReadWrite) + { + if (_hasActiveReadWriteTransaction) + { + throw new InvalidOperationException("Another read-write transaction is already active for this database."); + } + + _hasActiveReadWriteTransaction = true; + } + } + + public void EndTransaction(IndexedDbTransactionMode mode) + { + if (mode == IndexedDbTransactionMode.ReadWrite) + { + _hasActiveReadWriteTransaction = false; + } + } + + public void OpenConnection(IndexedDbDatabase connection) + { + if (connection != null) + { + _connections.Add(new WeakReference(connection)); + } + + _openConnections++; + ConnectionsChanged?.Invoke(); + } + + public void CloseConnection(IndexedDbDatabase connection) + { + if (_openConnections > 0) + { + _openConnections--; + } + + CleanupConnections(); + ConnectionsChanged?.Invoke(); + } + + public void NotifyVersionChangeRequested(Int64 oldVersion, Int64 newVersion) + { + CleanupConnections(); + + for (var i = 0; i < _connections.Count; i++) + { + if (_connections[i].TryGetTarget(out var connection)) + { + connection.NotifyVersionChangeRequested(oldVersion, newVersion); + } + } + } + + private void CleanupConnections() + { + for (var i = _connections.Count - 1; i >= 0; i--) + { + if (!_connections[i].TryGetTarget(out var connection) || connection.IsClosed) + { + _connections.RemoveAt(i); + } + } + } + + public IndexedDbStoreBucket GetOrCreateStore(String name) + { + name = name ?? String.Empty; + + if (!_stores.TryGetValue(name, out var store)) + { + store = new IndexedDbStoreBucket(); + _stores[name] = store; + } + + return store; + } + + public IndexedDbStoreBucket GetStore(String name) + { + name = name ?? String.Empty; + return _stores.TryGetValue(name, out var store) ? store : null; + } + + public void SetStore(String name, IndexedDbStoreBucket store) + { + _stores[name ?? String.Empty] = store ?? new IndexedDbStoreBucket(); + } + + public Boolean DeleteStore(String name) + { + return _stores.Remove(name ?? String.Empty); + } + + public Dictionary CloneStores() + { + return _stores.ToDictionary(m => m.Key, m => m.Value.Clone(), StringComparer.Ordinal); + } + + public void ReplaceStores(Dictionary stores) + { + _stores.Clear(); + + if (stores == null) + { + return; + } + + foreach (var store in stores) + { + _stores[store.Key] = store.Value.Clone(); + } + } + } + + internal sealed class IndexedDbStoreBucket + { + private readonly Dictionary _indexes; + + public IndexedDbStoreBucket() + { + Records = new StorageBucket(); + _indexes = new Dictionary(StringComparer.Ordinal); + } + + private IndexedDbStoreBucket(StorageBucket records, Dictionary indexes) + { + Records = records ?? new StorageBucket(); + _indexes = indexes ?? new Dictionary(StringComparer.Ordinal); + } + + public StorageBucket Records { get; } + + public IndexedDbIndexDefinition CreateOrGetIndex(String name, String keyPath, Boolean unique) + { + name = name ?? String.Empty; + keyPath = keyPath ?? String.Empty; + + if (_indexes.TryGetValue(name, out var existing)) + { + if (!String.Equals(existing.KeyPath, keyPath, StringComparison.Ordinal) || existing.Unique != unique) + { + throw new InvalidOperationException(String.Concat("Index already exists with different definition: ", name)); + } + + return existing; + } + + var index = new IndexedDbIndexDefinition(name, keyPath, unique); + _indexes[name] = index; + return index; + } + + public Boolean TryGetIndex(String name, out IndexedDbIndexDefinition index) + { + return _indexes.TryGetValue(name ?? String.Empty, out index); + } + + public Boolean DeleteIndex(String name) + { + return _indexes.Remove(name ?? String.Empty); + } + + public List GetIndexes() + { + return _indexes.Values.ToList(); + } + + public IndexedDbStoreBucket Clone() + { + var records = new StorageBucket(Records.ToEntries()); + var indexes = _indexes.ToDictionary(m => m.Key, m => m.Value.Clone(), StringComparer.Ordinal); + return new IndexedDbStoreBucket(records, indexes); + } + } + + internal sealed class IndexedDbIndexDefinition + { + public IndexedDbIndexDefinition(String name, String keyPath, Boolean unique) + { + Name = name ?? String.Empty; + KeyPath = keyPath ?? String.Empty; + Unique = unique; + } + + public String Name { get; } + + public String KeyPath { get; } + + public Boolean Unique { get; } + + public IndexedDbIndexDefinition Clone() + { + return new IndexedDbIndexDefinition(Name, KeyPath, Unique); + } + } + + internal sealed class IndexedDbBlockedException : InvalidOperationException + { + public IndexedDbBlockedException(String message) + : base(message) + { + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/ICache.cs b/src/AngleSharp.Io/Interfaces/ICache.cs new file mode 100644 index 0000000..20cf477 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/ICache.cs @@ -0,0 +1,51 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using AngleSharp.Io; + using System; + + /// + /// Represents a Cache object. + /// + [DomName("Cache")] + [DomExposed("Window")] + public interface ICache + { + /// + /// Stores a response for the given request address. + /// + /// The request address. + /// The response to store. + [DomName("put")] + void Put(String requestAddress, IResponse response); + + /// + /// Retrieves the stored response for the given request address. + /// + /// The request address. + /// The stored response or null. + [DomName("match")] + IResponse Match(String requestAddress); + + /// + /// Deletes the stored response for the given request address. + /// + /// The request address. + /// True if a response was removed. + [DomName("delete")] + Boolean Delete(String requestAddress); + + /// + /// Gets the stored request addresses. + /// + /// The stored request addresses. + [DomName("keys")] + String[] Keys(); + + /// + /// Removes all stored responses. + /// + [DomName("clear")] + void Clear(); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/ICacheProviderFactory.cs b/src/AngleSharp.Io/Interfaces/ICacheProviderFactory.cs new file mode 100644 index 0000000..d6c71af --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/ICacheProviderFactory.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Dom; + + /// + /// Represents a factory for creating Cache Storage views per window. + /// + public interface ICacheProviderFactory + { + /// + /// Gets the Cache Storage object for the provided window. + /// + /// The window requesting cache access. + /// The Cache Storage object or null. + ICacheStorage GetCaches(IWindow window); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/ICacheStorage.cs b/src/AngleSharp.Io/Interfaces/ICacheStorage.cs new file mode 100644 index 0000000..056a9f1 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/ICacheStorage.cs @@ -0,0 +1,36 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + + /// + /// Represents the Cache Storage interface. + /// + [DomName("CacheStorage")] + [DomExposed("Window")] + public interface ICacheStorage + { + /// + /// Gets or creates the cache with the given name. + /// + /// The cache name. + /// The cache instance. + [DomName("open")] + ICache Open(String name); + + /// + /// Deletes the cache with the given name. + /// + /// The cache name. + /// True if the cache existed and was removed. + [DomName("delete")] + Boolean Delete(String name); + + /// + /// Gets all cache names. + /// + /// The cache names. + [DomName("keys")] + String[] Keys(); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IClipboardPlatform.cs b/src/AngleSharp.Io/Interfaces/IClipboardPlatform.cs new file mode 100644 index 0000000..5071b80 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IClipboardPlatform.cs @@ -0,0 +1,24 @@ +namespace AngleSharp.Io.Dom +{ + using System; + using System.Threading.Tasks; + + /// + /// Represents the platform clipboard implementation used by the DOM clipboard wrapper. + /// + public interface IClipboardPlatform + { + /// + /// Reads text from the platform clipboard. + /// + /// The clipboard text. + Task ReadTextAsync(); + + /// + /// Writes text to the platform clipboard. + /// + /// The text to write. + /// The task representing the asynchronous operation. + Task WriteTextAsync(String text); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IClipboardProviderFactory.cs b/src/AngleSharp.Io/Interfaces/IClipboardProviderFactory.cs new file mode 100644 index 0000000..50328d7 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IClipboardProviderFactory.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Browser.Dom; + + /// + /// Represents a factory for creating Clipboard views per navigator. + /// + public interface IClipboardProviderFactory + { + /// + /// Gets the Clipboard object for the provided navigator. + /// + /// The navigator requesting clipboard access. + /// The Clipboard object or null. + Clipboard GetClipboard(INavigator navigator); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IGeolocationPlatform.cs b/src/AngleSharp.Io/Interfaces/IGeolocationPlatform.cs new file mode 100644 index 0000000..1795a82 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IGeolocationPlatform.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Io.Dom +{ + using System.Threading.Tasks; + + /// + /// Represents the platform geolocation implementation used by the DOM geolocation wrapper. + /// + public interface IGeolocationPlatform + { + /// + /// Gets the current geolocation reading. + /// + /// The request options. + /// The current geolocation reading. + Task GetCurrentPositionAsync(GeolocationOptions options); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IGeolocationProviderFactory.cs b/src/AngleSharp.Io/Interfaces/IGeolocationProviderFactory.cs new file mode 100644 index 0000000..36dcb91 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IGeolocationProviderFactory.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Browser.Dom; + + /// + /// Represents a factory for creating Geolocation views per navigator. + /// + public interface IGeolocationProviderFactory + { + /// + /// Gets the Geolocation object for the provided navigator. + /// + /// The navigator requesting geolocation access. + /// The Geolocation object or null. + Geolocation GetGeolocation(INavigator navigator); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbCursor.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbCursor.cs new file mode 100644 index 0000000..6a08a74 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbCursor.cs @@ -0,0 +1,48 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + + /// + /// Represents a cursor over IndexedDB records. + /// + [DomName("IDBCursor")] + [DomExposed("Window")] + public interface IIndexedDbCursor + { + /// + /// Gets the current primary key. + /// + [DomName("key")] + String Key { get; } + + /// + /// Gets the current value. + /// + [DomName("value")] + String Value { get; } + + /// + /// Moves the cursor to the next matching record. + /// + /// The next cursor position or null. + [DomName("continue")] + IIndexedDbCursor Continue(); + } + + /// + /// Specifies cursor traversal direction. + /// + public enum IndexedDbCursorDirection + { + /// + /// Ascending key order. + /// + Next, + + /// + /// Descending key order. + /// + Prev, + } +} diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbDatabase.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbDatabase.cs new file mode 100644 index 0000000..c00a184 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbDatabase.cs @@ -0,0 +1,76 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + + /// + /// Represents an IndexedDB database. + /// + [DomName("IDBDatabase")] + [DomExposed("Window")] + public interface IIndexedDbDatabase + { + /// + /// Gets the database name. + /// + [DomName("name")] + String Name { get; } + + /// + /// Gets the database version. + /// + [DomName("version")] + Int64 Version { get; } + + /// + /// Gets if this connection has been closed. + /// + [DomName("closed")] + Boolean IsClosed { get; } + + /// + /// Raised when another open request needs this connection to close for a version upgrade. + /// + event Action VersionChangeRequested; + + /// + /// Gets or creates the object store with the given name. + /// + /// The object store name. + /// The object store. + [DomName("createObjectStore")] + IIndexedDbObjectStore CreateObjectStore(String name); + + /// + /// Deletes the object store with the given name. + /// + /// The object store name. + /// True if the store existed and was removed. + [DomName("deleteObjectStore")] + Boolean DeleteObjectStore(String name); + + /// + /// Begins a transaction for a single object store. + /// + /// The store included in the transaction scope. + /// The transaction mode. + /// The created transaction. + [DomName("transaction")] + IIndexedDbTransaction BeginTransaction(String storeName, IndexedDbTransactionMode mode); + + /// + /// Begins a transaction for multiple object stores. + /// + /// The stores included in the transaction scope. + /// The transaction mode. + /// The created transaction. + [DomName("transaction")] + IIndexedDbTransaction BeginTransaction(String[] storeNames, IndexedDbTransactionMode mode); + + /// + /// Closes the database connection. + /// + [DomName("close")] + void Close(); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbFactory.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbFactory.cs new file mode 100644 index 0000000..551d73b --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbFactory.cs @@ -0,0 +1,105 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + + /// + /// Represents an IndexedDB factory. + /// + [DomName("IDBFactory")] + [DomExposed("Window")] + public interface IIndexedDbFactory + { + /// + /// Opens or creates the database with the given name. + /// + /// The database name. + /// The database instance. + [DomName("open")] + IIndexedDbDatabase Open(String name); + + /// + /// Opens the database at the requested version. + /// + /// The database name. + /// The requested version (must be greater than 0). + /// The database instance. + [DomName("open")] + IIndexedDbDatabase Open(String name, Int64 version); + + /// + /// Opens the database at the requested version and runs upgrade logic if needed. + /// + /// The database name. + /// The requested version (must be greater than 0). + /// The callback used for upgrade changes. + /// The database instance. + [DomName("open")] + IIndexedDbDatabase Open(String name, Int64 version, Action upgrade); + + /// + /// Opens or creates the database with the given name using a request wrapper. + /// + /// The database name. + /// The open request. + [DomName("open")] + IIndexedDbRequest OpenRequest(String name); + + /// + /// Opens the database at the requested version using a request wrapper. + /// + /// The database name. + /// The requested version (must be greater than 0). + /// The open request. + [DomName("open")] + IIndexedDbRequest OpenRequest(String name, Int64 version); + + /// + /// Opens the database at the requested version using a request wrapper and optional upgrade callback. + /// + /// The database name. + /// The requested version (must be greater than 0). + /// The callback used for upgrade changes. + /// The open request. + [DomName("open")] + IIndexedDbRequest OpenRequest(String name, Int64 version, Action upgrade); + + /// + /// Opens the database at the requested version using a request wrapper and optional upgrade callback. + /// If blocked by open connections, the request waits and retries automatically when connections close. + /// + /// The database name. + /// The requested version (must be greater than 0). + /// The callback used for upgrade changes. + /// True to keep retrying when blocked until the request can proceed. + /// The open request. + [DomName("open")] + IIndexedDbRequest OpenRequest(String name, Int64 version, Action upgrade, Boolean waitForUnblock); + + /// + /// Deletes the database with the given name. + /// + /// The database name. + /// True if the database existed and was removed. + [DomName("deleteDatabase")] + Boolean DeleteDatabase(String name); + + /// + /// Deletes the database with the given name using a request wrapper. + /// + /// The database name. + /// The delete request. + [DomName("deleteDatabase")] + IIndexedDbRequest DeleteDatabaseRequest(String name); + + /// + /// Deletes the database with the given name using a request wrapper. + /// If blocked by open connections, the request waits and retries automatically when connections close. + /// + /// The database name. + /// True to keep retrying when blocked until the delete can proceed. + /// The delete request. + [DomName("deleteDatabase")] + IIndexedDbRequest DeleteDatabaseRequest(String name, Boolean waitForUnblock); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbIndex.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbIndex.cs new file mode 100644 index 0000000..b754b77 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbIndex.cs @@ -0,0 +1,80 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + using System.Collections.Generic; + + /// + /// Represents an IndexedDB index. + /// + [DomName("IDBIndex")] + [DomExposed("Window")] + public interface IIndexedDbIndex + { + /// + /// Gets the index name. + /// + [DomName("name")] + String Name { get; } + + /// + /// Gets the key-path key used for index extraction. + /// + [DomName("keyPath")] + String KeyPath { get; } + + /// + /// Gets whether duplicate index values are disallowed. + /// + [DomName("unique")] + Boolean IsUnique { get; } + + /// + /// Gets the first value for the given index key. + /// + /// The index key. + /// The value if any. + [DomName("get")] + String Get(String indexKey); + + /// + /// Gets all values for the given index key. + /// + /// The index key. + /// The matching values. + [DomName("getAll")] + IReadOnlyList GetAll(String indexKey); + + /// + /// Gets all values matching the index key range. + /// + /// The key range filter. + /// The matching values. + [DomName("getAll")] + IReadOnlyList GetAll(IndexedDbKeyRange range); + + /// + /// Counts records for the given index key. + /// + /// The index key. + /// The record count. + [DomName("count")] + Int32 Count(String indexKey); + + /// + /// Opens a cursor over all index entries. + /// + /// The first cursor position or null. + [DomName("openCursor")] + IIndexedDbCursor OpenCursor(); + + /// + /// Opens a cursor over index entries matching the key range. + /// + /// The index key range filter. + /// The cursor direction. + /// The first cursor position or null. + [DomName("openCursor")] + IIndexedDbCursor OpenCursor(IndexedDbKeyRange range, IndexedDbCursorDirection direction = IndexedDbCursorDirection.Next); + } +} diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbObjectStore.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbObjectStore.cs new file mode 100644 index 0000000..7f76218 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbObjectStore.cs @@ -0,0 +1,111 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + using System.Collections.Generic; + + /// + /// Represents an IndexedDB object store. + /// + [DomName("IDBObjectStore")] + [DomExposed("Window")] + public interface IIndexedDbObjectStore : IStorage + { + /// + /// Adds a value under the given key if the key does not exist. + /// + /// The key to insert. + /// The value to store. + [DomName("add")] + void Add(String key, String value); + + /// + /// Puts a value under the given key, replacing an existing value if present. + /// + /// The key to insert or replace. + /// The value to store. + [DomName("put")] + void Put(String key, String value); + + /// + /// Gets the value under the given key. + /// + /// The key to look up. + /// The value or null. + [DomName("get")] + String Get(String key); + + /// + /// Deletes the value under the given key. + /// + /// The key to delete. + /// True if a value was deleted. + [DomName("delete")] + Boolean Delete(String key); + + /// + /// Gets the number of records in the object store. + /// + /// The number of records. + [DomName("count")] + Int32 Count(); + + /// + /// Gets all values for the given key range. + /// + /// The key range filter. + /// The matching values. + [DomName("getAll")] + IReadOnlyList GetAll(IndexedDbKeyRange range); + + /// + /// Counts values for the given key range. + /// + /// The key range filter. + /// The number of matching values. + [DomName("count")] + Int32 Count(IndexedDbKeyRange range); + + /// + /// Creates or gets an index for the object store. + /// + /// The index name. + /// The key-path key used for extracting index values from records. + /// True if duplicate index keys are disallowed. + /// The index object. + [DomName("createIndex")] + IIndexedDbIndex CreateIndex(String name, String keyPath, Boolean unique = false); + + /// + /// Gets an existing index. + /// + /// The index name. + /// The index object if found. + [DomName("index")] + IIndexedDbIndex GetIndex(String name); + + /// + /// Deletes an existing index. + /// + /// The index name. + /// True if the index existed and was removed. + [DomName("deleteIndex")] + Boolean DeleteIndex(String name); + + /// + /// Opens a cursor over all records in key order. + /// + /// The first cursor position or null. + [DomName("openCursor")] + IIndexedDbCursor OpenCursor(); + + /// + /// Opens a cursor constrained by a key range. + /// + /// The key range filter. + /// The cursor direction. + /// The first cursor position or null. + [DomName("openCursor")] + IIndexedDbCursor OpenCursor(IndexedDbKeyRange range, IndexedDbCursorDirection direction = IndexedDbCursorDirection.Next); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbProviderFactory.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbProviderFactory.cs new file mode 100644 index 0000000..423457b --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbProviderFactory.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Dom; + + /// + /// Represents a factory for creating IndexedDB views per window. + /// + public interface IIndexedDbProviderFactory + { + /// + /// Gets the IndexedDB factory for the provided window. + /// + /// The window requesting IndexedDB access. + /// The IndexedDB factory or null. + IIndexedDbFactory GetIndexedDb(IWindow window); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbRequest.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbRequest.cs new file mode 100644 index 0000000..a12ddf8 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbRequest.cs @@ -0,0 +1,91 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + using System.Threading.Tasks; + + /// + /// Represents a lightweight request wrapper for IndexedDB operations. + /// + /// The result type. + [DomName("IDBRequest")] + [DomExposed("Window")] + public interface IIndexedDbRequest + { + /// + /// Gets the current request state. + /// + [DomName("state")] + IndexedDbRequestState State { get; } + + /// + /// Gets whether the request has completed. + /// + [DomName("done")] + Boolean IsCompleted { get; } + + /// + /// Gets whether the request failed. + /// + [DomName("error")] + Boolean HasError { get; } + + /// + /// Gets the result value if successful. + /// + [DomName("result")] + T Result { get; } + + /// + /// Gets the request error if any. + /// + [DomName("exception")] + Exception Exception { get; } + + /// + /// Gets the task representing request completion. + /// + Task WaitAsync(); + + /// + /// Raised when the request succeeds. + /// + event Action Succeeded; + + /// + /// Raised when the request fails. + /// + event Action Failed; + + /// + /// Raised when the request is blocked by open connections. + /// + event Action Blocked; + } + + /// + /// Represents the state of an IndexedDB request. + /// + public enum IndexedDbRequestState + { + /// + /// Request has not completed yet. + /// + Pending, + + /// + /// Request finished successfully. + /// + Success, + + /// + /// Request failed with an error. + /// + Error, + + /// + /// Request is blocked by open connections. + /// + Blocked, + } +} diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbTransaction.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbTransaction.cs new file mode 100644 index 0000000..95a0ac3 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbTransaction.cs @@ -0,0 +1,82 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + + /// + /// Represents a transaction scope for IndexedDB operations. + /// + [DomName("IDBTransaction")] + [DomExposed("Window")] + public interface IIndexedDbTransaction : IDisposable + { + /// + /// Gets the transaction mode. + /// + [DomName("mode")] + IndexedDbTransactionMode Mode { get; } + + /// + /// Gets if the transaction has completed. + /// + [DomName("done")] + Boolean IsCompleted { get; } + + /// + /// Gets if the transaction has been aborted. + /// + [DomName("aborted")] + Boolean IsAborted { get; } + + /// + /// Gets an object store in the transaction scope. + /// + /// The object store name. + /// The object store. + [DomName("objectStore")] + IIndexedDbObjectStore GetObjectStore(String name); + + /// + /// Commits the transaction. + /// + [DomName("commit")] + void Commit(); + + /// + /// Aborts the transaction. + /// + [DomName("abort")] + void Abort(); + + /// + /// Raised when the transaction completes successfully. + /// + event Action Completed; + + /// + /// Raised when the transaction is aborted. + /// + event Action Aborted; + } + + /// + /// Specifies the transaction mode. + /// + public enum IndexedDbTransactionMode + { + /// + /// Read-only transaction. + /// + ReadOnly, + + /// + /// Read-write transaction. + /// + ReadWrite, + + /// + /// Version-change transaction used during upgrades. + /// + VersionChange, + } +} diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbUpgradeTransaction.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbUpgradeTransaction.cs new file mode 100644 index 0000000..428b7fa --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbUpgradeTransaction.cs @@ -0,0 +1,49 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + + /// + /// Represents the version-change transaction passed during upgrades. + /// + [DomName("IDBVersionChangeTransaction")] + [DomExposed("Window")] + public interface IIndexedDbUpgradeTransaction + { + /// + /// Gets the old database version. + /// + [DomName("oldVersion")] + Int64 OldVersion { get; } + + /// + /// Gets the new database version. + /// + [DomName("newVersion")] + Int64 NewVersion { get; } + + /// + /// Creates or gets an object store within the upgrade transaction. + /// + /// The object store name. + /// The object store. + [DomName("createObjectStore")] + IIndexedDbObjectStore CreateObjectStore(String name); + + /// + /// Deletes an object store within the upgrade transaction. + /// + /// The object store name. + /// True if the store existed and was removed. + [DomName("deleteObjectStore")] + Boolean DeleteObjectStore(String name); + + /// + /// Gets an existing object store in the upgrade transaction. + /// + /// The object store name. + /// The object store. + [DomName("objectStore")] + IIndexedDbObjectStore GetObjectStore(String name); + } +} diff --git a/src/AngleSharp.Io/Interfaces/ILocalStorage.cs b/src/AngleSharp.Io/Interfaces/ILocalStorage.cs new file mode 100644 index 0000000..d535492 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/ILocalStorage.cs @@ -0,0 +1,9 @@ +namespace AngleSharp.Io.Dom +{ + /// + /// Represents a local storage implementation. + /// + public interface ILocalStorage : IStorage + { + } +} diff --git a/src/AngleSharp.Io/Interfaces/ISessionStorage.cs b/src/AngleSharp.Io/Interfaces/ISessionStorage.cs new file mode 100644 index 0000000..f497b9a --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/ISessionStorage.cs @@ -0,0 +1,9 @@ +namespace AngleSharp.Io.Dom +{ + /// + /// Represents a session storage implementation. + /// + public interface ISessionStorage : IStorage + { + } +} diff --git a/src/AngleSharp.Io/Interfaces/IStorage.cs b/src/AngleSharp.Io/Interfaces/IStorage.cs index 93cc55c..413a131 100644 --- a/src/AngleSharp.Io/Interfaces/IStorage.cs +++ b/src/AngleSharp.Io/Interfaces/IStorage.cs @@ -9,7 +9,7 @@ /// [DomName("Storage")] [DomExposed("Window")] - public interface IStorage + public interface IStorage { /// /// Gets the number of stored keys. diff --git a/src/AngleSharp.Io/Interfaces/IStorageProviderFactory.cs b/src/AngleSharp.Io/Interfaces/IStorageProviderFactory.cs new file mode 100644 index 0000000..dc21ca7 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IStorageProviderFactory.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Dom; + + /// + /// Represents a factory for creating storage views per window. + /// + public interface IStorageProviderFactory + { + /// + /// Gets the storage views for the provided window. + /// + /// The window requesting storage. + /// The storage views or null. + StorageViews GetStorages(IWindow window); + } +} diff --git a/src/AngleSharp.Io/Interfaces/IndexedDbKeyRange.cs b/src/AngleSharp.Io/Interfaces/IndexedDbKeyRange.cs new file mode 100644 index 0000000..79165ed --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IndexedDbKeyRange.cs @@ -0,0 +1,100 @@ +namespace AngleSharp.Io.Dom +{ + using System; + + /// + /// Represents a key range used for IndexedDB lookups. + /// + public sealed class IndexedDbKeyRange + { + private IndexedDbKeyRange(String lower, String upper, Boolean lowerOpen, Boolean upperOpen) + { + Lower = lower; + Upper = upper; + LowerOpen = lowerOpen; + UpperOpen = upperOpen; + } + + /// + /// Gets the lower bound key. + /// + public String Lower { get; } + + /// + /// Gets the upper bound key. + /// + public String Upper { get; } + + /// + /// Gets if the lower bound is open. + /// + public Boolean LowerOpen { get; } + + /// + /// Gets if the upper bound is open. + /// + public Boolean UpperOpen { get; } + + /// + /// Creates a range that includes exactly one key. + /// + public static IndexedDbKeyRange Only(String value) + { + return new IndexedDbKeyRange(value, value, false, false); + } + + /// + /// Creates a lower-bounded range. + /// + public static IndexedDbKeyRange LowerBound(String lower, Boolean open = false) + { + return new IndexedDbKeyRange(lower, null, open, false); + } + + /// + /// Creates an upper-bounded range. + /// + public static IndexedDbKeyRange UpperBound(String upper, Boolean open = false) + { + return new IndexedDbKeyRange(null, upper, false, open); + } + + /// + /// Creates a bounded range. + /// + public static IndexedDbKeyRange Bound(String lower, String upper, Boolean lowerOpen = false, Boolean upperOpen = false) + { + return new IndexedDbKeyRange(lower, upper, lowerOpen, upperOpen); + } + + internal Boolean Includes(String key) + { + if (key == null) + { + return false; + } + + if (Lower != null) + { + var lowerCompare = StringComparer.Ordinal.Compare(key, Lower); + + if (lowerCompare < 0 || (lowerCompare == 0 && LowerOpen)) + { + return false; + } + } + + if (Upper != null) + { + var upperCompare = StringComparer.Ordinal.Compare(key, Upper); + + if (upperCompare > 0 || (upperCompare == 0 && UpperOpen)) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/AngleSharp.Io/Interfaces/StorageViews.cs b/src/AngleSharp.Io/Interfaces/StorageViews.cs new file mode 100644 index 0000000..785794c --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/StorageViews.cs @@ -0,0 +1,29 @@ +namespace AngleSharp.Io.Dom +{ + /// + /// Represents the storage views available for a single window. + /// + public sealed class StorageViews + { + /// + /// Creates a new set of storage views. + /// + /// The local storage view. + /// The session storage view. + public StorageViews(ILocalStorage local, ISessionStorage session) + { + Local = local; + Session = session; + } + + /// + /// Gets the local storage view. + /// + public ILocalStorage Local { get; } + + /// + /// Gets the session storage view. + /// + public ISessionStorage Session { get; } + } +} diff --git a/src/AngleSharp.Io/IoConfigurationExtensions.cs b/src/AngleSharp.Io/IoConfigurationExtensions.cs index 4195466..a7a4c43 100644 --- a/src/AngleSharp.Io/IoConfigurationExtensions.cs +++ b/src/AngleSharp.Io/IoConfigurationExtensions.cs @@ -1,184 +1,355 @@ -namespace AngleSharp +namespace AngleSharp; + +using AngleSharp.Dom; +using AngleSharp.Io; +using AngleSharp.Io.Cookie; +using AngleSharp.Io.Dom; +using AngleSharp.Io.IndexedDb; +using AngleSharp.Io.GeolocationApi; +using AngleSharp.Io.Network; +using AngleSharp.Io.Storage; +using AngleSharp.Io.ClipboardApi; +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using AngleSharp.Browser.Dom; + +/// +/// Additional extensions for improved requesters. +/// +public static class IoConfigurationExtensions { - using AngleSharp.Dom; - using AngleSharp.Io; - using AngleSharp.Io.Cookie; - using AngleSharp.Io.Network; - using System; - using System.IO; - using System.Linq; - using System.Net.Http; + #region Combined /// - /// Additional extensions for improved requesters. + /// Registers the IO services all in one go. /// - public static class IoConfigurationExtensions + /// The configuration to extend. + /// The options to use. + /// The new configuration. + public static IConfiguration WithIo(this IConfiguration configuration, IoOptions options) { - #region Download - - /// - /// Adds capability to start a download when following some link to the - /// configuration. - /// - /// The configuration to extend. - /// - /// The callback to invoke when a download should be started. Returns true - /// to signal an interest in downloading the response, otherwise false. - /// - /// The new configuration. - public static IConfiguration WithDownload(this IConfiguration configuration, Func download) + if (options == null) { - var oldFactory = configuration.Services.OfType().FirstOrDefault(); - var newFactory = new DownloadFactory(oldFactory, download); - return configuration.WithDefaultLoader(new LoaderOptions - { - Filter = req => false, - }).WithOnly(newFactory); + options = new IoOptions(); } - /// - /// Adds the standard download capability, i.e., when a binary or attachment - /// is received the download callback is triggered. - /// - /// The configuration to extend. - /// - /// The callback with filename and stream as parameters. The stream must be - /// disposed / cleaned up after use. - /// - /// The new configuration. - public static IConfiguration WithStandardDownload(this IConfiguration configuration, Action download) + return configuration + .WithNavigator(options.Navigator) + .WithCookies(options.CookieHandler) + .WithRequesters(options.HttpHandler) + .WithStorageProviderFactory(options.StorageFactory) + .WithIndexedDbProviderFactory(options.IndexedDbFactory) + .WithCacheProviderFactory(options.CacheFactory) + .WithClipboard(options.ClipboardPlatform) + .WithGeolocation(options.GeolocationPlatform); + } + + #endregion + + #region Navigator + + /// + /// Registers the navigator service using the default implementation. + /// + /// The configuration to extend. + /// The new configuration. + public static IConfiguration WithNavigator(this IConfiguration configuration) + { + return configuration.WithNavigator(new NavigatorOptions()); + } + + /// + /// Registers the navigator service using the default implementation. + /// + /// The configuration to extend. + /// The navigator options to use. + /// The new configuration. + public static IConfiguration WithNavigator(this IConfiguration configuration, NavigatorOptions options) + { + return configuration.WithOnly(context => new Navigator(context, options)); + } + + #endregion + + #region Download + + /// + /// Adds capability to start a download when following some link to the + /// configuration. + /// + /// The configuration to extend. + /// + /// The callback to invoke when a download should be started. Returns true + /// to signal an interest in downloading the response, otherwise false. + /// + /// The new configuration. + public static IConfiguration WithDownload(this IConfiguration configuration, Func download) + { + var oldFactory = configuration.Services.OfType().FirstOrDefault(); + var newFactory = new DownloadFactory(oldFactory, download); + return configuration.WithDefaultLoader(new LoaderOptions + { + Filter = req => false, + }).WithOnly(newFactory); + } + + /// + /// Adds the standard download capability, i.e., when a binary or attachment + /// is received the download callback is triggered. + /// + /// The configuration to extend. + /// + /// The callback with filename and stream as parameters. The stream must be + /// disposed / cleaned up after use. + /// + /// The new configuration. + public static IConfiguration WithStandardDownload(this IConfiguration configuration, Action download) + { + var binary = new MimeType(MimeTypeNames.Binary); + return configuration.WithDownload((type, response) => { - var binary = new MimeType(MimeTypeNames.Binary); - return configuration.WithDownload((type, response) => + if (response.IsAttachment() || type == binary) { - if (response.IsAttachment() || type == binary) - { - var fileName = response.GetAttachedFileName(); - download.Invoke(fileName, response.Content); - return true; - } - - return false; - }); - } + var fileName = response.GetAttachedFileName(); + download.Invoke(fileName, response.Content); + return true; + } + + return false; + }); + } + + #endregion + + #region Requesters + + /// + /// Adds the requesters from the AngleSharp.Io package. + /// + /// The configuration to use. + /// The new configuration. + public static IConfiguration WithRequesters(this IConfiguration configuration) => + configuration.WithRequesters(new HttpClientHandler()); + + /// + /// Adds the requesters from the AngleSharp.Io package. + /// + /// The configuration to use. + /// + /// The HTTP client handler to use for sending requests. + /// + /// The new configuration. + public static IConfiguration WithRequesters(this IConfiguration configuration, HttpClientHandler httpClientHandler) + { + httpClientHandler.UseCookies = false; + httpClientHandler.AllowAutoRedirect = false; + return configuration.WithRequesters((HttpMessageHandler)httpClientHandler); + } + + /// + /// Adds the requesters from the AngleSharp.Io package. + /// + /// The configuration to use. + /// + /// The HTTP message handler to use for sending requests. + /// + /// The new configuration. + public static IConfiguration WithRequesters(this IConfiguration configuration, HttpMessageHandler httpMessageHandler) + { + var httpClient = new HttpClient(httpMessageHandler); + return configuration.With(new IRequester[] + { + new HttpClientRequester(httpClient), + new DataRequester(), + new FtpRequester(), + new FileRequester(), + new AboutRequester(), + }); + } + + /// + /// Adds the given requester to the configuration. + /// + /// The type of the requester to add. + /// The configuration to use. + /// The requester instance to add. + /// The new configuration. + public static IConfiguration WithRequester(this IConfiguration configuration, T requester) + where T : IRequester => configuration.With(requester); + + /// + /// Adds a new requester of the provided type to the configuration. + /// + /// The type of the requester to add. + /// The configuration to use. + /// The new configuration. + public static IConfiguration WithRequester(this IConfiguration configuration) + where T: IRequester, new() => configuration.WithRequester(new T()); + + #endregion + + #region Cookies - #endregion - - #region Requesters - - /// - /// Adds the requesters from the AngleSharp.Io package. - /// - /// The configuration to use. - /// The new configuration. - public static IConfiguration WithRequesters(this IConfiguration configuration) => - configuration.WithRequesters(new HttpClientHandler()); - - /// - /// Adds the requesters from the AngleSharp.Io package. - /// - /// The configuration to use. - /// - /// The HTTP client handler to use for sending requests. - /// - /// The new configuration. - public static IConfiguration WithRequesters(this IConfiguration configuration, HttpClientHandler httpClientHandler) + /// + /// Registers a persistent advanced cookie container using the local file handler. + /// + /// The configuration to extend. + /// The path to the required sync file. + /// The new instance with the service. + public static IConfiguration WithPersistentCookies(this IConfiguration configuration, String syncFilePath) => + configuration.WithCookies(new LocalFileHandler(syncFilePath)); + + /// + /// Registers a non-persistent advanced cookie container using the memory-only file + /// handler. + /// + /// The configuration to extend. + /// The new instance with the service. + public static IConfiguration WithTemporaryCookies(this IConfiguration configuration) => + configuration.WithCookies(new MemoryFileHandler()); + + /// + /// Registers a non-persistent advanced cookie container using the memory-only file + /// handler. + /// Alias for WithTemporaryCookies(). + /// + /// The configuration to extend. + /// The new instance with the service. + public static IConfiguration WithCookies(this IConfiguration configuration) => + configuration.WithTemporaryCookies(); + + /// + /// Registers the advanced cookie service. + /// + /// The configuration to extend. + /// The handler for the cookie source. + /// The new instance with the service. + public static IConfiguration WithCookies(this IConfiguration configuration, ICookieFileHandler fileHandler) => + configuration.WithCookies(new AdvancedCookieProvider(fileHandler)); + + /// + /// Registers a cookie service with the given provider. + /// + /// The configuration to extend. + /// The provider for cookie interactions. + /// The new instance with the service. + public static IConfiguration WithCookies(this IConfiguration configuration, ICookieProvider provider) => + configuration.WithOnly(provider); + + #endregion + + #region Storage + + /// + /// Registers a storage provider factory. + /// + /// The configuration to extend. + /// The directory path for local storage synchronization. + /// The new instance with the service. + public static IConfiguration WithStorageProviderFactory(this IConfiguration configuration, String syncDirectoryPath) + { + var factory = new StorageProviderFactory(); + factory.EnableLocalStorage(syncDirectoryPath); + factory.EnableSessionStorage(); + return configuration.WithStorageProviderFactory(factory); + } + + /// + /// Registers a storage provider factory. + /// + /// The configuration to extend. + /// The storage provider factory to use. + /// The new instance with the service. + public static IConfiguration WithStorageProviderFactory(this IConfiguration configuration, IStorageProviderFactory factory) => + configuration.WithOnly(factory); + + #endregion + + #region IndexedDb + + /// + /// Registers an IndexedDB provider factory. + /// + /// The configuration to extend. + /// The IndexedDB provider factory to use. + /// The new instance with the service. + public static IConfiguration WithIndexedDbProviderFactory(this IConfiguration configuration, IIndexedDbProviderFactory factory) => + configuration.WithOnly(factory); + + #endregion + + #region Cache + + /// + /// Registers a Cache Storage provider factory. + /// + /// The configuration to extend. + /// The Cache Storage provider factory to use. + /// The new instance with the service. + public static IConfiguration WithCacheProviderFactory(this IConfiguration configuration, ICacheProviderFactory factory) => + configuration.WithOnly(factory); + + #endregion + + #region Clipboard + + /// + /// Registers the clipboard service using the given platform implementation. + /// + /// The configuration to extend. + /// The platform clipboard implementation. + /// The new instance with the service. + public static IConfiguration WithClipboard(this IConfiguration configuration, IClipboardPlatform platform) + { + if (platform is not null) { - httpClientHandler.UseCookies = false; - httpClientHandler.AllowAutoRedirect = false; - return configuration.WithRequesters((HttpMessageHandler)httpClientHandler); + var factory = new ClipboardProviderFactory(platform); + return configuration.WithClipboardProviderFactory(factory); } - /// - /// Adds the requesters from the AngleSharp.Io package. - /// - /// The configuration to use. - /// - /// The HTTP message handler to use for sending requests. - /// - /// The new configuration. - public static IConfiguration WithRequesters(this IConfiguration configuration, HttpMessageHandler httpMessageHandler) + return configuration; + } + + /// + /// Registers a clipboard provider factory. + /// + /// The configuration to extend. + /// The clipboard provider factory to use. + /// The new instance with the service. + public static IConfiguration WithClipboardProviderFactory(this IConfiguration configuration, IClipboardProviderFactory factory) => + configuration.WithOnly(factory); + + #endregion + + #region Geolocation + + /// + /// Registers the geolocation service using the given platform implementation. + /// + /// The configuration to extend. + /// The platform geolocation implementation. + /// The new instance with the service. + public static IConfiguration WithGeolocation(this IConfiguration configuration, IGeolocationPlatform platform) + { + if (platform is not null) { - var httpClient = new HttpClient(httpMessageHandler); - return configuration.With(new IRequester[] - { - new HttpClientRequester(httpClient), - new DataRequester(), - new FtpRequester(), - new FileRequester(), - new AboutRequester(), - }); + var factory = new GeolocationProviderFactory(platform); + return configuration.WithGeolocationProviderFactory(factory); } - /// - /// Adds the given requester to the configuration. - /// - /// The type of the requester to add. - /// The configuration to use. - /// The requester instance to add. - /// The new configuration. - public static IConfiguration WithRequester(this IConfiguration configuration, T requester) - where T : IRequester => configuration.With(requester); - - /// - /// Adds a new requester of the provided type to the configuration. - /// - /// The type of the requester to add. - /// The configuration to use. - /// The new configuration. - public static IConfiguration WithRequester(this IConfiguration configuration) - where T: IRequester, new() => configuration.WithRequester(new T()); - - #endregion - - #region Cookies - - /// - /// Registers a persistent advanced cookie container using the local file handler. - /// - /// The configuration to extend. - /// The path to the required sync file. - /// The new instance with the service. - public static IConfiguration WithPersistentCookies(this IConfiguration configuration, String syncFilePath) => - configuration.WithCookies(new LocalFileHandler(syncFilePath)); - - /// - /// Registers a non-persistent advanced cookie container using the memory-only file - /// handler. - /// - /// The configuration to extend. - /// The new instance with the service. - public static IConfiguration WithTemporaryCookies(this IConfiguration configuration) => - configuration.WithCookies(new MemoryFileHandler()); - - /// - /// Registers a non-persistent advanced cookie container using the memory-only file - /// handler. - /// Alias for WithTemporaryCookies(). - /// - /// The configuration to extend. - /// The new instance with the service. - public static IConfiguration WithCookies(this IConfiguration configuration) => - configuration.WithTemporaryCookies(); - - /// - /// Registers the advanced cookie service. - /// - /// The configuration to extend. - /// The handler for the cookie source. - /// The new instance with the service. - public static IConfiguration WithCookies(this IConfiguration configuration, ICookieFileHandler fileHandler) => - configuration.WithCookies(new AdvancedCookieProvider(fileHandler)); - - /// - /// Registers a cookie service with the given provider. - /// - /// The configuration to extend. - /// The provider for cookie interactions. - /// The new instance with the service. - public static IConfiguration WithCookies(this IConfiguration configuration, ICookieProvider provider) => - configuration.WithOnly(provider); - - #endregion + return configuration; } -} \ No newline at end of file + + /// + /// Registers a geolocation provider factory. + /// + /// The configuration to extend. + /// The geolocation provider factory to use. + /// The new instance with the service. + public static IConfiguration WithGeolocationProviderFactory(this IConfiguration configuration, IGeolocationProviderFactory factory) => + configuration.WithOnly(factory); + + #endregion +} diff --git a/src/AngleSharp.Io/IoOptions.cs b/src/AngleSharp.Io/IoOptions.cs new file mode 100644 index 0000000..ce4017f --- /dev/null +++ b/src/AngleSharp.Io/IoOptions.cs @@ -0,0 +1,56 @@ +namespace AngleSharp.Io; + +using System.Net.Http; +using AngleSharp.Io.Cache; +using AngleSharp.Io.Cookie; +using AngleSharp.Io.Dom; +using AngleSharp.Io.IndexedDb; +using AngleSharp.Io.Storage; + +/// +/// Options for setting up the Io services. +/// +public sealed class IoOptions +{ + /// + /// Gets or sets the options for configuring the navigator. + /// + public NavigatorOptions Navigator { get; set; } = new NavigatorOptions(); + + /// + /// Gets or sets the handler for configuring the cookie jar. + /// + public ICookieFileHandler CookieHandler { get; set; } = new MemoryFileHandler(); + + /// + /// Gets or sets the handler for configuring the HTTP requester. + /// + public HttpClientHandler HttpHandler { get; set; } = new HttpClientHandler(); + + /// + /// Gets or sets the provider for configuring the web storage provider. + /// + public IStorageProviderFactory StorageFactory { get; set; } = StorageProviderFactory.CreateTemporary(); + + /// + /// Gets or sets the provider for configuring the storage provider. + /// + public IIndexedDbProviderFactory IndexedDbFactory { get; set; } = IndexedDbProviderFactory.CreateTemporary(); + + /// + /// Gets or sets the provider for configuring the cache storage provider. + /// + public ICacheProviderFactory CacheFactory { get; set; } = CacheProviderFactory.CreateTemporary(); + + /// + /// Gets or sets the provider for configuring the clipboard platform. + /// By default, this is null, which means that the clipboard API will not be available. + /// + public IClipboardPlatform ClipboardPlatform { get; set; } = null; + + /// + /// Gets or sets the provider for configuring the geolocation platform. + /// By default, this is null, which means that the geolocation API will not be available. + /// + public IGeolocationPlatform GeolocationPlatform { get; set; } = null; +} diff --git a/src/AngleSharp.Io/NavigatorOptions.cs b/src/AngleSharp.Io/NavigatorOptions.cs new file mode 100644 index 0000000..93da7cb --- /dev/null +++ b/src/AngleSharp.Io/NavigatorOptions.cs @@ -0,0 +1,14 @@ +namespace AngleSharp.Io; + +using System; + +/// +/// Options for setting up the navigator. +/// +public sealed class NavigatorOptions +{ + /// + /// Gets or sets the user agent string to use for the navigator. + /// + public String UserAgent { get; set; } = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.85 Safari/537.36 OPR/32.0.1948.19 (Edition beta)"; +} diff --git a/src/AngleSharp.Io/Network/FileRequester.cs b/src/AngleSharp.Io/Network/FileRequester.cs index be19156..4023ab8 100644 --- a/src/AngleSharp.Io/Network/FileRequester.cs +++ b/src/AngleSharp.Io/Network/FileRequester.cs @@ -1,6 +1,7 @@ namespace AngleSharp.Io.Network { using System; + using System.IO; using System.Net; using System.Threading; using System.Threading.Tasks; @@ -16,22 +17,44 @@ public class FileRequester : BaseRequester /// The options to consider. /// The token for cancelling the task. /// The task that will eventually give the response data. - protected override async Task PerformRequestAsync(Request request, CancellationToken cancel) + protected override Task PerformRequestAsync(Request request, CancellationToken cancel) { - if (FileWebRequest.Create(request.Address.Href) is FileWebRequest requester) + cancel.ThrowIfCancellationRequested(); + + try { - var response = await requester.GetResponseAsync().ConfigureAwait(false); - var content = response.GetResponseStream(); + var uri = new Uri(request.Address.Href, UriKind.Absolute); + + if (uri.IsFile) + { + var content = File.OpenRead(uri.LocalPath); - return new DefaultResponse + return Task.FromResult(new DefaultResponse + { + Address = request.Address, + Content = content, + StatusCode = HttpStatusCode.OK + }); + } + + return Task.FromResult(default); + } + catch (FileNotFoundException) + { + return Task.FromResult(new DefaultResponse { Address = request.Address, - Content = content, - StatusCode = HttpStatusCode.OK - }; + StatusCode = HttpStatusCode.NotFound + }); + } + catch (DirectoryNotFoundException) + { + return Task.FromResult(new DefaultResponse + { + Address = request.Address, + StatusCode = HttpStatusCode.NotFound + }); } - - return default; } /// diff --git a/src/AngleSharp.Io/Network/FtpRequester.cs b/src/AngleSharp.Io/Network/FtpRequester.cs index 17617b9..eedcdff 100644 --- a/src/AngleSharp.Io/Network/FtpRequester.cs +++ b/src/AngleSharp.Io/Network/FtpRequester.cs @@ -18,20 +18,25 @@ public class FtpRequester : BaseRequester /// The task that will eventually give the response data. protected override async Task PerformRequestAsync(Request request, CancellationToken cancel) { + #pragma warning disable SYSLIB0014 if (FtpWebRequest.Create(request.Address.Href) is FtpWebRequest requester) + #pragma warning restore SYSLIB0014 { requester.Method = WebRequestMethods.Ftp.DownloadFile; requester.Credentials = new NetworkCredential("anonymous", String.Empty); - var response = await requester.GetResponseAsync().ConfigureAwait(false); - var content = response.GetResponseStream(); - - return new DefaultResponse + using (cancel.Register(requester.Abort)) { - Address = request.Address, - Content = content, - StatusCode = HttpStatusCode.OK - }; + var response = await requester.GetResponseAsync().ConfigureAwait(false); + var content = response.GetResponseStream(); + + return new DefaultResponse + { + Address = request.Address, + Content = content, + StatusCode = HttpStatusCode.OK + }; + } } return default; diff --git a/src/AngleSharp.Io/Properties/AssemblyInfo.cs b/src/AngleSharp.Io/Properties/AssemblyInfo.cs deleted file mode 100644 index 31d3d1b..0000000 --- a/src/AngleSharp.Io/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -[assembly: AssemblyCopyright("Copyright © AngleSharp, 2013-2019")] -[assembly: ComVisible(false)] -[assembly: InternalsVisibleToAttribute("AngleSharp.Io.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010001adf274fa2b375134e8e4558d606f1a0f96f5cd0c6b99970f7cce9887477209d7c29f814e2508d8bd2526e99e8cd273bd1158a3984f1ea74830ec5329a77c6ff201a15edeb8b36ab046abd1bce211fe8dbb076d7d806f46b15bfda44def04ead0669971e96c5f666c9eda677f28824fff7aa90d32929ed91d529a7a41699893")] diff --git a/src/AngleSharp.Io/Storage/IStorageHandler.cs b/src/AngleSharp.Io/Storage/IStorageHandler.cs new file mode 100644 index 0000000..619100b --- /dev/null +++ b/src/AngleSharp.Io/Storage/IStorageHandler.cs @@ -0,0 +1,25 @@ +namespace AngleSharp.Io.Storage +{ + using System; + using System.Collections.Generic; + + /// + /// Represents a handler for reading and writing storage data per origin. + /// + public interface IStorageHandler + { + /// + /// Reads the storage entries for the given origin. + /// + /// The origin to read. + /// The key-value pairs for the origin. + IEnumerable> Read(String origin); + + /// + /// Writes the storage entries for the given origin. + /// + /// The origin to write. + /// The key-value pairs to write. + void Write(String origin, IEnumerable> entries); + } +} diff --git a/src/AngleSharp.Io/Storage/MemoryStorageHandler.cs b/src/AngleSharp.Io/Storage/MemoryStorageHandler.cs new file mode 100644 index 0000000..59e12e9 --- /dev/null +++ b/src/AngleSharp.Io/Storage/MemoryStorageHandler.cs @@ -0,0 +1,58 @@ +namespace AngleSharp.Io.Storage +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Represents an in-memory storage handler. + /// + public class MemoryStorageHandler : IStorageHandler + { + private readonly Dictionary>> _byOrigin; + + /// + /// Creates a new in-memory storage handler. + /// + public MemoryStorageHandler() + { + _byOrigin = new Dictionary>>(StringComparer.Ordinal); + } + + /// + public IEnumerable> Read(String origin) + { + origin = origin ?? String.Empty; + + if (_byOrigin.TryGetValue(origin, out var entries)) + { + return entries.ToArray(); + } + + return Array.Empty>(); + } + + /// + public void Write(String origin, IEnumerable> entries) + { + origin = origin ?? String.Empty; + + if (entries == null) + { + _byOrigin.Remove(origin); + return; + } + + var values = entries.ToList(); + + if (values.Count == 0) + { + _byOrigin.Remove(origin); + } + else + { + _byOrigin[origin] = values; + } + } + } +} diff --git a/src/AngleSharp.Io/Storage/PersistenceStorageHandler.cs b/src/AngleSharp.Io/Storage/PersistenceStorageHandler.cs new file mode 100644 index 0000000..c91e11d --- /dev/null +++ b/src/AngleSharp.Io/Storage/PersistenceStorageHandler.cs @@ -0,0 +1,110 @@ +namespace AngleSharp.Io.Storage +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Security.Cryptography; + using System.Text; + + /// + /// Represents a persistent storage handler writing files per origin. + /// + public class PersistenceStorageHandler : IStorageHandler + { + private readonly String _directoryPath; + + /// + /// Creates a new persistent storage handler for the given directory. + /// + /// The directory used to store origin-specific files. + public PersistenceStorageHandler(String directoryPath) + { + _directoryPath = directoryPath ?? throw new ArgumentNullException(nameof(directoryPath)); + Directory.CreateDirectory(_directoryPath); + } + + /// + public IEnumerable> Read(String origin) + { + var filePath = GetFilePath(origin); + + if (!File.Exists(filePath)) + { + return Array.Empty>(); + } + + var result = new List>(); + var lines = File.ReadAllLines(filePath); + + foreach (var line in lines) + { + var index = line.IndexOf('\t'); + + if (index <= 0) + { + continue; + } + + var key = Decode(line.Substring(0, index)); + var value = Decode(line.Substring(index + 1)); + result.Add(new KeyValuePair(key, value)); + } + + return result; + } + + /// + public void Write(String origin, IEnumerable> entries) + { + var filePath = GetFilePath(origin); + var values = entries?.ToArray() ?? Array.Empty>(); + + if (values.Length == 0) + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + + return; + } + + var lines = values.Select(m => $"{Encode(m.Key)}\t{Encode(m.Value)}").ToArray(); + File.WriteAllLines(filePath, lines); + } + + private String GetFilePath(String origin) + { + origin = origin ?? String.Empty; + var bytes = Encoding.UTF8.GetBytes(origin); + var hash = ComputeHash(bytes); + var hex = ToHex(hash); + return Path.Combine(_directoryPath, $"{hex}.storage"); + } + + private static Byte[] ComputeHash(Byte[] bytes) + { + using (var sha = SHA256.Create()) + { + return sha.ComputeHash(bytes); + } + } + + private static String ToHex(Byte[] bytes) + { + var sb = new StringBuilder(bytes.Length * 2); + + for (var i = 0; i < bytes.Length; i++) + { + sb.Append(bytes[i].ToString("x2")); + } + + return sb.ToString(); + } + + private static String Encode(String value) => Uri.EscapeDataString(value ?? String.Empty); + + private static String Decode(String value) => Uri.UnescapeDataString(value ?? String.Empty); + } +} diff --git a/src/AngleSharp.Io/Storage/Storage.cs b/src/AngleSharp.Io/Storage/Storage.cs new file mode 100644 index 0000000..06a1edb --- /dev/null +++ b/src/AngleSharp.Io/Storage/Storage.cs @@ -0,0 +1,243 @@ +namespace AngleSharp.Io.Storage +{ + using AngleSharp.Io.Dom; + using System; + using System.Collections.Generic; + + /// + /// Represents a storage view over origin-bound buckets. + /// + public class Storage : ILocalStorage, ISessionStorage + { + private readonly Func _getOrigin; + private readonly Func _getBucket; + private readonly Action _persist; + private readonly Action _persistWithMutation; + + /// + /// Creates a new storage view. + /// + /// Gets the current origin. + /// Gets the storage bucket for the given origin. + /// Persists the bucket for the given origin. + public Storage(Func getOrigin, Func getBucket, Action persist) + : this(getOrigin, getBucket, persist, null) + { + } + + /// + /// Creates a new storage view. + /// + /// Gets the current origin. + /// Gets the storage bucket for the given origin. + /// Persists the bucket for the given origin. + public Storage(Func getOrigin, Func getBucket, Action persist) + { + _getOrigin = getOrigin ?? throw new ArgumentNullException(nameof(getOrigin)); + _getBucket = getBucket ?? throw new ArgumentNullException(nameof(getBucket)); + _persistWithMutation = persist; + } + + private Storage(Func getOrigin, Func getBucket, Action persist, Action persistWithMutation) + { + _getOrigin = getOrigin ?? throw new ArgumentNullException(nameof(getOrigin)); + _getBucket = getBucket ?? throw new ArgumentNullException(nameof(getBucket)); + _persist = persist; + _persistWithMutation = persistWithMutation; + } + + /// + public Int32 Length => CurrentBucket.Length; + + /// + public String Key(Int32 index) => CurrentBucket.Key(index); + + /// + public String this[String key] + { + get => CurrentBucket.Get(key); + set + { + if (String.IsNullOrEmpty(key)) + { + throw new ArgumentNullException(nameof(key)); + } + + var origin = CurrentOrigin; + var bucket = _getBucket.Invoke(origin); + var oldValue = bucket.Get(key); + bucket.Set(key, value); + Persist(origin, bucket, key, oldValue, value); + } + } + + /// + public void Remove(String key) + { + if (String.IsNullOrEmpty(key)) + { + return; + } + + var origin = CurrentOrigin; + var bucket = _getBucket.Invoke(origin); + var oldValue = bucket.Get(key); + + if (bucket.Remove(key)) + { + Persist(origin, bucket, key, oldValue, null); + } + } + + /// + public void Clear() + { + var origin = CurrentOrigin; + var bucket = _getBucket.Invoke(origin); + + if (bucket.Length == 0) + { + return; + } + + bucket.Clear(); + Persist(origin, bucket, null, null, null); + } + + private String CurrentOrigin => _getOrigin.Invoke() ?? String.Empty; + + private StorageBucket CurrentBucket => _getBucket.Invoke(CurrentOrigin); + + private void Persist(String origin, StorageBucket bucket, String key, String oldValue, String newValue) + { + if (_persistWithMutation != null) + { + _persistWithMutation(origin, bucket, key, oldValue, newValue); + return; + } + + _persist?.Invoke(origin, bucket); + } + } + + /// + /// Represents the stored key-value pairs of one origin. + /// + public sealed class StorageBucket + { + private readonly Dictionary _entries; + private readonly List _keys; + + /// + /// Creates a new bucket. + /// + public StorageBucket() + { + _entries = new Dictionary(StringComparer.Ordinal); + _keys = new List(); + } + + /// + /// Creates a new bucket from existing entries. + /// + /// The entries used to initialize the bucket. + public StorageBucket(IEnumerable> entries) + : this() + { + if (entries != null) + { + foreach (var entry in entries) + { + Set(entry.Key, entry.Value); + } + } + } + + /// + /// Gets the number of keys. + /// + public Int32 Length => _keys.Count; + + /// + /// Gets the key at the given index. + /// + /// The index of the key. + /// The key or null. + public String Key(Int32 index) + { + if (index < 0 || index >= _keys.Count) + { + return null; + } + + return _keys[index]; + } + + /// + /// Gets the value of the given key. + /// + /// The key to retrieve. + /// The value or null. + public String Get(String key) + { + if (String.IsNullOrEmpty(key)) + { + return null; + } + + return _entries.TryGetValue(key, out var value) ? value : null; + } + + /// + /// Sets the value of the given key. + /// + /// The key to set. + /// The value to set. + public void Set(String key, String value) + { + if (!_entries.ContainsKey(key)) + { + _keys.Add(key); + } + + _entries[key] = value; + } + + /// + /// Removes the given key. + /// + /// The key to remove. + public Boolean Remove(String key) + { + if (_entries.Remove(key)) + { + _keys.Remove(key); + return true; + } + + return false; + } + + /// + /// Clears the bucket. + /// + public void Clear() + { + _entries.Clear(); + _keys.Clear(); + } + + /// + /// Gets the entries in key order. + /// + /// The entries. + public IEnumerable> ToEntries() + { + for (var i = 0; i < _keys.Count; i++) + { + var key = _keys[i]; + yield return new KeyValuePair(key, _entries[key]); + } + } + } +} diff --git a/src/AngleSharp.Io/Storage/StorageProviderFactory.cs b/src/AngleSharp.Io/Storage/StorageProviderFactory.cs new file mode 100644 index 0000000..43cb6c7 --- /dev/null +++ b/src/AngleSharp.Io/Storage/StorageProviderFactory.cs @@ -0,0 +1,302 @@ +namespace AngleSharp.Io.Storage +{ + using AngleSharp.Dom; + using AngleSharp.Dom.Events; + using AngleSharp.Io.Dom; + using System; + using System.Collections.Generic; + using System.Runtime.CompilerServices; + + /// + /// Represents the default storage provider factory with origin- and browsing-context-dependent views. + /// + public class StorageProviderFactory : IStorageProviderFactory + { + private readonly ConditionalWeakTable _views; + private readonly List> _windows; + private readonly Dictionary _storageByScopeAndOrigin; + + private IStorageHandler _localStorageHandler; + private IStorageHandler _sessionStorageHandler; + + private Boolean _hasLocalStorage; + private Boolean _hasSessionStorage; + + /// + /// Creates a new storage provider factory with temporary local storage and session storage enabled. + /// + /// The new storage provider factory. + public static IStorageProviderFactory CreateTemporary() + { + var factory = new StorageProviderFactory(); + factory.EnableTemporaryLocalStorage(); + factory.EnableSessionStorage(); + return factory; + } + + /// + /// Creates a new storage provider factory with persistent local storage and session storage enabled. + /// + /// The path used for local storage synchronization. + /// The new storage provider factory. + public static IStorageProviderFactory CreatePersistent(String syncPath) + { + var factory = new StorageProviderFactory(); + factory.EnableLocalStorage(syncPath); + factory.EnableSessionStorage(); + return factory; + } + + /// + /// Creates a new storage provider factory. + /// + public StorageProviderFactory() + { + _views = new ConditionalWeakTable(); + _windows = new List>(); + _storageByScopeAndOrigin = new Dictionary(StringComparer.Ordinal); + } + + /// + /// Enables local storage synchronization with the given directory. + /// + /// The directory used for local storage synchronization. + public void EnableLocalStorage(String syncDirectoryPath) + { + EnableLocalStorage(new PersistenceStorageHandler(syncDirectoryPath)); + } + + /// + /// Enables local storage synchronization with the given storage handler. + /// + /// The handler used for local storage synchronization. + public void EnableLocalStorage(IStorageHandler handler) + { + _hasLocalStorage = true; + _localStorageHandler = handler ?? throw new ArgumentNullException(nameof(handler)); + ClearStorage(ScopeLocal); + } + + /// + /// Enables in-memory local storage. + /// + public void EnableTemporaryLocalStorage() + { + _hasLocalStorage = true; + _localStorageHandler = new MemoryStorageHandler(); + ClearStorage(ScopeLocal); + } + + /// + /// Enables session storage with the given storage handler. + /// + /// The handler used for session storage. + public void EnableSessionStorage(IStorageHandler handler) + { + _hasSessionStorage = true; + _sessionStorageHandler = handler ?? throw new ArgumentNullException(nameof(handler)); + ClearStorage(ScopeSession); + } + + /// + /// Enables in-memory session storage. + /// + public void EnableSessionStorage() + { + EnableSessionStorage(new MemoryStorageHandler()); + } + + /// + public StorageViews GetStorages(IWindow window) + { + if (window == null) + { + return null; + } + + TrackWindow(window); + return _views.GetValue(window, CreateStorageViews); + } + + private StorageViews CreateStorageViews(IWindow window) + { + var getOrigin = new Func(() => GetOrigin(window)); + + var local = _hasLocalStorage + ? new Storage(getOrigin, origin => GetOrCreateStorage(ScopeLocal, window, origin), (origin, storage, key, oldValue, newValue) => + { + PersistLocalStorage(origin, storage); + NotifyStorageEvent(window, ScopeLocal, origin, key, oldValue, newValue); + }) + : null; + var session = _hasSessionStorage + ? new Storage(getOrigin, origin => GetOrCreateStorage(ScopeSession, window, origin), (origin, storage, key, oldValue, newValue) => + { + PersistSessionStorage(window, origin, storage); + NotifyStorageEvent(window, ScopeSession, origin, key, oldValue, newValue); + }) + : null; + + return new StorageViews(local, session); + } + + private StorageBucket GetOrCreateStorage(String scope, IWindow window, String origin) + { + origin = origin ?? String.Empty; + var key = GetStorageKey(scope, window, origin); + var handler = scope == ScopeLocal ? _localStorageHandler : _sessionStorageHandler; + + if (!_storageByScopeAndOrigin.TryGetValue(key, out var storage)) + { + var entries = handler?.Read(GetStorageHandlerKey(scope, window, origin)); + storage = new StorageBucket(entries); + _storageByScopeAndOrigin[key] = storage; + } + + return storage; + } + + private void PersistLocalStorage(String origin, StorageBucket storage) + { + _localStorageHandler?.Write(origin, storage.ToEntries()); + } + + private void PersistSessionStorage(IWindow window, String origin, StorageBucket storage) + { + _sessionStorageHandler?.Write(GetStorageHandlerKey(ScopeSession, window, origin), storage.ToEntries()); + } + + private void NotifyStorageEvent(IWindow sourceWindow, String scope, String origin, String key, String oldValue, String newValue) + { + var sourceKey = GetStorageKey(scope, sourceWindow, origin); + + CleanupWindows(); + + for (var i = 0; i < _windows.Count; i++) + { + if (!_windows[i].TryGetTarget(out var target)) + { + continue; + } + + if (ReferenceEquals(target, sourceWindow)) + { + continue; + } + + var targetOrigin = GetOrigin(target); + var targetKey = GetStorageKey(scope, target, targetOrigin); + + if (!String.Equals(sourceKey, targetKey, StringComparison.Ordinal)) + { + continue; + } + + target.FireSimpleEvent(EventNames.Storage, false, false); + } + } + + private void TrackWindow(IWindow window) + { + CleanupWindows(); + + for (var i = 0; i < _windows.Count; i++) + { + if (_windows[i].TryGetTarget(out var target) && ReferenceEquals(target, window)) + { + return; + } + } + + _windows.Add(new WeakReference(window)); + } + + private void CleanupWindows() + { + for (var i = _windows.Count - 1; i >= 0; i--) + { + if (!_windows[i].TryGetTarget(out var _)) + { + _windows.RemoveAt(i); + } + } + } + + private void ClearStorage(String scope) + { + var keys = new List(); + + foreach (var key in _storageByScopeAndOrigin.Keys) + { + if (key.StartsWith(scope, StringComparison.Ordinal)) + { + keys.Add(key); + } + } + + foreach (var key in keys) + { + _storageByScopeAndOrigin.Remove(key); + } + } + + private const String ScopeLocal = "l|"; + private const String ScopeSession = "s|"; + + private static String GetStorageKey(String scope, IWindow window, String origin) + { + if (scope == ScopeLocal) + { + return scope + origin; + } + + var root = GetRootContext(window?.Document?.Context); + var rootKey = root == null ? String.Empty : RuntimeHelpers.GetHashCode(root).ToString(System.Globalization.CultureInfo.InvariantCulture); + + return scope + rootKey + "|" + origin; + } + + private static String GetStorageHandlerKey(String scope, IWindow window, String origin) + { + if (scope == ScopeLocal) + { + return origin; + } + + var root = GetRootContext(window?.Document?.Context); + var rootKey = root == null ? String.Empty : RuntimeHelpers.GetHashCode(root).ToString(System.Globalization.CultureInfo.InvariantCulture); + + return rootKey + "|" + origin; + } + + private static AngleSharp.IBrowsingContext GetRootContext(AngleSharp.IBrowsingContext context) + { + while (context?.Parent != null) + { + context = context.Parent; + } + + return context; + } + + private static String GetOrigin(IWindow window) + { + var href = window?.Location?.Href; + + if (String.IsNullOrEmpty(href)) + { + return String.Empty; + } + + var url = new Url(href); + + if (url.IsInvalid || url.IsRelative) + { + return String.Empty; + } + + return url.Origin ?? String.Empty; + } + + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 1f27205..e7d70ce 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,6 +2,10 @@ Providers additional requesters and IO helpers for AngleSharp. AngleSharp.Io - 0.17.0 + 1.1.0 + latest + true + true + $(MSBuildThisFileDirectory)\Key.snk \ No newline at end of file diff --git a/src/AngleSharp.Io/Key.snk b/src/Key.snk similarity index 100% rename from src/AngleSharp.Io/Key.snk rename to src/Key.snk