From dc31e5cc7308c72505315206d12ef6c3dbb80d84 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Sat, 28 Sep 2024 23:29:45 -0400 Subject: [PATCH] Use a custom protocol when loading HTML (#71) * Use a custom protocol when loading HTML * Bump to 0.0.15 (Binary 0.1.12) * Add ability to specify origin for html; improve docs When using `html` as an option for constructing a webview, you may want to have a finer grained control over the origin. This change allows you to specify the origin both during creation of the webview and at `load_html` time. * Update examples w/ origin usage; persistence * Add changelog update --- CHANGELOG.md | 7 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- deno.json | 2 +- examples/load-html.ts | 7 ++ examples/tldraw.ts | 2 +- schemas/WebViewMessage.json | 4 ++ schemas/WebViewOptions.json | 17 +++-- schemas/WebViewRequest.json | 35 +++++++++- schemas/WebViewResponse.json | 2 + scripts/generate-schema.ts | 50 +++++++++------ src/lib.ts | 2 +- src/main.rs | 120 ++++++++++++++++++++++++++++++----- src/schemas.ts | 42 +++++++++++- 14 files changed, 244 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e5029c..63fa6d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.0.15 (binary 0.1.12) -- 2024-09-28 + +- Pages loaded with `html` are now considered to be in a secure context. +- When creating a webview with `html` or calling `webview.loadHtml()` the webview now has a default origin which can be changed via the `origin` parameter +- Improved type generation to output more doc strings and documented more code +- Update TLDraw example with a persistence key + ## 0.0.14 (binary 0.1.11) -- 2024-09-26 - fix an issue where arm64 macs weren't downloading the correct binary diff --git a/Cargo.lock b/Cargo.lock index 4e1cde5..2a88188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,7 +278,7 @@ dependencies = [ [[package]] name = "deno-webview" -version = "0.1.11" +version = "0.1.12" dependencies = [ "schemars", "serde", diff --git a/Cargo.toml b/Cargo.toml index 048135f..c5452b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deno-webview" -version = "0.1.11" +version = "0.1.12" edition = "2021" [profile.release] diff --git a/deno.json b/deno.json index e7b05f3..e722027 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "name": "@justbe/webview", "exports": "./src/lib.ts", - "version": "0.0.14", + "version": "0.0.15", "tasks": { "dev": "deno run --watch main.ts", "gen": "deno task gen:rust && deno task gen:deno", diff --git a/examples/load-html.ts b/examples/load-html.ts index 98afe1e..fa4ca10 100644 --- a/examples/load-html.ts +++ b/examples/load-html.ts @@ -3,9 +3,16 @@ import { createWebView } from "../src/lib.ts"; using webview = await createWebView({ title: "Load Html Example", html: "

Initial html

", + // Note: This origin is used with a custom protocol so it doesn't match + // https://example.com. This doesn't need to be set, but can be useful if + // you want to control resources that are scoped to a specific origin like + // local storage or indexeddb. + origin: "example.com", + devtools: true, }); webview.on("started", async () => { + await webview.openDevTools(); await webview.loadHtml("

Updated html!

"); }); diff --git a/examples/tldraw.ts b/examples/tldraw.ts index 1a5c035..054e434 100644 --- a/examples/tldraw.ts +++ b/examples/tldraw.ts @@ -9,7 +9,7 @@ function App() { return ( <>
- +
); diff --git a/schemas/WebViewMessage.json b/schemas/WebViewMessage.json index 3e07170..8898221 100644 --- a/schemas/WebViewMessage.json +++ b/schemas/WebViewMessage.json @@ -58,6 +58,7 @@ ] }, "version": { + "description": "The version of the webview binary", "type": "string" } } @@ -76,6 +77,7 @@ ] }, "message": { + "description": "The message sent from the webview UI to the client.", "type": "string" } } @@ -243,6 +245,7 @@ ], "properties": { "height": { + "description": "The height of the window in logical pixels.", "type": "number", "format": "double" }, @@ -252,6 +255,7 @@ "format": "double" }, "width": { + "description": "The width of the window in logical pixels.", "type": "number", "format": "double" } diff --git a/schemas/WebViewOptions.json b/schemas/WebViewOptions.json index d74f39e..0429aa9 100644 --- a/schemas/WebViewOptions.json +++ b/schemas/WebViewOptions.json @@ -3,32 +3,35 @@ "title": "WebViewOptions", "description": "Options for creating a webview.", "type": "object", - "oneOf": [ + "anyOf": [ { - "description": "Url to load in the webview.", "type": "object", "required": [ "url" ], "properties": { "url": { + "description": "Url to load in the webview. Note: Don't use data URLs here, as they are not supported. Use the `html` field instead.", "type": "string" } - }, - "additionalProperties": false + } }, { - "description": "Html to load in the webview.", "type": "object", "required": [ "html" ], "properties": { "html": { + "description": "Html to load in the webview.", + "type": "string" + }, + "origin": { + "description": "What to set as the origin of the webview when loading html.", + "default": "init", "type": "string" } - }, - "additionalProperties": false + } } ], "required": [ diff --git a/schemas/WebViewRequest.json b/schemas/WebViewRequest.json index 265c0af..f0cb147 100644 --- a/schemas/WebViewRequest.json +++ b/schemas/WebViewRequest.json @@ -17,6 +17,7 @@ ] }, "id": { + "description": "The id of the request.", "type": "string" } } @@ -36,9 +37,11 @@ ] }, "id": { + "description": "The id of the request.", "type": "string" }, "js": { + "description": "The javascript to evaluate.", "type": "string" } } @@ -58,9 +61,11 @@ ] }, "id": { + "description": "The id of the request.", "type": "string" }, "title": { + "description": "The title to set.", "type": "string" } } @@ -79,6 +84,7 @@ ] }, "id": { + "description": "The id of the request.", "type": "string" } } @@ -98,9 +104,11 @@ ] }, "id": { + "description": "The id of the request.", "type": "string" }, "visible": { + "description": "Whether the window should be visible or hidden.", "type": "boolean" } } @@ -119,6 +127,7 @@ ] }, "id": { + "description": "The id of the request.", "type": "string" } } @@ -137,6 +146,7 @@ ] }, "id": { + "description": "The id of the request.", "type": "string" } } @@ -155,9 +165,11 @@ ] }, "id": { + "description": "The id of the request.", "type": "string" }, "include_decorations": { + "description": "Whether to include the title bar and borders in the size measurement.", "default": null, "type": [ "boolean", @@ -181,10 +193,16 @@ ] }, "id": { + "description": "The id of the request.", "type": "string" }, "size": { - "$ref": "#/definitions/SimpleSize" + "description": "The size to set.", + "allOf": [ + { + "$ref": "#/definitions/SimpleSize" + } + ] } } }, @@ -202,12 +220,14 @@ ] }, "fullscreen": { + "description": "Whether to enter fullscreen mode. If left unspecified, the window will enter fullscreen mode if it is not already in fullscreen mode or exit fullscreen mode if it is currently in fullscreen mode.", "type": [ "boolean", "null" ] }, "id": { + "description": "The id of the request.", "type": "string" } } @@ -226,9 +246,11 @@ ] }, "id": { + "description": "The id of the request.", "type": "string" }, "maximized": { + "description": "Whether to maximize the window. If left unspecified, the window will be maximized if it is not already maximized or restored if it was previously maximized.", "type": [ "boolean", "null" @@ -250,9 +272,11 @@ ] }, "id": { + "description": "The id of the request.", "type": "string" }, "minimized": { + "description": "Whether to minimize the window. If left unspecified, the window will be minimized if it is not already minimized or restored if it was previously minimized.", "type": [ "boolean", "null" @@ -275,10 +299,19 @@ ] }, "html": { + "description": "HTML to set as the content of the webview.", "type": "string" }, "id": { + "description": "The id of the request.", "type": "string" + }, + "origin": { + "description": "What to set as the origin of the webview when loading html. If not specified, the origin will be set to the value of the `origin` field when the webview was created.", + "type": [ + "string", + "null" + ] } } } diff --git a/schemas/WebViewResponse.json b/schemas/WebViewResponse.json index 4376963..532e53c 100644 --- a/schemas/WebViewResponse.json +++ b/schemas/WebViewResponse.json @@ -147,6 +147,7 @@ ], "properties": { "height": { + "description": "The height of the window in logical pixels.", "type": "number", "format": "double" }, @@ -156,6 +157,7 @@ "format": "double" }, "width": { + "description": "The width of the window in logical pixels.", "type": "number", "format": "double" } diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts index c206fdc..3dba7a6 100644 --- a/scripts/generate-schema.ts +++ b/scripts/generate-schema.ts @@ -55,6 +55,13 @@ const isOptionalType = return false; }; +const flattenUnion = (union: NodeIR[], member: NodeIR) => { + if (member.type === "union") { + return union.concat(member.members); + } + return union.concat(member); +}; + function jsonSchemaToIR(schema: JSONSchema): DocIR { const nodeToIR = (node: JSONSchema): NodeIR => { return match(node) @@ -79,8 +86,10 @@ function jsonSchemaToIR(schema: JSONSchema): DocIR { maximum: node.maximum, })) .with( - { type: "string" }, + { type: P.union("string", P.when(isOptionalType("string"))) }, (node) => { + const isOptional = + Array.isArray(node.type) && node.type.includes("null") || false; if (node.enum) { if (node.enum.length === 1) { return { @@ -96,7 +105,10 @@ function jsonSchemaToIR(schema: JSONSchema): DocIR { })), }; } - return ({ type: "string" as const, optional: !!node.default }); + return ({ + type: "string" as const, + optional: !!node.default || isOptional, + }); }, ) .with( @@ -114,12 +126,26 @@ function jsonSchemaToIR(schema: JSONSchema): DocIR { schema.definitions![node.$ref.split("/").pop()!] as JSONSchema, ), ) + .with({ allOf: P.array() }, (node) => { + if (node.allOf?.length === 1) { + return nodeToIR(node.allOf[0] as JSONSchema); + } + return { + type: "intersection" as const, + members: node.allOf?.map((v) => nodeToIR(v as JSONSchema)) ?? [], + }; + }) .with( - { oneOf: P.array() }, + P.union({ oneOf: P.array() }, { anyOf: P.array() }), (node) => { const union = { type: "union" as const, - members: node.oneOf?.map((v) => nodeToIR(v as JSONSchema)) ?? [], + members: + ((node.oneOf ?? node.anyOf)?.map((v) => + nodeToIR(v as JSONSchema) + ) ?? []) + .filter((v) => v.type !== "unknown") + .reduce(flattenUnion, [] as NodeIR[]), }; if (node.properties) { return ({ @@ -143,21 +169,6 @@ function jsonSchemaToIR(schema: JSONSchema): DocIR { return union; }, ) - .with( - { anyOf: P.array() }, - () => ({ - type: "union" as const, - members: (node.anyOf?.map((v) => nodeToIR(v as JSONSchema)) ?? []) - .filter((v) => v.type !== "unknown") - // flatten nested unions - .reduce((union, member) => { - if (member.type === "union") { - return union.concat(member.members); - } - return union.concat(member); - }, [] as NodeIR[]), - }), - ) .with( { type: "object" }, () => ({ @@ -168,6 +179,7 @@ function jsonSchemaToIR(schema: JSONSchema): DocIR { return ({ key, required: node.required?.includes(key) ?? false, + description: (value as JSONSchema).description, value: nodeToIR(value as JSONSchema), }); }), diff --git a/src/lib.ts b/src/lib.ts index 0af59b0..1314029 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -38,7 +38,7 @@ export type { WebViewOptions } from "./schemas.ts"; // Should match the cargo package version /** The version of the webview binary that's expected */ -export const BIN_VERSION = "0.1.11"; +export const BIN_VERSION = "0.1.12"; type JSON = | string diff --git a/src/main.rs b/src/main.rs index bc606f9..1c59a70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,9 @@ +use std::borrow::Cow; +use std::cell::RefCell; use std::env; use std::io::{self, BufRead, Write}; use std::sync::mpsc; +use std::sync::Arc; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -8,6 +11,14 @@ use serde_json; use tao::dpi::{LogicalSize, Size}; use tao::window::Fullscreen; +use tao::{ + event::{Event, StartCause, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + window::WindowBuilder, +}; +use wry::http::Response as HttpResponse; +use wry::WebViewBuilder; + /// The version of the webview binary. const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -86,11 +97,24 @@ fn default_true() -> bool { #[derive(JsonSchema, Deserialize, Debug)] #[serde(rename_all = "camelCase")] +#[serde(untagged)] enum WebViewTarget { - /// Url to load in the webview. - Url(String), - /// Html to load in the webview. - Html(String), + Url { + /// Url to load in the webview. Note: Don't use data URLs here, as they are not supported. Use the `html` field instead. + url: String, + }, + Html { + /// Html to load in the webview. + html: String, + /// What to set as the origin of the webview when loading html. + #[serde(default = "default_origin")] + origin: String, + }, +} + +/// The default origin to use when loading html. +fn default_origin() -> String { + "init".to_string() } // --- RPC Definitions --- @@ -109,8 +133,14 @@ enum Message { #[serde(rename_all = "camelCase")] #[serde(tag = "$type")] enum Notification { - Started { version: String }, - Ipc { message: String }, + Started { + /// The version of the webview binary + version: String, + }, + Ipc { + /// The message sent from the webview UI to the client. + message: String, + }, Closed, } @@ -120,53 +150,84 @@ enum Notification { #[serde(tag = "$type")] enum Request { GetVersion { + /// The id of the request. id: String, }, Eval { + /// The id of the request. id: String, + /// The javascript to evaluate. js: String, }, SetTitle { + /// The id of the request. id: String, + /// The title to set. title: String, }, GetTitle { + /// The id of the request. id: String, }, SetVisibility { + /// The id of the request. id: String, + /// Whether the window should be visible or hidden. visible: bool, }, IsVisible { + /// The id of the request. id: String, }, OpenDevTools { + /// The id of the request. id: String, }, GetSize { + /// The id of the request. id: String, + /// Whether to include the title bar and borders in the size measurement. #[serde(default)] include_decorations: Option, }, SetSize { + /// The id of the request. id: String, + /// The size to set. size: SimpleSize, }, Fullscreen { + /// The id of the request. id: String, + /// Whether to enter fullscreen mode. + /// If left unspecified, the window will enter fullscreen mode if it is not already in fullscreen mode + /// or exit fullscreen mode if it is currently in fullscreen mode. fullscreen: Option, }, Maximize { + /// The id of the request. id: String, + /// Whether to maximize the window. + /// If left unspecified, the window will be maximized if it is not already maximized + /// or restored if it was previously maximized. maximized: Option, }, Minimize { + /// The id of the request. id: String, + /// Whether to minimize the window. + /// If left unspecified, the window will be minimized if it is not already minimized + /// or restored if it was previously minimized. minimized: Option, }, LoadHtml { + /// The id of the request. id: String, + /// HTML to set as the content of the webview. html: String, + /// What to set as the origin of the webview when loading html. + /// If not specified, the origin will be set to the value of the `origin` field when the webview was created. + origin: Option, }, } @@ -190,7 +251,9 @@ enum ResultType { Boolean(bool), Float(f64), Size { + /// The width of the window in logical pixels. width: f64, + /// The height of the window in logical pixels. height: f64, /// The ratio between physical and logical sizes. scale_factor: f64, @@ -213,19 +276,18 @@ fn main() -> wry::Result<()> { let args: Vec = env::args().collect(); let webview_options: WebViewOptions = serde_json::from_str(&args[1]).unwrap(); - use tao::{ - event::{Event, StartCause, WindowEvent}, - event_loop::{ControlFlow, EventLoop}, - window::WindowBuilder, - }; - use wry::WebViewBuilder; + // These two cells are used to store the html and origin if the webview is created with html. + // The html cell is needed to provide a value to the custom protocol and origin is needed + // as a fallback if `load_html` is called without an origin. + let html_cell = Arc::new(RefCell::new("".to_string())); + let origin_cell = Arc::new(RefCell::new(default_origin().to_string())); let (tx, to_deno) = mpsc::channel::(); let (from_deno, rx) = mpsc::channel::(); let event_loop = EventLoop::new(); let mut window_builder = WindowBuilder::new() - .with_title(webview_options.title) + .with_title(webview_options.title.clone()) .with_transparent(webview_options.transparent) .with_decorations(webview_options.decorations); match webview_options.size { @@ -243,10 +305,23 @@ fn main() -> wry::Result<()> { } let window = window_builder.build(&event_loop).unwrap(); + let html_cell_init = html_cell.clone(); let mut webview_builder = match webview_options.target { - WebViewTarget::Url(url) => WebViewBuilder::new(&window).with_url(url), - WebViewTarget::Html(html) => WebViewBuilder::new(&window).with_html(html), + WebViewTarget::Url { url } => WebViewBuilder::new(&window).with_url(url), + WebViewTarget::Html { html, origin } => { + origin_cell.replace(origin.clone()); + html_cell.replace(html); + WebViewBuilder::new(&window).with_url(&format!("load-html://{}", origin)) + } } + .with_custom_protocol("load-html".into(), move |_req| { + HttpResponse::builder() + .header("Content-Type", "text/html") + .body(Cow::Owned( + html_cell_init.as_ref().borrow().as_bytes().to_vec(), + )) + .unwrap() + }) .with_transparent(webview_options.transparent) .with_autoplay(webview_options.autoplay) .with_incognito(webview_options.incognito) @@ -430,8 +505,19 @@ fn main() -> wry::Result<()> { window.set_minimized(minimized); res(Response::Ack { id }); } - Request::LoadHtml { id, html } => { - webview.load_html(&html).unwrap(); + Request::LoadHtml { id, html, origin } => { + html_cell.replace(html); + let origin = match origin { + Some(origin) => { + origin_cell.replace(origin.clone()); + origin + } + None => origin_cell.borrow().clone(), + }; + + webview + .load_url(&format!("load-html://{}?{}", origin, id)) + .unwrap(); res(Response::Ack { id }); } } diff --git a/src/schemas.ts b/src/schemas.ts index b25707e..0d50e43 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -46,10 +46,14 @@ export type WebViewOptions = } & ( | { + /** Url to load in the webview. Note: Don't use data URLs here, as they are not supported. Use the `html` field instead. */ url: string; } | { + /** Html to load in the webview. */ html: string; + /** What to set as the origin of the webview when loading html. */ + origin?: string; } ); export const WebViewOptions: z.ZodType = z.intersection( @@ -71,7 +75,10 @@ export const WebViewOptions: z.ZodType = z.intersection( title: z.string(), transparent: z.boolean().optional(), }), - z.union([z.object({ url: z.string() }), z.object({ html: z.string() })]), + z.union([ + z.object({ url: z.string() }), + z.object({ html: z.string(), origin: z.string().optional() }), + ]), ); /** @@ -80,43 +87,57 @@ export const WebViewOptions: z.ZodType = z.intersection( export type WebViewRequest = | { $type: "getVersion"; + /** The id of the request. */ id: string; } | { $type: "eval"; + /** The id of the request. */ id: string; + /** The javascript to evaluate. */ js: string; } | { $type: "setTitle"; + /** The id of the request. */ id: string; + /** The title to set. */ title: string; } | { $type: "getTitle"; + /** The id of the request. */ id: string; } | { $type: "setVisibility"; + /** The id of the request. */ id: string; + /** Whether the window should be visible or hidden. */ visible: boolean; } | { $type: "isVisible"; + /** The id of the request. */ id: string; } | { $type: "openDevTools"; + /** The id of the request. */ id: string; } | { $type: "getSize"; + /** The id of the request. */ id: string; + /** Whether to include the title bar and borders in the size measurement. */ include_decorations?: boolean; } | { $type: "setSize"; + /** The id of the request. */ id: string; + /** The size to set. */ size: { height: number; width: number; @@ -124,23 +145,33 @@ export type WebViewRequest = } | { $type: "fullscreen"; + /** Whether to enter fullscreen mode. If left unspecified, the window will enter fullscreen mode if it is not already in fullscreen mode or exit fullscreen mode if it is currently in fullscreen mode. */ fullscreen?: boolean; + /** The id of the request. */ id: string; } | { $type: "maximize"; + /** The id of the request. */ id: string; + /** Whether to maximize the window. If left unspecified, the window will be maximized if it is not already maximized or restored if it was previously maximized. */ maximized?: boolean; } | { $type: "minimize"; + /** The id of the request. */ id: string; + /** Whether to minimize the window. If left unspecified, the window will be minimized if it is not already minimized or restored if it was previously minimized. */ minimized?: boolean; } | { $type: "loadHtml"; + /** HTML to set as the content of the webview. */ html: string; + /** The id of the request. */ id: string; + /** What to set as the origin of the webview when loading html. If not specified, the origin will be set to the value of the `origin` field when the webview was created. */ + origin?: string; }; export const WebViewRequest: z.ZodType = z.discriminatedUnion( "$type", @@ -189,6 +220,7 @@ export const WebViewRequest: z.ZodType = z.discriminatedUnion( $type: z.literal("loadHtml"), html: z.string(), id: z.string(), + origin: z.string().optional(), }), ], ); @@ -220,8 +252,11 @@ export type WebViewResponse = | { $type: "size"; value: { + /** The height of the window in logical pixels. */ height: number; + /** The ratio between physical and logical sizes. */ scale_factor: number; + /** The width of the window in logical pixels. */ width: number; }; }; @@ -265,10 +300,12 @@ export type WebViewMessage = data: | { $type: "started"; + /** The version of the webview binary */ version: string; } | { $type: "ipc"; + /** The message sent from the webview UI to the client. */ message: string; } | { @@ -301,8 +338,11 @@ export type WebViewMessage = | { $type: "size"; value: { + /** The height of the window in logical pixels. */ height: number; + /** The ratio between physical and logical sizes. */ scale_factor: number; + /** The width of the window in logical pixels. */ width: number; }; };