diff --git a/CLAUDE.md b/CLAUDE.md index e03dc82..860ae74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,12 +51,12 @@ dotnet test inside the usage policy. One deletion besides: the right-click Delete of a session removes its transcript `.jsonl` and its `\` folder (`SessionCatalog.Delete`), only after a confirm and never while a claude process or tab has the session; it refuses any path outside the account's `projects\\.jsonl`. -- **The browser tool drives only the Browser panel's WebView2** (its own user-data folder) through in-process CDP. Never the +- **The browser tool drives only the Browser panel's WebView2** (its own user-data folder) through in-process CDP, one WebView2 per tab on the panel's one profile. Never the terminal's WebView2, never a remote debugging port. The pipe is `CurrentUserOnly`, and the only launch flags it adds are `--mcp-config` and `--append-system-prompt`. `browserTool` off: new tabs get no flags and `BrowserMcp` refuses calls from open ones. - Hardened: each tools/call has a 60 s limit, `navigate` and `window.open` pass only http, https, blob, about:blank and data - (`BrowserPanel.SchemeAllowed`), every tool but `navigate` refuses while the panel shows any other page (`OnUi`, checked before and after the body, whatever got it - there: history, the address bar), and `devtools` is an allow-list by domain (`BrowserMcp.DevToolsRefusal`) that denies cookies, + Hardened: each tools/call has a 60 s limit, `navigate`, `new_tab` and `window.open` (which opens a tab only when user-initiated, max 20, else dropped; `window.close()` closes only such pop-up tabs, and `DropControlsCloseHandler` stops WPF's WebView2 closing the whole window on it) pass only http, https, blob, + about:blank and data (`BrowserPanel.SchemeAllowed`), every page tool but `navigate` refuses while the front tab shows any other page (`OnUi` captures the front tab once per call and runs both checks, before and after the body, on that tab, whatever got it + there: history, the address bar; `tabs` lists such a tab as "(not a web page)", without title or URL), and `devtools` is an allow-list by domain (`BrowserMcp.DevToolsRefusal`) that denies cookies, downloads, local files, new-document scripts, history navigation and the whole Input domain (keyboard, mouse, touch, drag: it would paste the clipboard). `evaluate` runs with the page's own access. (PRIVACY.md and the README say so too, keep them in step). - **Changes go through Claude Code's own CLI.** Plugins & connectors runs `claude plugin …` and `claude mcp …` hidden diff --git a/PRIVACY.md b/PRIVACY.md index b484383..aec7b23 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -46,7 +46,7 @@ If you write to the publisher (see Contact), the publisher handles your message ### 3. Websites you open - The Browser panel is a WebView2 browser. It loads only what you open in it, or what Claude opens in it (next point). Those sites see what any website sees, and may set cookies. -- Claude can use the Browser panel: each session gets it as a local MCP server (`ccwrapper-browser`), unless you turn it off in Settings → CCWrapper (`browserTool` in `app.json`). CCWrapper itself sends nothing new. The pages load only when Claude or you open them, in the panel's profile for the account, which is stored on your computer in `%LOCALAPPDATA%\CCWrapper\WebView2\browser-`, so those sites see your logins there, and what Claude runs in a page (the `evaluate` and `devtools` tools) acts with them. `devtools` refuses cookie access, downloads, local files, all simulated input (keyboard, mouse, touch and drag, so it cannot paste your clipboard), the page history and scripts that persist across pages. Claude opens only `http`, `https`, `blob`, `about:blank` and `data` addresses, not local files, and its tools refuse to work on a page that is not a web address (for example a `file:` page you opened in the address bar yourself). The tool talks to its CCWrapper window through a named pipe on your own computer that only your Windows user can open. What Claude Code does with a page's text, screenshots or script results is covered in section 2, and Claude Code asks your permission per tool. +- Claude can use the Browser panel: each session gets it as a local MCP server (`ccwrapper-browser`), unless you turn it off in Settings → CCWrapper (`browserTool` in `app.json`). CCWrapper itself sends nothing new. The pages load only when Claude or you open them, in the panel's profile for the account, which is stored on your computer in `%LOCALAPPDATA%\CCWrapper\WebView2\browser-`, so those sites see your logins there, and what Claude runs in a page (the `evaluate` and `devtools` tools) acts with them. `devtools` refuses cookie access, downloads, local files, all simulated input (keyboard, mouse, touch and drag, so it cannot paste your clipboard), the page history and scripts that persist across pages. The panel has tabs, all in that one profile; a pop-up opened by a click opens a new tab (at most 20), one a page opens on its own is dropped, and a page's `window.close()` closes only a pop-up tab. Claude opens only `http`, `https`, `blob`, `about:blank` and `data` addresses, not local files, and a pop-up to any other address is dropped. Its tools act on the tab in front, can list, open, switch and close tabs (a tab that is not a web page is listed without title or address), and refuse to work on a page that is not a web address (for example a `file:` page you opened in the address bar yourself). All of this holds in every tab. The tool talks to its CCWrapper window through a named pipe on your own computer that only your Windows user can open. What Claude Code does with a page's text, screenshots or script results is covered in section 2, and Claude Code asks your permission per tool. - A link in the terminal opens in the Browser panel if it points to your own computer (`localhost`), and in your default browser otherwise. Only `http` and `https` links open. - Each account has its own browser profile, stored on your computer (see below). diff --git a/README.md b/README.md index 2f1a6d7..460a110 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![Platform](https://img.shields.io/badge/platform-Windows%2010%20%7C%2011-0078D6) ![.NET](https://img.shields.io/badge/.NET-10-512BD4) ![License](https://img.shields.io/badge/license-GPL--3.0-blue) -![Tests](https://img.shields.io/badge/tests-422%20passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-441%20passing-brightgreen) Claude Code in a bare PowerShell window works, but it's clumsy. There's no search, you can't paste screenshots or files with Ctrl+V, you can't see your other sessions, and context, usage, file changes and subagents are hard to keep an eye on. If you also juggle **several Claude Code accounts** (work and personal, for example), it gets worse. @@ -91,7 +91,7 @@ Confirmations, warnings and errors use the app's dark theme too. **Right panel** - **Files:** a tree of every file Claude changed this session, with `+/−` lines in green and red, or the size change for binaries. Click a file to see all of it with the changes highlighted. - **Agents:** this session's subagents, running or finished, with model, effort, context and tokens. Click one to see its live transcript. -- **Browser:** a built-in WebView2 browser for your dev server. Claude browses in it too: every session can navigate, read the page, click, type, take screenshots, run scripts and use DevTools there, and is told to prefer it over another browser. Ask for Chrome by name and it uses Chrome. Scripts and DevTools act with the page's own logins in that profile; DevTools refuses cookie access, downloads, local files, all simulated input (keyboard, mouse, touch and drag, so it cannot paste your clipboard), the page history and scripts that persist across pages. Claude opens only http, https, blob, about:blank and data addresses, never local files, and its tools refuse to work on a page that is not a web address (for example a `file:` page you opened yourself). To turn this off, clear **Let Claude use the Browser panel** in Settings → CCWrapper. +- **Browser:** a built-in WebView2 browser for your dev server, with tabs (the **+** button opens one, up to 20; tabs are not restored after a restart). A pop-up a click opens appears in a new tab; pop-ups a page opens on its own are dropped, and a page's `window.close()` closes only pop-up tabs. Claude browses in it too: every session can navigate, read the page, click, type, take screenshots, run scripts, use DevTools, and list, open, switch and close tabs there, and is told to prefer it over another browser. Ask for Chrome by name and it uses Chrome. Scripts and DevTools act with the page's own logins in that profile; DevTools refuses cookie access, downloads, local files, all simulated input (keyboard, mouse, touch and drag, so it cannot paste your clipboard), the page history and scripts that persist across pages. Claude opens only http, https, blob, about:blank and data addresses, never local files (a pop-up to any other address is dropped), and its tools refuse to work on a page that is not a web address (for example a `file:` page you opened yourself). Its tools act on the tab in front. To turn this off, clear **Let Claude use the Browser panel** in Settings → CCWrapper. **Bottom bar** - Model, effort and permission-mode switches that drive Claude Code's own `/model`, `/effort` and Shift+Tab. @@ -187,7 +187,7 @@ CCWrapper is a viewer and a launcher. It doesn't touch your logins. - **Check health** in Plugins & connectors runs `claude mcp list`, which connects to every MCP server (and starts the local ones) to see if it answers, as `/mcp` does. - **Otherwise it writes only to** `%LOCALAPPDATA%\CCWrapper`: pasted images, the browser profiles, settings backups, `app.json` (CCWrapper's own settings: your update choice, the compact hint and the browser choice), and the live status data described next. - **The status line:** if an account has no `statusLine` of its own, each session gets a tiny silent relay (`CCWrapper.StatusRelay.exe`) through `--settings`, which feeds the bottom bar. If you have your own status line, it's left alone. -- **The Browser tool:** each session also gets that relay as a local MCP server (`ccwrapper-browser`, through `--mcp-config`), and a line in its system prompt (`--append-system-prompt`) to prefer it. The server talks only to its own CCWrapper window, through a local pipe that only your Windows user can open. Pages Claude opens load in the Browser panel's profile for the account, with your logins, and Claude Code asks permission per tool as it does for any MCP tool. Its scripts and DevTools act with those logins, but DevTools refuses cookie access, downloads, local files, all simulated input (keyboard, mouse, touch and drag), the page history and scripts that persist across pages. Claude opens only http, https, blob, about:blank and data addresses, not local files, and its tools refuse to work on a page that is not a web address. Turn it off in Settings → CCWrapper. +- **The Browser tool:** each session also gets that relay as a local MCP server (`ccwrapper-browser`, through `--mcp-config`), and a line in its system prompt (`--append-system-prompt`) to prefer it. The server talks only to its own CCWrapper window, through a local pipe that only your Windows user can open. Pages Claude opens load in the Browser panel's profile for the account (all tabs share it), with your logins, and Claude Code asks permission per tool as it does for any MCP tool. Its scripts and DevTools act with those logins, but DevTools refuses cookie access, downloads, local files, all simulated input (keyboard, mouse, touch and drag), the page history and scripts that persist across pages. Claude opens only http, https, blob, about:blank and data addresses, not local files, and its tools refuse to work on a page that is not a web address. The same holds for every tab. Turn it off in Settings → CCWrapper. - **Its only network call is the update check,** and only in an installed copy, only after you said yes on first start; the switch is in **About**. It asks GitHub for the latest release and downloads it; GitHub sees your IP address and the app version, nothing else is sent. The Browser panel loads only what you or Claude open in it. Details in [PRIVACY.md](PRIVACY.md). - **Plan usage is checked with Claude Code's own `/usage`:** a hidden `claude "/usage"` (MCP servers off) runs in the folder of the account's most recent session, and Claude Code saves the result in `.claude.json`. Each check takes a few seconds, adds `/usage` to that folder's prompt history (Up arrow), and runs your startup hooks like any `claude` start, so automatic checks are skipped when the last one is under 10 minutes old. A check that can't finish in 30 seconds (not logged in, a trust question) is stopped and the last numbers stay. - Every session is the unmodified `claude.exe`, run under your own login. @@ -222,7 +222,7 @@ Every step is in the history: `git log --first-parent` shows the merge of each t - [xterm.js](https://xtermjs.org/) 6 (the VS Code terminal) in WebView2; - [Porta.Pty](https://www.nuget.org/packages/Porta.Pty) for ConPTY; - [DiffPlex](https://github.com/mmanela/diffplex) for diffs; -- xUnit, with 422 tests on synthetic fixtures. +- xUnit, with 441 tests on synthetic fixtures. ## Development diff --git a/src/CCWrapper/Terminal/BrowserMcp.cs b/src/CCWrapper/Terminal/BrowserMcp.cs index d75631f..6f6f9f9 100644 --- a/src/CCWrapper/Terminal/BrowserMcp.cs +++ b/src/CCWrapper/Terminal/BrowserMcp.cs @@ -9,7 +9,9 @@ namespace CCWrapper.Terminal; /// limit is abandoned (its token cancelled) and the next one may start while it lingers. A failure throws, and the /// exception's message is what Claude reads, so make it say what went wrong ("No element matches #login"). /// A target that is all ASCII digits is a ref number from the latest ; anything -/// else is a CSS selector, and the first element it matches is used. A ref that no longer exists throws. +/// else is a CSS selector, and the first element it matches is used. A ref that no longer exists throws. +/// The panel has tabs of its own, each with an id (1, 2, 3…, never reused). Every member but the four tab members acts on +/// the front tab; a page's target=_blank or window.open opens a new tab in front. public interface IBrowserDriver { /// Opens and waits for the page to load. Returns a short text for Claude, e.g. the final @@ -37,6 +39,18 @@ public interface IBrowserDriver /// Calls one Chrome DevTools Protocol method on the page ( is a JSON object) and /// returns its JSON result. Task DevToolsAsync(string method, string paramsJson, CancellationToken ct); + + /// The open tabs as text: id, title and URL of each, and which one is in front. + Task TabsAsync(CancellationToken ct); + + /// Opens a new tab in front, loads in it and waits for the page to load. + Task NewTabAsync(string url, CancellationToken ct); + + /// Brings tab to front. An unknown id throws. + Task SwitchTabAsync(int id, CancellationToken ct); + + /// Closes tab ; closing the last one leaves one blank tab. An unknown id throws. + Task CloseTabAsync(int id, CancellationToken ct); } /// The MCP server of the Browser tool, without its transport: one newline-delimited JSON-RPC 2.0 message in, zero @@ -136,6 +150,10 @@ async Task CallAsync(JsonNode? id, JsonObject? p, CancellationToken ct) }), (a, c) => DevToolsRefusal(a.Method) is { } why ? Task.FromException(new InvalidOperationException(why)) : driver.DevToolsAsync(a.Method, a.Params, c)), + "tabs" => c => TextResult(driver.TabsAsync(c)), + "new_tab" => Run(args["url"] is null ? "about:blank" : Arg(args, "url"), driver.NewTabAsync), + "switch_tab" => Run(Id(args), driver.SwitchTabAsync), + "close_tab" => Run(Id(args), driver.CloseTabAsync), var name => throw new ArgumentException("Unknown tool: " + name), }; } @@ -163,6 +181,9 @@ JsonValue v when v.GetValueKind() == JsonValueKind.Number => v.ToJsonString(), _ => throw new ArgumentException($"Missing or invalid '{key}'."), }; + static int Id(JsonObject args) => args["id"] is JsonValue v && v.GetValueKind() == JsonValueKind.Number && v.TryGetValue(out int id) + ? id : throw new ArgumentException("Missing or invalid 'id': a tab id (an integer) from the tabs tool."); + static Func> Run(T args, Func> call) => c => TextResult(call(args, c)); static string? Text(JsonNode? node) => node is JsonValue v && v.TryGetValue(out string? s) ? s : null; @@ -184,35 +205,49 @@ static string Result(JsonNode? id, JsonNode result) => static string Error(JsonNode? id, int code, string message) => new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id?.DeepClone(), ["error"] = new JsonObject { ["code"] = code, ["message"] = message } }.ToJsonString(); + const string TabId = """{ "type": "integer", "description": "A tab id from the tabs tool." }"""; + const string Target = """{ "type": "string", "description": "A ref number from the latest snapshot (e.g. \"12\"), or a CSS selector." }"""; const string Tools = $$""" [ { "name": "navigate", - "description": "Open a URL in CCWrapper's Browser panel (the browser inside this app, beside the terminal) and wait for it to load.", + "description": "Open a URL in the front tab of CCWrapper's Browser panel (the browser inside this app, beside the terminal) and wait for it to load. The panel has tabs: every tool but tabs, new_tab, switch_tab and close_tab acts on the front tab, and a link or window.open that opens a new window opens a new tab in front.", "inputSchema": { "type": "object", "properties": { "url": { "type": "string", "description": "The address to open, e.g. https://example.com." } }, "required": ["url"] } }, { "name": "snapshot", - "description": "Read the current page as text: headings, text, links, buttons and form fields, each element you can click or type into tagged with a ref number like [12]. Take a new snapshot after the page changes: each one renumbers the refs.", + "description": "Read the front tab's page as text: headings, text, links, buttons and form fields, each element you can click or type into tagged with a ref number like [12]. Take a new snapshot after the page changes: each one renumbers the refs.", "inputSchema": { "type": "object", "properties": {} } }, { "name": "click", - "description": "Click an element of the current page.", + "description": "Click an element of the front tab's page. If it opens a new tab, that tab comes to front and the result says so.", "inputSchema": { "type": "object", "properties": { "target": {{Target}} }, "required": ["target"] } }, { "name": "type", - "description": "Replace the value of an input, textarea or editable element with text, as if typed. Set submit to press Enter afterwards.", + "description": "Replace the value of an input, textarea or editable element of the front tab's page with text, as if typed. Set submit to press Enter afterwards.", "inputSchema": { "type": "object", "properties": { "target": {{Target}}, "text": { "type": "string", "description": "The text to type." }, "submit": { "type": "boolean", "description": "Press Enter after typing. Default false." } }, "required": ["target", "text"] } }, { "name": "screenshot", - "description": "Capture the visible part of the current page as a PNG image.", + "description": "Capture the visible part of the front tab's page as a PNG image.", "inputSchema": { "type": "object", "properties": {} } }, { "name": "evaluate", - "description": "Run a JavaScript expression in the current page and return its value as JSON. A returned Promise is awaited.", + "description": "Run a JavaScript expression in the front tab's page and return its value as JSON. A returned Promise is awaited.", "inputSchema": { "type": "object", "properties": { "expression": { "type": "string", "description": "The JavaScript expression, e.g. document.title." } }, "required": ["expression"] } }, { "name": "devtools", - "description": "Call a Chrome DevTools Protocol method on the current page (e.g. Emulation.setDeviceMetricsOverride, Network.getResponseBody) and return its JSON result. For what the other tools cannot do. {{DevToolsLimits}}", + "description": "Call a Chrome DevTools Protocol method on the front tab's page (e.g. Emulation.setDeviceMetricsOverride, Network.getResponseBody) and return its JSON result. For what the other tools cannot do. {{DevToolsLimits}}", "inputSchema": { "type": "object", "properties": { "method": { "type": "string", "description": "The CDP method, Domain.method." }, - "params": { "type": "object", "description": "The method's parameters. Default {}." } }, "required": ["method"] } } + "params": { "type": "object", "description": "The method's parameters. Default {}." } }, "required": ["method"] } }, + { "name": "tabs", + "description": "List the Browser panel's tabs: the id, title and URL of each, and which one is in front (the one the other tools act on). A tab showing a page that is not a web address lists as (not a web page).", + "inputSchema": { "type": "object", "properties": {} } }, + { "name": "new_tab", + "description": "Open a new tab in front and load a URL in it; the other tools then act on it. At most 20 tabs.", + "inputSchema": { "type": "object", "properties": { "url": { "type": "string", "description": "The address to open, e.g. https://example.com. Default about:blank." } } } }, + { "name": "switch_tab", + "description": "Bring a tab to front, so the other tools act on it.", + "inputSchema": { "type": "object", "properties": { "id": {{TabId}} }, "required": ["id"] } }, + { "name": "close_tab", + "description": "Close a tab. Closing the last tab leaves one blank tab.", + "inputSchema": { "type": "object", "properties": { "id": {{TabId}} }, "required": ["id"] } } ] """; } diff --git a/src/CCWrapper/Terminal/Smoke.cs b/src/CCWrapper/Terminal/Smoke.cs index abeaf2e..6bc8fe7 100644 --- a/src/CCWrapper/Terminal/Smoke.cs +++ b/src/CCWrapper/Terminal/Smoke.cs @@ -81,12 +81,14 @@ static async Task TalkAsync(Process relay) var list = await Ask("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}"""); var call = await Ask("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"snapshot","arguments":{}}}"""); return init?["result"]?["serverInfo"]?["name"]?.GetValue() == "ccwrapper-browser" - && list?["result"]?["tools"]?.AsArray().Count == 7 + && list?["result"]?["tools"]?.AsArray().Count == 11 && call?["id"]?.GetValue() == 3 && call?["result"]?["content"]?[0]?["text"]?.GetValue() == "smoke-snapshot"; } - // The real Browser panel in a throwaway profile: snapshot refs, typing, clicking and evaluating on a data: page. + // The real Browser panel in a throwaway profile: snapshot refs, typing, clicking and evaluating on a data: page; then tabs: + // a click's window.open that becomes a tab with its opener's window object, a timer's that is dropped, new_tab, switch_tab, + // window.close() (pop-up tabs only) and close_tab. static async Task PanelAsync() { var dir = Path.Combine(Path.GetTempPath(), "ccw-smoke-browser"); @@ -99,14 +101,15 @@ static async Task PanelAsync() ShowInTaskbar = false, ShowActivated = false, }.Show(); var run = PanelStepsAsync(panel); - if (await Task.WhenAny(run, Task.Delay(TimeSpan.FromSeconds(40))) == run && await run) return true; + if (await Task.WhenAny(run, Task.Delay(TimeSpan.FromSeconds(60))) == run && await run) return true; Console.Error.WriteLine("smoke: the Browser panel steps failed"); return false; } static async Task PanelStepsAsync(IBrowserDriver panel) { - const string html = "

Smoke

link"; + const string html = "

Smoke

link" + + ""; await panel.NavigateAsync("data:text/html," + Uri.EscapeDataString(html), default); var snap = await panel.SnapshotAsync(default); var button = System.Text.RegularExpressions.Regex.Match(snap, @"\[(\d+)\] button ""Go"""); @@ -117,7 +120,42 @@ static async Task PanelStepsAsync(IBrowserDriver panel) var title = await panel.EvaluateAsync("document.title", default); try { await panel.ClickAsync("999", default); return false; } catch (InvalidOperationException ex) when (ex.Message.StartsWith("No element matches")) { } - return title == "\"clicked:hello\""; + if (title != "\"clicked:hello\"") return false; + return await TabStepsAsync(panel); + } + + static async Task TabStepsAsync(IBrowserDriver panel) + { + if (!(await panel.ClickAsync("#pop", default)).Contains("Tab 2 is now in front") || !(await panel.TabsAsync(default)).Contains("Tab 2 (front): ")) return false; + await panel.SwitchTabAsync(1, default); + if (await panel.EvaluateAsync("!!window.w && !window.w.closed", default) != "true") return false; // the popup tab kept its opener link + var dropped = await panel.ClickAsync("#bad", default); // an ftp: popup is refused (a file: one never reaches NewWindowRequested): no new tab + var listed = await panel.TabsAsync(default); + if (dropped.Contains("is now in front") || !listed.Contains("Tab 2") || listed.Contains("Tab 3")) return false; + // a page that opens a window on its own timer, with no click: dropped, so no tab 4 and the front stays + var opened = await panel.NewTabAsync("data:text/html," + Uri.EscapeDataString("Two

Second

"), default); + if (!opened.StartsWith("Opened tab 3 in front. Loaded") || await panel.EvaluateAsync("document.title", default) != "\"Two\"") return false; + await Task.Delay(1000); + if ((await panel.TabsAsync(default)).Contains("Tab 4")) return false; + await panel.SwitchTabAsync(1, default); + await panel.SwitchTabAsync(3, default); + var tabs = await panel.TabsAsync(default); + if (!tabs.Contains("Tab 1: clicked:hello - data:") || !tabs.Contains("Tab 3 (front): Two - data:")) return false; + // window.close() leaves a tab Claude or the user opened (one history entry, which Chromium lets a script close) and closes a pop-up tab + await panel.EvaluateAsync("setTimeout(() => window.close(), 300)", default); + await Task.Delay(1000); + if (!(await panel.TabsAsync(default)).Contains("Tab 3 (front)")) return false; + await panel.SwitchTabAsync(2, default); + await panel.EvaluateAsync("setTimeout(() => window.close(), 300)", default); + await Task.Delay(1000); + var afterPopup = await panel.TabsAsync(default); + if (afterPopup.Contains("Tab 2") || !afterPopup.Contains("Tab 1 (front)")) return false; // tab 3 is its strip neighbour, but tab 1 opened it + await panel.CloseTabAsync(3, default); + var left = await panel.TabsAsync(default); + if (left.Contains("Tab 2") || left.Contains("Tab 3") || !left.Contains("Tab 1 (front)")) return false; + try { await panel.SwitchTabAsync(3, default); return false; } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("There is no tab 3")) { } + return (await panel.SnapshotAsync(default)).Contains("# Smoke"); } sealed class SmokeDriver : IBrowserDriver @@ -129,5 +167,9 @@ sealed class SmokeDriver : IBrowserDriver public Task ScreenshotAsync(CancellationToken ct) => throw new NotSupportedException(); public Task EvaluateAsync(string expression, CancellationToken ct) => throw new NotSupportedException(); public Task DevToolsAsync(string method, string paramsJson, CancellationToken ct) => throw new NotSupportedException(); + public Task TabsAsync(CancellationToken ct) => throw new NotSupportedException(); + public Task NewTabAsync(string url, CancellationToken ct) => throw new NotSupportedException(); + public Task SwitchTabAsync(int id, CancellationToken ct) => throw new NotSupportedException(); + public Task CloseTabAsync(int id, CancellationToken ct) => throw new NotSupportedException(); } } diff --git a/src/CCWrapper/Theme.xaml b/src/CCWrapper/Theme.xaml index 1818967..11d98b2 100644 --- a/src/CCWrapper/Theme.xaml +++ b/src/CCWrapper/Theme.xaml @@ -332,6 +332,38 @@ + + +