diff --git a/CHANGELOG.md b/CHANGELOG.md index b29671d..eddd4dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,5 +3,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## 1.1 (2022-07-28) +Added a built in browser based on VS Code's Simple Browser +- Enabled availability checks before opening browser +- Added checkbox to enable cache bypass in the browser + ## 1.0.0 (2022-04-24) Initial release \ No newline at end of file diff --git a/browser/browserUi.ts b/browser/browserUi.ts index ad8b127..09cb372 100644 --- a/browser/browserUi.ts +++ b/browser/browserUi.ts @@ -1,18 +1,52 @@ const vscode = acquireVsCodeApi(); +/** + * Messages sent to the host of this iframe + */ enum ToHostMessageType { + /** + * Open the current URL in the system browser + */ OpenInSystemBrowser = "open-in-system-browser", + + /** + * The user intiated a setting change for the automatic browser cache bypass + */ AutomaticBrowserCacheBybassStateChanged = "automatic-browser-cache-bypass-setting-changed" } +/** + * Messages recieved from the host of this iframe + */ enum FromHostMessageType { + /** + * The focus lock indicator setting has changed + */ FocusIndicatorLockEnabledStateChanged = "focus-lock-indicator-setting-changed", + + /** + * The automatic browser cache bypass setting has changed + */ AutomaticBrowserCacheBybassStateChanged = "automatic-browser-cache-bypass-setting-changed", + + /** + * Force an refresh of the focus lock state + */ + RefreshFocusLockState = "refresh-focus-lock-state", + + /** + * Open a URL in our browser + */ NavigateToUrl = "navigate-to-url" } const CACHE_BYPASS_PARAMETER_NAME = "ilbCacheBypassSecretParameter"; +/** + * Settings are (intially) passed in the html body as a metatag; this grabs it + * from there, and turns it into a real instance/ + * @returns The settings object + */ function extractSettingsFromMetaTag(): { url: string; focusLockIndicator: boolean; automaticBrowserCacheBypass: boolean } { const element = document.getElementById("browser-settings"); if (element) { @@ -29,35 +63,46 @@ function toggleFocusLockIndicator() { document.body.classList.toggle("enable-focus-lock-indicator", settings.focusLockIndicator); } -function toggleAutomaticBrowserCacheBypassButton() { +function updateAutomaticBrowserCacheBypassCheckboxState() { bypassCacheCheckbox.checked = settings.automaticBrowserCacheBypass; } +/** + * Sets the address bar to the currentlly set iframe URL. Intended to be used + * when someone has changed the address bar, but we didn't navigate to the URL + * they typed in + */ function resetAddressBarToCurrentIFrameValue() { const iframeUrl = new URL(contentIframe.src); iframeUrl.searchParams.delete(CACHE_BYPASS_PARAMETER_NAME); - locationBar.value = iframeUrl.toString(); + addressBar.value = iframeUrl.toString(); } const settings = extractSettingsFromMetaTag(); +// Locate all the buttons & elements we work with const contentIframe = document.querySelector("iframe")!; -const locationBar = document.querySelector(".url-input")!; +const addressBar = document.querySelector(".url-input")!; const forwardButton = document.querySelector(".forward-button")!; const backButton = document.querySelector(".back-button")!; const bypassCacheCheckbox = document.querySelector("#bypassCacheCheckbox")!; const reloadButton = document.querySelector(".reload-button")!; const openExternalButton = document.querySelector(".open-external-button")!; +/** + * Navigate the iframe to the supplied URL, including automatically appending + * cache bypass parameters if needed + * @param url URL to navigate to + */ function navigateTo(url: URL): void { + // Delete the cache bypass parameter if it's present if (url.searchParams.has(CACHE_BYPASS_PARAMETER_NAME)) { url.searchParams.delete(CACHE_BYPASS_PARAMETER_NAME); } - const nakedUrl = url.toString(); - vscode.setState({ url: nakedUrl }); - locationBar.value = nakedUrl; + // Save the state in the host + vscode.setState({ url: url.toString() }); // Try to bust the cache for the iframe There does not appear to be any way // to reliably do this except modifying the url @@ -66,8 +111,58 @@ function navigateTo(url: URL): void { } contentIframe.src = url.toString(); + resetAddressBarToCurrentIFrameValue(); +} + +/** + * Process a user change of address bar text. This will attempt to check that + * the URL is valid, and only navigate if it is. If the URL doesn't include the + * scheme, we will attempt to add one by default (http if local host, https + * otherwise) + */ +function handleAddressBarChange(e: Event) { + let rawUrl = (e.target).value; + let parsedUrl: URL | null = null; + + // Try to parse it + try { + parsedUrl = new URL(rawUrl); + } catch { + try { + // Since it wasn't a successful URL, lets try adding a scheme + if (!/^https?:\/\//.test(rawUrl)) { + if (rawUrl.startsWith("localhost/") || rawUrl.startsWith("localhost:")) { + // default to http for localhost + rawUrl = "http://" + rawUrl; + } else { + rawUrl = "https://" + rawUrl; + } + + // Try parsing it again + parsedUrl = new URL(rawUrl); + } + } catch { /* Not parsable */ } + } + + if (!parsedUrl || (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:")) { + resetAddressBarToCurrentIFrameValue(); + return; + } + + navigateTo(parsedUrl!); +} + +/** + * Refresh the display of the focus lock state indicator based on the iframe + * focus state + */ +function refreshFocusLockState(): void { + const iframeFocused = document.activeElement?.tagName === "IFRAME"; + document.body.classList.toggle("iframe-focused", iframeFocused); + console.log(`Focused: ${iframeFocused}`); } +// Listen for host-sent messages window.addEventListener("message", e => { switch (e.data.type) { case FromHostMessageType.FocusIndicatorLockEnabledStateChanged: @@ -81,7 +176,11 @@ window.addEventListener("message", e => { case FromHostMessageType.AutomaticBrowserCacheBybassStateChanged: settings.automaticBrowserCacheBypass = e.data.automaticBrowserCacheBypass; - toggleAutomaticBrowserCacheBypassButton(); + updateAutomaticBrowserCacheBypassCheckboxState(); + break; + + case FromHostMessageType.RefreshFocusLockState: + refreshFocusLockState(); break; } }); @@ -89,40 +188,15 @@ window.addEventListener("message", e => { document.addEventListener("DOMContentLoaded", () => { toggleFocusLockIndicator(); - setInterval(() => { - const iframeFocused = document.activeElement?.tagName === "IFRAME"; - document.body.classList.toggle("iframe-focused", iframeFocused); - }, 50); + // Handle focus events in the window so we can correctly indicate of focus + // is captured by the iframe itself + window.addEventListener("focus", refreshFocusLockState); + window.addEventListener("blur", refreshFocusLockState); - locationBar.addEventListener("change", e => { - let rawUrl = (e.target).value; - let parsedUrl: URL | null = null; - - try { - parsedUrl = new URL(rawUrl); - } catch { - try { - if (!/^https?:\/\//.test(rawUrl)) { - if (rawUrl.startsWith("localhost/") || rawUrl.startsWith("localhost:")) { - // default to http - rawUrl = "http://" + rawUrl; - } else { - rawUrl = "https://" + rawUrl; - } - - parsedUrl = new URL(rawUrl); - } - } catch { /* Not parsable */ } - } - - if (!parsedUrl || (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:")) { - resetAddressBarToCurrentIFrameValue(); - return; - } - - navigateTo(parsedUrl!); - }); + // When the user commits a change in the address bar, handle it + addressBar.addEventListener("change", handleAddressBarChange); + // Handle changes to the cache bypass checkbox bypassCacheCheckbox?.addEventListener("change", (e) => { const isChecked = (e.target).checked; vscode.postMessage({ @@ -143,7 +217,7 @@ document.addEventListener("DOMContentLoaded", () => { openExternalButton.addEventListener("click", () => { vscode.postMessage({ type: ToHostMessageType.OpenInSystemBrowser, - url: locationBar.value + url: addressBar.value }); }); diff --git a/src/browserManager.ts b/src/browserManager.ts index 81006e4..0ccf0bf 100644 --- a/src/browserManager.ts +++ b/src/browserManager.ts @@ -1,12 +1,18 @@ import * as vscode from "vscode"; import { ShowOptions, BrowserView, BROWSER_VIEW_TYPE } from "./browserView"; +/** + * Captured state from a previously opened webview + */ interface WebViewPersistedState { url?: string; } +/** + * Manages the active browser view, including registering for certain IDE-level + * events to help with restoration + */ export class BrowserManager { - private _activeView?: BrowserView; constructor( @@ -18,7 +24,14 @@ export class BrowserManager { this._activeView = undefined; } + /** + * Show a specific URL in the browser. Only one will be displayed at a time + * @param url URL to display + * @param options How the browser should be displayed + */ public show(url: string, options?: ShowOptions): void { + // If we already have a view, we should ask it to show the URL, rather + // than creating a new browser if (this._activeView) { this._activeView.show(url, options); return; @@ -28,13 +41,19 @@ export class BrowserManager { this.registerWebviewListeners(view); } + /** + * Handle IDE-driven restoration of a previously open browser + */ public restore(panel: vscode.WebviewPanel, state: WebViewPersistedState): Thenable { const url = state?.url; + + // Give up if we the URL we're being asked to restore is not parsable if (!url) { panel.dispose(); return Promise.resolve(); } + // Supply the **existing** panel, which we're going to restore into const view = BrowserView.create(this.extensionUri, url, undefined, panel); this.registerWebviewListeners(view); @@ -51,7 +70,11 @@ export class BrowserManager { this._activeView = view; } - public handleExtensionActivation(context: vscode.ExtensionContext) { + /** + * Listen for IDE-level events + * @param context Extension context + */ + public handleExtensionActivation(context: vscode.ExtensionContext): void { context.subscriptions.push( vscode.window.registerWebviewPanelSerializer( BROWSER_VIEW_TYPE, { diff --git a/src/browserView.ts b/src/browserView.ts index 9157b03..4060f88 100644 --- a/src/browserView.ts +++ b/src/browserView.ts @@ -2,18 +2,50 @@ import * as vscode from "vscode"; import { EXTENSION_ID } from "./extension"; import * as nodeCrypto from "crypto"; +/** + * Options for how to display the browser window e.g. which column to place it in + */ export interface ShowOptions { readonly viewColumn?: vscode.ViewColumn; } +/** + * Messages received from the owned WebView + */ enum FromWebViewMessageType { + /** + * Request to open a URL in the system browser + */ OpenInSystemBrowser = "open-in-system-browser", + + /** + * The user changed the automatic browser cache bypass setting + */ AutomaticBrowserCacheBybassStateChanged = "automatic-browser-cache-bypass-setting-changed" } +/** + * Messages sent to the owned WebView + */ enum ToWebViewMessageType { + /** + * Focus lock indicator setting has changed + */ FocusIndicatorLockEnabledStateChanged = "focus-lock-indicator-setting-changed", + + /** + * Automatic browser cache bypass setting has changed + */ AutomaticBrowserCacheBybassStateChanged = "automatic-browser-cache-bypass-setting-changed", + + /** + * Force an refresh of the focus lock state + */ + RefreshFocusLockState = "refresh-focus-lock-state", + + /** + * Request the WebView to open a specific URL + */ NavigateToUrl = "navigate-to-url" } @@ -26,7 +58,11 @@ function escapeAttribute(value: string | vscode.Uri): string { return value.toString().replace(/"/g, """); } +/** + * Generate a nonce for the content security policy attributes + */ function getNonce(): string { + // Favour the browser crypto, if not use nodes (compatible) API const actualCrypto = global.crypto ?? nodeCrypto.webcrypto; const values = new Uint8Array(64); @@ -35,27 +71,43 @@ function getNonce(): string { return values.reduce((p, v) => p += v.toString(16), ""); } +/** + * A Browser view that can navigate to URLs and allow forward/back of navigation + * that happens within that WebView + */ export class BrowserView { private disposables: vscode.Disposable[] = []; private readonly _onDidDispose = new vscode.EventEmitter(); public readonly onDispose = this._onDidDispose.event; + /** + * Creates a browser view & editor + * + * @param extensionUri The base URI for resources to be loaded from + * @param targetUrl URL to display + * @param showOptions How the pane should be displayed + * @param targetWebView If supplied, editor will be created in that pane. If + * omitted, a new pane will be created + * @returns + */ public static create( extensionUri: vscode.Uri, targetUrl: string, showOptions?: ShowOptions, targetWebView?: vscode.WebviewPanel ): BrowserView { + // Restore scenarios provide an existing Web View to attach to. If it's + // not supplied, we assume we want to create a new one. if (!targetWebView) { targetWebView = vscode.window.createWebviewPanel( BROWSER_VIEW_TYPE, BROWSER_TITLE, { viewColumn: showOptions?.viewColumn ?? vscode.ViewColumn.Active, - preserveFocus: true + preserveFocus: true // Don't automatically switch focus to the pane }, { - enableScripts: true, - enableForms: true, - retainContextWhenHidden: true, + enableScripts: true, // We execute scripts + enableForms: true, // We need form submissions + retainContextWhenHidden: true, // Don't purge the page when it's no longer the active tab localResourceRoots: [ vscode.Uri.joinPath(extensionUri, "out/browser") ] @@ -78,6 +130,14 @@ export class BrowserView { this.webViewPanel.webview.onDidReceiveMessage(this.handleWebViewMessage, this, this.disposables); this.webViewPanel.onDidDispose(this.dispose, this, this.disposables); + // When we're not longer the active editor, we need to re-evaluate the + // display of the focus captured indicator. + this.webViewPanel.onDidChangeViewState((e) => { + this.webViewPanel.webview.postMessage({ + type: ToWebViewMessageType.RefreshFocusLockState + }); + }, null, this.disposables); + this.show(url); } @@ -118,14 +178,28 @@ export class BrowserView { } } - private getHtml(url: string) { + /** + * Generates the HTML for the webview -- this is the actual editor HTML that + * includes our interactive controls etc. Of note, it includes the URL that + * will be navigated to when the editor renders. + * + * Important: This does nonce / Content Security Policy calculations. + * @param url URL to navigate to + * @returns HTML as a string to pass to a web view + */ + private getHtml(url: string): string { const configuration = vscode.workspace.getConfiguration(EXTENSION_ID); const nonce = getNonce(); - const mainJs = this.extensionResourceUrl("out/browser", "browserUi.js"); - const mainCss = this.extensionResourceUrl("out/browser", "styles.css"); + const mainJs = this.extensionResourceUrl("out", "browser", "browserUi.js"); + const mainCss = this.extensionResourceUrl("out", "browser", "styles.css"); const automaticBrowserBypass = configuration.get(AUTOMATIC_BROWSER_CACHE_BYPASS_SETTING_SECTION, true); + const settingsData = escapeAttribute(JSON.stringify({ + url: url, + focusLockIndiciatorEnabled: configuration.get(FOCUS_LOCK_SETTING_SECTION, true), + automaticBrowserCacheBypass: automaticBrowserBypass + })); return /* html */ ` @@ -140,41 +214,34 @@ export class BrowserView { frame-src *; "> - +
-
Focus Lock
+
Focus captured
@@ -183,8 +250,15 @@ export class BrowserView { `; } - private extensionResourceUrl(...parts: string[]): vscode.Uri { - return this.webViewPanel.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, ...parts)); + /** + * Paths inside the webview need to reference unique & opaque URLs to access + * local resources. This is a conveniance function to make those conversions + * clearer + * @param pathComponents Directory paths to combine to get final relative path + * @returns the opaque url for the resource + */ + private extensionResourceUrl(...pathComponents: string[]): vscode.Uri { + return this.webViewPanel.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, ...pathComponents)); } public dispose() { @@ -194,6 +268,12 @@ export class BrowserView { this.disposables = []; } + /** + * Show a specific URL in this instance of the browser. This will also bring + * the editor pane to the front in the requested column + * @param url URL to navigate to + * @param options What display options to use + */ public show(url: string, options?: ShowOptions) { if (!this.webViewPanel.webview.html) { this.webViewPanel.webview.html = this.getHtml(url);