diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cr] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65db2fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/docs/ +/lib/ +/bin/ +/.shards/ +*.dwarf +# Libraries don't need dependency lock +# Dependencies will be locked in applications that use them +/shard.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..765f0e9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +language: crystal + +# Uncomment the following if you'd like Travis to run specs and check code formatting +# script: +# - crystal spec +# - crystal tool format --check diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b34727a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Ali Naqvi + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4afa161 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# Crystal Webview + +Crystal language bindings for [zserge's Webview](https://github.com/zserge/webview) which is an excellent cross-platform single header webview library for C/C++ using Gtk, Cocoa or MSHTML/Edge repectively. + +**Webview** relys on default rendering engine of host Operating System, thus binaries generated with this Shard will be much more leaner as compared to [Electron](https://github.com/electron/electron) which bundles Chromium with each distribution. + +Shard Supports **Two-way bindings** between Crystal and JavaScript. You can invoke JS code via `Webview::Webview#eval` and calling Crystal code from JS is done via `WebView::Webview#bind` (refer to Example 3 for sample on how to invoke Crystal functions from JS) + +Webview supported platforms and the engines you can expect to render your application content are as follows: + +| Operating System | Browser Engine Used | +| ---------------- | ------------------- | +| OSX | Cocoa/WebKit | +| Linux | Gtk-webkit2 | +| Windows | MSHTML or EdgeHTML | + +## Pre-requisite +1. Make sure you compile `ext/webview.cc` and save object file `webview.o` under respective OS folder in `ext` folder. Follow instructions for your specific OS + +### Mac compilation +``` +cd ext +c++ -c webview.cc -o darwin/webview.o -DWEBVIEW_COCOA=1 -DOBJC_OLD_DISPATCH_PROTOTYPES=1 -std=c++11 +``` + +### Linux compilation +If you're planning on targeting Linux you must ensure that Webkit2gtk is already installed and available for discovery via the pkg-config command. + +``` +cd ext +c++ -c webview.cc -o linux/webview.o -DWEBVIEW_GTK=1 -std=c++11 +``` + + +## Installation + +1. Add the dependency to your `shard.yml`: + + ```yaml + dependencies: + webview: + github: naqvis/webview + ``` + +2. Run `shards install` + +## Usage + +### Example 1: Loading URL + +```crystal +require "webview" + +wv = Webview.window(640, 480, Webview::SizeHints::NONE, "Hello WebView", "http://crystal-lang.org") +wv.run +wv.destroy +``` + +### Example 2: Loading HTML + +```crystal +require "webview" + +html = <<-HTML +data:text/html, + +Hello,World! + + +
+
+ +

City Gallery

+
+ +
+

London

+ Mountain View +

London is the capital city of England. It is the most populous city in the United Kingdom, with a metropolitan area of over 13 million inhabitants.

+

Standing on the River Thames, London has been a major settlement for two millennia, its history going back to its founding by the Romans, who named it Londinium.

+
+ +
+ + +HTML + +wv = Webview.window(640, 480, Webview::SizeHints::NONE, "Hello WebView", html) +wv.run +wv.destroy +``` + +### Example 3: Calling Crystal code from JavaScript +```crystal +require "webview" + +html = <<-HTML +data:text/html, + + hello + + +HTML + +wv = Webview.window(640, 480, Webview::SizeHints::NONE, "Hello WebView", html, true) + +wv.bind("noop", Webview::JSProc.new { |a| + pp "Noop called with arguments: #{a}" + JSON::Any.new("noop") +}) + +wv.bind("add", Webview::JSProc.new { |a| + pp "add called with arguments: #{a}" + ret = 0_i64 + a.each do |v| + ret += v.as_i64 + end + JSON::Any.new(ret) +}) + + +wv.run +wv.destroy +``` + + +## Contributing + +1. Fork it () +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request + +## Contributors + +- [Ali Naqvi](https://github.com/naqvis) - creator and maintainer diff --git a/ext/darwin/webview.o b/ext/darwin/webview.o new file mode 100644 index 0000000..8adc52f Binary files /dev/null and b/ext/darwin/webview.o differ diff --git a/ext/webview.cc b/ext/webview.cc new file mode 100644 index 0000000..28b64df --- /dev/null +++ b/ext/webview.cc @@ -0,0 +1,2 @@ +#define WEBVIEW_IMPLEMENTATION +#include "webview.h" diff --git a/ext/webview.h b/ext/webview.h new file mode 100644 index 0000000..e51839c --- /dev/null +++ b/ext/webview.h @@ -0,0 +1,1228 @@ +/* + * MIT License + * + * Copyright (c) 2017 Serge Zaitsev + * + * 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. + */ +#ifndef WEBVIEW_H +#define WEBVIEW_H + +#ifndef WEBVIEW_API +#define WEBVIEW_API extern +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void *webview_t; + +// Creates a new webview instance. If debug is non-zero - developer tools will +// be enabled (if the platform supports them). Window parameter can be a +// pointer to the native window handle. If it's non-null - then child WebView +// is embedded into the given parent window. Otherwise a new window is created. +// Depending on the platform, a GtkWindow, NSWindow or HWND pointer can be +// passed here. +WEBVIEW_API webview_t webview_create(int debug, void *window); + +// Destroys a webview and closes the native window. +WEBVIEW_API void webview_destroy(webview_t w); + +// Runs the main loop until it's terminated. After this function exits - you +// must destroy the webview. +WEBVIEW_API void webview_run(webview_t w); + +// Stops the main loop. It is safe to call this function from another other +// background thread. +WEBVIEW_API void webview_terminate(webview_t w); + +// Posts a function to be executed on the main thread. You normally do not need +// to call this function, unless you want to tweak the native window. +WEBVIEW_API void +webview_dispatch(webview_t w, void (*fn)(webview_t w, void *arg), void *arg); + +// Returns a native window handle pointer. When using GTK backend the pointer +// is GtkWindow pointer, when using Cocoa backend the pointer is NSWindow +// pointer, when using Win32 backend the pointer is HWND pointer. +WEBVIEW_API void *webview_get_window(webview_t w); + +// Updates the title of the native window. Must be called from the UI thread. +WEBVIEW_API void webview_set_title(webview_t w, const char *title); + +// Window size hints +#define WEBVIEW_HINT_NONE 0 // Width and height are default size +#define WEBVIEW_HINT_MIN 1 // Width and height are minimum bounds +#define WEBVIEW_HINT_MAX 2 // Width and height are maximum bounds +#define WEBVIEW_HINT_FIXED 3 // Window size can not be changed by a user +// Updates native window size. See WEBVIEW_HINT constants. +WEBVIEW_API void webview_set_size(webview_t w, int width, int height, + int hints); + +// Navigates webview to the given URL. URL may be a data URI, i.e. +// "data:text/text,...". It is often ok not to url-encode it +// properly, webview will re-encode it for you. +WEBVIEW_API void webview_navigate(webview_t w, const char *url); + +// Injects JavaScript code at the initialization of the new page. Every time +// the webview will open a the new page - this initialization code will be +// executed. It is guaranteed that code is executed before window.onload. +WEBVIEW_API void webview_init(webview_t w, const char *js); + +// Evaluates arbitrary JavaScript code. Evaluation happens asynchronously, also +// the result of the expression is ignored. Use RPC bindings if you want to +// receive notifications about the results of the evaluation. +WEBVIEW_API void webview_eval(webview_t w, const char *js); + +// Binds a native C callback so that it will appear under the given name as a +// global JavaScript function. Internally it uses webview_init(). Callback +// receives a request string and a user-provided argument pointer. Request +// string is a JSON array of all the arguments passed to the JavaScript +// function. +WEBVIEW_API void webview_bind(webview_t w, const char *name, + void (*fn)(const char *seq, const char *req, + void *arg), + void *arg); + +// Allows to return a value from the native binding. Original request pointer +// must be provided to help internal RPC engine match requests with responses. +// If status is zero - result is expected to be a valid JSON result value. +// If status is not zero - result is an error JSON object. +WEBVIEW_API void webview_return(webview_t w, const char *seq, int status, + const char *result); + +#ifdef __cplusplus +} +#endif + +#ifndef WEBVIEW_HEADER + +#if !defined(WEBVIEW_GTK) && !defined(WEBVIEW_COCOA) && !defined(WEBVIEW_EDGE) +#if defined(__linux__) +#define WEBVIEW_GTK +#elif defined(__APPLE__) +#define WEBVIEW_COCOA +#elif defined(_WIN32) +#define WEBVIEW_EDGE +#else +#error "please, specify webview backend" +#endif +#endif + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace webview { +using dispatch_fn_t = std::function; + +inline std::string url_encode(const std::string s) { + std::string encoded; + for (unsigned int i = 0; i < s.length(); i++) { + auto c = s[i]; + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + encoded = encoded + c; + } else { + char hex[4]; + snprintf(hex, sizeof(hex), "%%%02x", c); + encoded = encoded + hex; + } + } + return encoded; +} + +inline std::string url_decode(const std::string s) { + std::string decoded; + for (unsigned int i = 0; i < s.length(); i++) { + if (s[i] == '%') { + int n; + n = std::stoul(s.substr(i + 1, 2), nullptr, 16); + decoded = decoded + static_cast(n); + i = i + 2; + } else if (s[i] == '+') { + decoded = decoded + ' '; + } else { + decoded = decoded + s[i]; + } + } + return decoded; +} + +inline std::string html_from_uri(const std::string s) { + if (s.substr(0, 15) == "data:text/html,") { + return url_decode(s.substr(15)); + } + return ""; +} + +inline int json_parse_c(const char *s, size_t sz, const char *key, size_t keysz, + const char **value, size_t *valuesz) { + enum { + JSON_STATE_VALUE, + JSON_STATE_LITERAL, + JSON_STATE_STRING, + JSON_STATE_ESCAPE, + JSON_STATE_UTF8 + } state = JSON_STATE_VALUE; + const char *k = NULL; + int index = 1; + int depth = 0; + int utf8_bytes = 0; + + if (key == NULL) { + index = keysz; + keysz = 0; + } + + *value = NULL; + *valuesz = 0; + + for (; sz > 0; s++, sz--) { + enum { + JSON_ACTION_NONE, + JSON_ACTION_START, + JSON_ACTION_END, + JSON_ACTION_START_STRUCT, + JSON_ACTION_END_STRUCT + } action = JSON_ACTION_NONE; + unsigned char c = *s; + switch (state) { + case JSON_STATE_VALUE: + if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',' || + c == ':') { + continue; + } else if (c == '"') { + action = JSON_ACTION_START; + state = JSON_STATE_STRING; + } else if (c == '{' || c == '[') { + action = JSON_ACTION_START_STRUCT; + } else if (c == '}' || c == ']') { + action = JSON_ACTION_END_STRUCT; + } else if (c == 't' || c == 'f' || c == 'n' || c == '-' || + (c >= '0' && c <= '9')) { + action = JSON_ACTION_START; + state = JSON_STATE_LITERAL; + } else { + return -1; + } + break; + case JSON_STATE_LITERAL: + if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',' || + c == ']' || c == '}' || c == ':') { + state = JSON_STATE_VALUE; + s--; + sz++; + action = JSON_ACTION_END; + } else if (c < 32 || c > 126) { + return -1; + } // fallthrough + case JSON_STATE_STRING: + if (c < 32 || (c > 126 && c < 192)) { + return -1; + } else if (c == '"') { + action = JSON_ACTION_END; + state = JSON_STATE_VALUE; + } else if (c == '\\') { + state = JSON_STATE_ESCAPE; + } else if (c >= 192 && c < 224) { + utf8_bytes = 1; + state = JSON_STATE_UTF8; + } else if (c >= 224 && c < 240) { + utf8_bytes = 2; + state = JSON_STATE_UTF8; + } else if (c >= 240 && c < 247) { + utf8_bytes = 3; + state = JSON_STATE_UTF8; + } else if (c >= 128 && c < 192) { + return -1; + } + break; + case JSON_STATE_ESCAPE: + if (c == '"' || c == '\\' || c == '/' || c == 'b' || c == 'f' || + c == 'n' || c == 'r' || c == 't' || c == 'u') { + state = JSON_STATE_STRING; + } else { + return -1; + } + break; + case JSON_STATE_UTF8: + if (c < 128 || c > 191) { + return -1; + } + utf8_bytes--; + if (utf8_bytes == 0) { + state = JSON_STATE_STRING; + } + break; + default: + return -1; + } + + if (action == JSON_ACTION_END_STRUCT) { + depth--; + } + + if (depth == 1) { + if (action == JSON_ACTION_START || action == JSON_ACTION_START_STRUCT) { + if (index == 0) { + *value = s; + } else if (keysz > 0 && index == 1) { + k = s; + } else { + index--; + } + } else if (action == JSON_ACTION_END || + action == JSON_ACTION_END_STRUCT) { + if (*value != NULL && index == 0) { + *valuesz = (size_t)(s + 1 - *value); + return 0; + } else if (keysz > 0 && k != NULL) { + if (keysz == (size_t)(s - k - 1) && memcmp(key, k + 1, keysz) == 0) { + index = 0; + } else { + index = 2; + } + k = NULL; + } + } + } + + if (action == JSON_ACTION_START_STRUCT) { + depth++; + } + } + return -1; +} + +inline std::string json_escape(std::string s) { + // TODO: implement + return '"' + s + '"'; +} + +inline int json_unescape(const char *s, size_t n, char *out) { + int r = 0; + if (*s++ != '"') { + return -1; + } + while (n > 2) { + char c = *s; + if (c == '\\') { + s++; + n--; + switch (*s) { + case 'b': + c = '\b'; + break; + case 'f': + c = '\f'; + break; + case 'n': + c = '\n'; + break; + case 'r': + c = '\r'; + break; + case 't': + c = '\t'; + break; + case '\\': + c = '\\'; + break; + case '/': + c = '/'; + break; + case '\"': + c = '\"'; + break; + default: // TODO: support unicode decoding + return -1; + } + } + if (out != NULL) { + *out++ = c; + } + s++; + n--; + r++; + } + if (*s != '"') { + return -1; + } + if (out != NULL) { + *out = '\0'; + } + return r; +} + +inline std::string json_parse(const std::string s, const std::string key, + const int index) { + const char *value; + size_t value_sz; + if (key == "") { + json_parse_c(s.c_str(), s.length(), nullptr, index, &value, &value_sz); + } else { + json_parse_c(s.c_str(), s.length(), key.c_str(), key.length(), &value, + &value_sz); + } + if (value != nullptr) { + if (value[0] != '"') { + return std::string(value, value_sz); + } + int n = json_unescape(value, value_sz, nullptr); + if (n > 0) { + char *decoded = new char[n + 1]; + json_unescape(value, value_sz, decoded); + std::string result(decoded, n); + delete[] decoded; + return result; + } + } + return ""; +} + +} // namespace webview + +#if defined(WEBVIEW_GTK) +// +// ==================================================================== +// +// This implementation uses webkit2gtk backend. It requires gtk+3.0 and +// webkit2gtk-4.0 libraries. Proper compiler flags can be retrieved via: +// +// pkg-config --cflags --libs gtk+-3.0 webkit2gtk-4.0 +// +// ==================================================================== +// +#include +#include +#include + +namespace webview { + +class gtk_webkit_engine { +public: + gtk_webkit_engine(bool debug, void *window) + : m_window(static_cast(window)) { + gtk_init_check(0, NULL); + m_window = static_cast(window); + if (m_window == nullptr) { + m_window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + } + g_signal_connect(G_OBJECT(m_window), "destroy", + G_CALLBACK(+[](GtkWidget *, gpointer arg) { + static_cast(arg)->terminate(); + }), + this); + // Initialize webview widget + m_webview = webkit_web_view_new(); + WebKitUserContentManager *manager = + webkit_web_view_get_user_content_manager(WEBKIT_WEB_VIEW(m_webview)); + g_signal_connect(manager, "script-message-received::external", + G_CALLBACK(+[](WebKitUserContentManager *, + WebKitJavascriptResult *r, gpointer arg) { + auto *w = static_cast(arg); +#if WEBKIT_MAJOR_VERSION >= 2 && WEBKIT_MINOR_VERSION >= 22 + JSCValue *value = + webkit_javascript_result_get_js_value(r); + char *s = jsc_value_to_string(value); +#else + JSGlobalContextRef ctx = + webkit_javascript_result_get_global_context(r); + JSValueRef value = webkit_javascript_result_get_value(r); + JSStringRef js = JSValueToStringCopy(ctx, value, NULL); + size_t n = JSStringGetMaximumUTF8CStringSize(js); + char *s = g_new(char, n); + JSStringGetUTF8CString(js, s, n); + JSStringRelease(js); +#endif + w->on_message(s); + g_free(s); + }), + this); + webkit_user_content_manager_register_script_message_handler(manager, + "external"); + init("window.external={invoke:function(s){window.webkit.messageHandlers." + "external.postMessage(s);}}"); + + gtk_container_add(GTK_CONTAINER(m_window), GTK_WIDGET(m_webview)); + gtk_widget_grab_focus(GTK_WIDGET(m_webview)); + + if (debug) { + WebKitSettings *settings = + webkit_web_view_get_settings(WEBKIT_WEB_VIEW(m_webview)); + webkit_settings_set_enable_write_console_messages_to_stdout(settings, + true); + webkit_settings_set_enable_developer_extras(settings, true); + } + + gtk_widget_show_all(m_window); + } + void *window() { return (void *)m_window; } + void run() { gtk_main(); } + void terminate() { gtk_main_quit(); } + void dispatch(std::function f) { + g_idle_add_full(G_PRIORITY_HIGH_IDLE, (GSourceFunc)([](void *f) -> int { + (*static_cast(f))(); + return G_SOURCE_REMOVE; + }), + new std::function(f), + [](void *f) { delete static_cast(f); }); + } + + void set_title(const std::string title) { + gtk_window_set_title(GTK_WINDOW(m_window), title.c_str()); + } + + void set_size(int width, int height, int hints) { + gtk_window_set_resizable(GTK_WINDOW(m_window), hints != WEBVIEW_HINT_FIXED); + if (hints == WEBVIEW_HINT_NONE) { + gtk_window_resize(GTK_WINDOW(m_window), width, height); + } else if (hints == WEBVIEW_HINT_FIXED) { + gtk_widget_set_size_request(m_window, width, height); + } else { + GdkGeometry g; + g.min_width = g.max_width = width; + g.min_height = g.max_height = height; + GdkWindowHints h = + (hints == WEBVIEW_HINT_MIN ? GDK_HINT_MIN_SIZE : GDK_HINT_MAX_SIZE); + // This defines either MIN_SIZE, or MAX_SIZE, but not both: + gtk_window_set_geometry_hints(GTK_WINDOW(m_window), nullptr, &g, h); + } + } + + void navigate(const std::string url) { + webkit_web_view_load_uri(WEBKIT_WEB_VIEW(m_webview), url.c_str()); + } + + void init(const std::string js) { + WebKitUserContentManager *manager = + webkit_web_view_get_user_content_manager(WEBKIT_WEB_VIEW(m_webview)); + webkit_user_content_manager_add_script( + manager, webkit_user_script_new( + js.c_str(), WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, + WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, NULL, NULL)); + } + + void eval(const std::string js) { + webkit_web_view_run_javascript(WEBKIT_WEB_VIEW(m_webview), js.c_str(), NULL, + NULL, NULL); + } + +private: + virtual void on_message(const std::string msg) = 0; + GtkWidget *m_window; + GtkWidget *m_webview; +}; + +using browser_engine = gtk_webkit_engine; + +} // namespace webview + +#elif defined(WEBVIEW_COCOA) + +// +// ==================================================================== +// +// This implementation uses Cocoa WKWebView backend on macOS. It is +// written using ObjC runtime and uses WKWebView class as a browser runtime. +// You should pass "-framework Webkit" flag to the compiler. +// +// ==================================================================== +// + +#define OBJC_OLD_DISPATCH_PROTOTYPES 1 +#include +#include + +#define NSBackingStoreBuffered 2 + +#define NSWindowStyleMaskResizable 8 +#define NSWindowStyleMaskMiniaturizable 4 +#define NSWindowStyleMaskTitled 1 +#define NSWindowStyleMaskClosable 2 + +#define NSApplicationActivationPolicyRegular 0 + +#define WKUserScriptInjectionTimeAtDocumentStart 0 + +namespace webview { + +// Helpers to avoid too much typing +id operator"" _cls(const char *s, std::size_t) { return (id)objc_getClass(s); } +SEL operator"" _sel(const char *s, std::size_t) { return sel_registerName(s); } +id operator"" _str(const char *s, std::size_t) { + return objc_msgSend("NSString"_cls, "stringWithUTF8String:"_sel, s); +} + +class cocoa_wkwebview_engine { +public: + cocoa_wkwebview_engine(bool debug, void *window) { + // Application + id app = objc_msgSend("NSApplication"_cls, "sharedApplication"_sel); + objc_msgSend(app, "setActivationPolicy:"_sel, + NSApplicationActivationPolicyRegular); + + // Delegate + auto cls = objc_allocateClassPair((Class) "NSResponder"_cls, "AppDelegate", 0); + class_addProtocol(cls, objc_getProtocol("NSTouchBarProvider")); + class_addMethod(cls, "applicationShouldTerminateAfterLastWindowClosed:"_sel, + (IMP)(+[](id, SEL, id) -> BOOL { return 1; }), "c@:@"); + class_addMethod(cls, "userContentController:didReceiveScriptMessage:"_sel, + (IMP)(+[](id self, SEL, id, id msg) { + auto w = + (cocoa_wkwebview_engine *)objc_getAssociatedObject( + self, "webview"); + w->on_message((const char *)objc_msgSend( + objc_msgSend(msg, "body"_sel), "UTF8String"_sel)); + }), + "v@:@@"); + objc_registerClassPair(cls); + + auto delegate = objc_msgSend((id)cls, "new"_sel); + objc_setAssociatedObject(delegate, "webview", (id)this, + OBJC_ASSOCIATION_ASSIGN); + objc_msgSend(app, sel_registerName("setDelegate:"), delegate); + + // Main window + if (window == nullptr) { + m_window = objc_msgSend("NSWindow"_cls, "alloc"_sel); + m_window = objc_msgSend( + m_window, "initWithContentRect:styleMask:backing:defer:"_sel, + CGRectMake(0, 0, 0, 0), 0, NSBackingStoreBuffered, 0); + } else { + m_window = (id)window; + } + + // Webview + auto config = objc_msgSend("WKWebViewConfiguration"_cls, "new"_sel); + m_manager = objc_msgSend(config, "userContentController"_sel); + m_webview = objc_msgSend("WKWebView"_cls, "alloc"_sel); + if (debug) { + objc_msgSend(objc_msgSend(config, "preferences"_sel), + "setValue:forKey:"_sel, + objc_msgSend("NSNumber"_cls, "numberWithBool:"_sel, 1), + "developerExtrasEnabled"_str); + } + objc_msgSend(m_webview, "initWithFrame:configuration:"_sel, + CGRectMake(0, 0, 0, 0), config); + objc_msgSend(m_manager, "addScriptMessageHandler:name:"_sel, delegate, + "external"_str); + init(R"script( + window.external = { + invoke: function(s) { + window.webkit.messageHandlers.external.postMessage(s); + }, + }; + )script"); + objc_msgSend(m_window, "setContentView:"_sel, m_webview); + objc_msgSend(m_window, "makeKeyAndOrderFront:"_sel, nullptr); + } + ~cocoa_wkwebview_engine() { close(); } + void *window() { return (void *)m_window; } + void terminate() { + close(); + objc_msgSend("NSApp"_cls, "terminate:"_sel, nullptr); + } + void run() { + id app = objc_msgSend("NSApplication"_cls, "sharedApplication"_sel); + dispatch([&]() { objc_msgSend(app, "activateIgnoringOtherApps:"_sel, 1); }); + objc_msgSend(app, "run"_sel); + } + void dispatch(std::function f) { + dispatch_async_f(dispatch_get_main_queue(), new dispatch_fn_t(f), + (dispatch_function_t)([](void *arg) { + auto f = static_cast(arg); + (*f)(); + delete f; + })); + } + void set_title(const std::string title) { + objc_msgSend(m_window, "setTitle:"_sel, + objc_msgSend("NSString"_cls, "stringWithUTF8String:"_sel, + title.c_str())); + } + void set_size(int width, int height, int hints) { + auto style = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | + NSWindowStyleMaskMiniaturizable; + if (hints != WEBVIEW_HINT_FIXED) { + style = style | NSWindowStyleMaskResizable; + } + objc_msgSend(m_window, "setStyleMask:"_sel, style); + + struct { + CGFloat width; + CGFloat height; + } size; + if (hints == WEBVIEW_HINT_MIN) { + size.width = width; + size.height = height; + objc_msgSend(m_window, "setContentMinSize:"_sel, size); + } else if (hints == WEBVIEW_HINT_MAX) { + size.width = width; + size.height = height; + objc_msgSend(m_window, "setContentMaxSize:"_sel, size); + } else { + objc_msgSend(m_window, "setFrame:display:animate:"_sel, + CGRectMake(0, 0, width, height), 1, 0); + } + } + void navigate(const std::string url) { + auto nsurl = objc_msgSend( + "NSURL"_cls, "URLWithString:"_sel, + objc_msgSend("NSString"_cls, "stringWithUTF8String:"_sel, url.c_str())); + objc_msgSend( + m_webview, "loadRequest:"_sel, + objc_msgSend("NSURLRequest"_cls, "requestWithURL:"_sel, nsurl)); + } + void init(const std::string js) { + objc_msgSend( + m_manager, "addUserScript:"_sel, + objc_msgSend(objc_msgSend("WKUserScript"_cls, "alloc"_sel), + "initWithSource:injectionTime:forMainFrameOnly:"_sel, + objc_msgSend("NSString"_cls, "stringWithUTF8String:"_sel, + js.c_str()), + WKUserScriptInjectionTimeAtDocumentStart, 1)); + } + void eval(const std::string js) { + objc_msgSend( + m_webview, "evaluateJavaScript:completionHandler:"_sel, + objc_msgSend("NSString"_cls, "stringWithUTF8String:"_sel, js.c_str()), + nullptr); + } + +private: + virtual void on_message(const std::string msg) = 0; + void close() { objc_msgSend(m_window, "close"_sel); } + id m_window; + id m_webview; + id m_manager; +}; + +using browser_engine = cocoa_wkwebview_engine; + +} // namespace webview + +#elif defined(WEBVIEW_EDGE) + +// +// ==================================================================== +// +// This implementation uses Win32 API to create a native window. It can +// use either EdgeHTML or Edge/Chromium backend as a browser engine. +// +// ==================================================================== +// + +#define WIN32_LEAN_AND_MEAN +#include + +#pragma comment(lib, "user32.lib") + +// EdgeHTML headers and libs +#include +#include +#include +#pragma comment(lib, "windowsapp") + +// Edge/Chromium headers and libs +#include "webview2.h" +#pragma comment(lib, "ole32.lib") +#pragma comment(lib, "oleaut32.lib") + +namespace webview { + +using msg_cb_t = std::function; + +// Common interface for EdgeHTML and Edge/Chromium +class browser { +public: + virtual ~browser() = default; + virtual bool embed(HWND, bool, msg_cb_t) = 0; + virtual void navigate(const std::string url) = 0; + virtual void eval(const std::string js) = 0; + virtual void init(const std::string js) = 0; + virtual void resize(HWND) = 0; +}; + +// +// EdgeHTML browser engine +// +using namespace winrt; +using namespace Windows::Foundation; +using namespace Windows::Web::UI; +using namespace Windows::Web::UI::Interop; + +class edge_html : public browser { +public: + bool embed(HWND wnd, bool debug, msg_cb_t cb) override { + init_apartment(winrt::apartment_type::single_threaded); + auto process = WebViewControlProcess(); + auto op = process.CreateWebViewControlAsync(reinterpret_cast(wnd), + Rect()); + if (op.Status() != AsyncStatus::Completed) { + handle h(CreateEvent(nullptr, false, false, nullptr)); + op.Completed([h = h.get()](auto, auto) { SetEvent(h); }); + HANDLE hs[] = {h.get()}; + DWORD i; + CoWaitForMultipleHandles(COWAIT_DISPATCH_WINDOW_MESSAGES | + COWAIT_DISPATCH_CALLS | + COWAIT_INPUTAVAILABLE, + INFINITE, 1, hs, &i); + } + m_webview = op.GetResults(); + m_webview.Settings().IsScriptNotifyAllowed(true); + m_webview.IsVisible(true); + m_webview.ScriptNotify([=](auto const &sender, auto const &args) { + std::string s = winrt::to_string(args.Value()); + cb(s.c_str()); + }); + m_webview.NavigationStarting([=](auto const &sender, auto const &args) { + m_webview.AddInitializeScript(winrt::to_hstring(init_js)); + }); + init("window.external.invoke = s => window.external.notify(s)"); + return true; + } + + void navigate(const std::string url) override { + std::string html = html_from_uri(url); + if (html != "") { + m_webview.NavigateToString(winrt::to_hstring(html)); + } else { + Uri uri(winrt::to_hstring(url)); + m_webview.Navigate(uri); + } + } + + void init(const std::string js) override { + init_js = init_js + "(function(){" + js + "})();"; + } + + void eval(const std::string js) override { + m_webview.InvokeScriptAsync( + L"eval", single_threaded_vector({winrt::to_hstring(js)})); + } + + void resize(HWND wnd) override { + if (m_webview == nullptr) { + return; + } + RECT r; + GetClientRect(wnd, &r); + Rect bounds(r.left, r.top, r.right - r.left, r.bottom - r.top); + m_webview.Bounds(bounds); + } + +private: + WebViewControl m_webview = nullptr; + std::string init_js = ""; +}; + +// +// Edge/Chromium browser engine +// +class edge_chromium : public browser { +public: + bool embed(HWND wnd, bool debug, msg_cb_t cb) override { + CoInitializeEx(nullptr, 0); + std::atomic_flag flag = ATOMIC_FLAG_INIT; + flag.test_and_set(); + HRESULT res = CreateWebView2EnvironmentWithDetails( + nullptr, nullptr, nullptr, + new webview2_com_handler(wnd, [&](IWebView2WebView *webview) { + m_webview = webview; + flag.clear(); + })); + if (res != S_OK) { + CoUninitialize(); + return false; + } + MSG msg = {}; + while (flag.test_and_set() && GetMessage(&msg, NULL, 0, 0)) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + init("window.external={invoke:s=>window.chrome.webview.postMessage(s)}"); + return true; + } + + void resize(HWND wnd) override { + if (m_webview == nullptr) { + return; + } + RECT bounds; + GetClientRect(wnd, &bounds); + m_webview->put_Bounds(bounds); + } + + void navigate(const std::string url) override { + auto wurl = to_lpwstr(url); + m_webview->Navigate(wurl); + delete[] wurl; + } + + void init(const std::string js) override { + LPCWSTR wjs = to_lpwstr(js); + m_webview->AddScriptToExecuteOnDocumentCreated(wjs, nullptr); + delete[] wjs; + } + + void eval(const std::string js) override { + LPCWSTR wjs = to_lpwstr(js); + m_webview->ExecuteScript(wjs, nullptr); + delete[] wjs; + } + +private: + LPWSTR to_lpwstr(const std::string s) { + int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, NULL, 0); + wchar_t *ws = new wchar_t[n]; + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, ws, n); + return ws; + } + + IWebView2WebView *m_webview = nullptr; + + class webview2_com_handler + : public IWebView2CreateWebView2EnvironmentCompletedHandler, + public IWebView2CreateWebViewCompletedHandler { + using webview2_com_handler_cb_t = std::function; + + public: + webview2_com_handler(HWND hwnd, webview2_com_handler_cb_t cb) + : m_window(hwnd), m_cb(cb) {} + ULONG STDMETHODCALLTYPE AddRef() { return 1; } + ULONG STDMETHODCALLTYPE Release() { return 1; } + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv) { + return S_OK; + } + HRESULT STDMETHODCALLTYPE Invoke(HRESULT res, IWebView2Environment *env) { + env->CreateWebView(m_window, this); + return S_OK; + } + HRESULT STDMETHODCALLTYPE Invoke(HRESULT res, IWebView2WebView *webview) { + webview->AddRef(); + m_cb(webview); + return S_OK; + } + + private: + HWND m_window; + webview2_com_handler_cb_t m_cb; + }; +}; + +class win32_edge_engine { +public: + win32_edge_engine(bool debug, void *window) { + if (window == nullptr) { + WNDCLASSEX wc; + ZeroMemory(&wc, sizeof(WNDCLASSEX)); + wc.cbSize = sizeof(WNDCLASSEX); + wc.hInstance = GetModuleHandle(nullptr); + wc.lpszClassName = "webview"; + wc.lpfnWndProc = + (WNDPROC)(+[](HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) -> int { + auto w = (win32_edge_engine *)GetWindowLongPtr(hwnd, GWLP_USERDATA); + switch (msg) { + case WM_SIZE: + w->m_browser->resize(hwnd); + break; + case WM_CLOSE: + DestroyWindow(hwnd); + break; + case WM_DESTROY: + w->terminate(); + break; + case WM_GETMINMAXINFO: { + auto lpmmi = (LPMINMAXINFO)lp; + if (w == nullptr) { + return 0; + } + if (w->m_maxsz.x > 0 && w->m_maxsz.y > 0) { + lpmmi->ptMaxSize = w->m_maxsz; + lpmmi->ptMaxTrackSize = w->m_maxsz; + } + if (w->m_minsz.x > 0 && w->m_minsz.y > 0) { + lpmmi->ptMinTrackSize = w->m_minsz; + } + } break; + default: + return DefWindowProc(hwnd, msg, wp, lp); + } + return 0; + }); + RegisterClassEx(&wc); + m_window = CreateWindow("webview", "", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, + CW_USEDEFAULT, 640, 480, nullptr, nullptr, + GetModuleHandle(nullptr), nullptr); + SetWindowLongPtr(m_window, GWLP_USERDATA, (LONG_PTR)this); + } else { + m_window = *(static_cast(window)); + } + + ShowWindow(m_window, SW_SHOW); + UpdateWindow(m_window); + SetFocus(m_window); + + auto cb = + std::bind(&win32_edge_engine::on_message, this, std::placeholders::_1); + + if (!m_browser->embed(m_window, debug, cb)) { + m_browser = std::make_unique(); + m_browser->embed(m_window, debug, cb); + } + + m_browser->resize(m_window); + } + + void run() { + MSG msg; + BOOL res; + while ((res = GetMessage(&msg, nullptr, 0, 0)) != -1) { + if (msg.hwnd) { + TranslateMessage(&msg); + DispatchMessage(&msg); + continue; + } + if (msg.message == WM_APP) { + auto f = (dispatch_fn_t *)(msg.lParam); + (*f)(); + delete f; + } else if (msg.message == WM_QUIT) { + return; + } + } + } + void *window() { return (void *)m_window; } + void terminate() { PostQuitMessage(0); } + void dispatch(dispatch_fn_t f) { + PostThreadMessage(m_main_thread, WM_APP, 0, (LPARAM) new dispatch_fn_t(f)); + } + + void set_title(const std::string title) { + SetWindowText(m_window, title.c_str()); + } + + void set_size(int width, int height, int hints) { + auto style = GetWindowLong(m_window, GWL_STYLE); + if (hints == WEBVIEW_HINT_FIXED) { + style &= ~(WS_THICKFRAME | WS_MAXIMIZEBOX); + } else { + style |= (WS_THICKFRAME | WS_MAXIMIZEBOX); + } + SetWindowLong(m_window, GWL_STYLE, style); + + if (hints == WEBVIEW_HINT_MAX) { + m_maxsz.x = width; + m_maxsz.y = height; + } else if (hints == WEBVIEW_HINT_MIN) { + m_minsz.x = width; + m_minsz.y = height; + } else { + RECT r; + r.left = r.top = 0; + r.right = width; + r.bottom = height; + AdjustWindowRect(&r, WS_OVERLAPPEDWINDOW, 0); + SetWindowPos( + m_window, NULL, r.left, r.top, r.right - r.left, r.bottom - r.top, + SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOMOVE | SWP_FRAMECHANGED); + m_browser->resize(m_window); + } + } + + void navigate(const std::string url) { m_browser->navigate(url); } + void eval(const std::string js) { m_browser->eval(js); } + void init(const std::string js) { m_browser->init(js); } + +private: + virtual void on_message(const std::string msg) = 0; + + HWND m_window; + POINT m_minsz = POINT { 0, 0 }; + POINT m_maxsz = POINT { 0, 0 }; + DWORD m_main_thread = GetCurrentThreadId(); + std::unique_ptr m_browser = + std::make_unique(); +}; + +using browser_engine = win32_edge_engine; +} // namespace webview + +#endif /* WEBVIEW_GTK, WEBVIEW_COCOA, WEBVIEW_EDGE */ + +namespace webview { + +class webview : public browser_engine { +public: + webview(bool debug = false, void *wnd = nullptr) + : browser_engine(debug, wnd) {} + + void navigate(const std::string url) { + if (url == "") { + browser_engine::navigate("data:text/html," + + url_encode("Hello")); + return; + } + std::string html = html_from_uri(url); + if (html != "") { + browser_engine::navigate("data:text/html," + url_encode(html)); + } else { + browser_engine::navigate(url); + } + } + + using binding_t = std::function; + using binding_ctx_t = std::pair; + + using sync_binding_t = std::function; + using sync_binding_ctx_t = std::pair; + + void bind(const std::string name, sync_binding_t fn) { + bind(name, + [](std::string seq, std::string req, void *arg) { + auto pair = static_cast(arg); + pair->first->resolve(seq, 0, pair->second(req)); + }, + new sync_binding_ctx_t(this, fn)); + } + + void bind(const std::string name, binding_t f, void *arg) { + auto js = "(function() { var name = '" + name + "';" + R"( + var RPC = window._rpc = (window._rpc || {nextSeq: 1}); + window[name] = function() { + var seq = RPC.nextSeq++; + var promise = new Promise(function(resolve, reject) { + RPC[seq] = { + resolve: resolve, + reject: reject, + }; + }); + window.external.invoke(JSON.stringify({ + id: seq, + method: name, + params: Array.prototype.slice.call(arguments), + })); + return promise; + } + })())"; + init(js); + bindings[name] = new binding_ctx_t(new binding_t(f), arg); + } + + void resolve(const std::string seq, int status, const std::string result) { + dispatch([=]() { + if (status == 0) { + eval("window._rpc[" + seq + "].resolve(" + result + "); window._rpc[" + + seq + "] = undefined"); + } else { + eval("window._rpc[" + seq + "].reject(" + result + "); window._rpc[" + + seq + "] = undefined"); + } + }); + } + +private: + void on_message(const std::string msg) { + auto seq = json_parse(msg, "id", 0); + auto name = json_parse(msg, "method", 0); + auto args = json_parse(msg, "params", 0); + if (bindings.find(name) == bindings.end()) { + return; + } + auto fn = bindings[name]; + (*fn->first)(seq, args, fn->second); + } + std::map bindings; +}; +} // namespace webview + +WEBVIEW_API webview_t webview_create(int debug, void *wnd) { + return new webview::webview(debug, wnd); +} + +WEBVIEW_API void webview_destroy(webview_t w) { + delete static_cast(w); +} + +WEBVIEW_API void webview_run(webview_t w) { + static_cast(w)->run(); +} + +WEBVIEW_API void webview_terminate(webview_t w) { + static_cast(w)->terminate(); +} + +WEBVIEW_API void webview_dispatch(webview_t w, void (*fn)(webview_t, void *), + void *arg) { + static_cast(w)->dispatch([=]() { fn(w, arg); }); +} + +WEBVIEW_API void *webview_get_window(webview_t w) { + return static_cast(w)->window(); +} + +WEBVIEW_API void webview_set_title(webview_t w, const char *title) { + static_cast(w)->set_title(title); +} + +WEBVIEW_API void webview_set_size(webview_t w, int width, int height, + int hints) { + static_cast(w)->set_size(width, height, hints); +} + +WEBVIEW_API void webview_navigate(webview_t w, const char *url) { + static_cast(w)->navigate(url); +} + +WEBVIEW_API void webview_init(webview_t w, const char *js) { + static_cast(w)->init(js); +} + +WEBVIEW_API void webview_eval(webview_t w, const char *js) { + static_cast(w)->eval(js); +} + +WEBVIEW_API void webview_bind(webview_t w, const char *name, + void (*fn)(const char *seq, const char *req, + void *arg), + void *arg) { + static_cast(w)->bind( + name, + [=](std::string seq, std::string req, void *arg) { + fn(seq.c_str(), req.c_str(), arg); + }, + arg); +} + +WEBVIEW_API void webview_return(webview_t w, const char *seq, int status, + const char *result) { + static_cast(w)->resolve(seq, status, result); +} + +#endif /* WEBVIEW_HEADER */ + +#endif /* WEBVIEW_H */ + diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..8a7d65d --- /dev/null +++ b/shard.yml @@ -0,0 +1,12 @@ +name: webview +version: 0.1.0 + +authors: + - Ali Naqvi + +description: | + Crystal bindings to the zserge's tiny cross-platform Webview library. + +crystal: 0.34.0 + +license: MIT diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..3d8b3df --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/webview" diff --git a/spec/webview_spec.cr b/spec/webview_spec.cr new file mode 100644 index 0000000..a2b6360 --- /dev/null +++ b/spec/webview_spec.cr @@ -0,0 +1,9 @@ +require "./spec_helper" + +describe Webview do + # TODO: Write tests + + it "works" do + false.should eq(true) + end +end diff --git a/src/lib.cr b/src/lib.cr new file mode 100644 index 0000000..ee4aec8 --- /dev/null +++ b/src/lib.cr @@ -0,0 +1,69 @@ +module Webview + {% if flag?(:darwin) %} + @[Link(ldflags: "-L#{__DIR__}/../ext/darwin -lwebview.o -lc++")] + @[Link(framework: "WebKit")] + {% elsif flag?(:linux) %} + @[Link(ldflags: "-L#{__DIR__}/../ext/linux -lwebview.o -lc++")] + @[Link(ldflags: "`command -v pkg-config > /dev/null && pkg-config --cflags --libs gtk+-3.0 webkit2gtk-4.0`")] + {% elsif flag?(:windows) %} + @[Link(ldflags: "-L#{__DIR__}/../ext/windows -lwebview.o -lc++")] + # Windows requires special linker flags for GUI apps. + @[Link(ldflags: "-lole32 -lcomctl32 -loleaut32 -luuid -lgdi32 -H windowsgui")] + {% else %} + raise "Platform not supported" + {% end %} + lib LibWebView + alias T = Void* + + # Creates a new webview instance. If debug is non-zero - developer tools will + # be enabled (if the platform supports them). Window parameter can be a + # pointer to the native window handle. If it's non-null - then child WebView + # is embedded into the given parent window. Otherwise a new window is created. + # Depending on the platform, a GtkWindow, NSWindow or HWND pointer can be + # passed here. + fun create = webview_create(debug : LibC::Int, window : Void*) : T + # Destroys a webview and closes the native window. + fun destroy = webview_destroy(w : T) + # Runs the main loop until it's terminated. After this function exits - you + # must destroy the webview. + fun run = webview_run(w : T) + # Stops the main loop. It is safe to call this function from another other + # background thread. + fun terminate = webview_terminate(w : T) + # Posts a function to be executed on the main thread. You normally do not need + # to call this function, unless you want to tweak the native window. + fun dispatch = webview_dispatch(w : T, fn : (T, Void* -> Void), arg : Void*) + # Returns a native window handle pointer. When using GTK backend the pointer + # is GtkWindow pointer, when using Cocoa backend the pointer is NSWindow + # pointer, when using Win32 backend the pointer is HWND pointer. + fun get_window = webview_get_window(w : T) : Void* + # Updates the title of the native window. Must be called from the UI thread. + fun set_title = webview_set_title(w : T, title : LibC::Char*) + # Updates native window size. See WEBVIEW_HINT constants. + fun set_size = webview_set_size(w : T, width : LibC::Int, height : LibC::Int, hints : LibC::Int) + # Navigates webview to the given URL. URL may be a data URI, i.e. + # "data:text/text,...". It is often ok not to url-encode it + # properly, webview will re-encode it for you. + fun navigate = webview_navigate(w : T, url : LibC::Char*) + + # Injects JavaScript code at the initialization of the new page. Every time + # the webview will open a the new page - this initialization code will be + # executed. It is guaranteed that code is executed before window.onload. + fun init = webview_init(w : T, js : LibC::Char*) + # Evaluates arbitrary JavaScript code. Evaluation happens asynchronously, also + # the result of the expression is ignored. Use RPC bindings if you want to + # receive notifications about the results of the evaluation. + fun eval = webview_eval(w : T, js : LibC::Char*) + # Binds a native C callback so that it will appear under the given name as a + # global JavaScript function. Internally it uses webview_init(). Callback + # receives a request string and a user-provided argument pointer. Request + # string is a JSON array of all the arguments passed to the JavaScript + # function. + fun bind = webview_bind(w : T, name : LibC::Char*, fn : (LibC::Char*, LibC::Char*, Void* -> Void), arg : Void*) + # Allows to return a value from the native binding. Original request pointer + # must be provided to help internal RPC engine match requests with responses. + # If status is zero - result is expected to be a valid JSON result value. + # If status is not zero - result is an error JSON object. + fun webview_return(w : T, seq : LibC::Char*, status : LibC::Int, result : LibC::Char*) + end +end diff --git a/src/webview.cr b/src/webview.cr new file mode 100644 index 0000000..ce52f77 --- /dev/null +++ b/src/webview.cr @@ -0,0 +1,119 @@ +require "json" + +# Crystal bindings for [zserge's Webview](https://github.com/zserge/webview) which is an excellent cross-platform single header webview library for C/C++ using Gtk, Cocoa or MSHTML repectively. +module Webview + VERSION = "0.1.0" + + # Window size hints + enum SizeHints + NONE = 0 # Width and height are default size + MIN = 1 # Width and height are minimum bounds + MAX = 2 # Width and height are maximum bounds + FIXED = 3 # Window size can not be changed by user + end + alias JSProc = Array(JSON::Any) -> JSON::Any + + class Webview + private record BindContext, w : LibWebView::T, cb : JSProc + + @@dispatchs = Hash(Proc(Nil), Pointer(Void)?).new + @@bindings = Hash(JSProc, Pointer(Void)?).new + + def initialize(debug, title) + @w = LibWebView.create(debug ? 1 : 0, nil) + LibWebView.set_title(@w, title) + end + + # destroys a WebView and closes the native window. + def destroy + LibWebView.destroy(@w) + end + + # Terminate stops the main loop. It is safe to call this function from + # a background thread. + def terminate + LibWebView.terminate(@w) + end + + # runs the main loop until it's terminated. After this function exists + # you must destroy the WebView + def run + LibWebView.run(@w) + end + + # returns a native window handle pointer. When using GTK backend the + # pointer is GtkWindow pointer, when using Cocoa backend the pointer is + # NSWindow pointer, when using Win32 backend the pointer is HWND pointer. + def window + LibWebView.get_window(@w) + end + + def title=(val) + LibWebView.set_title(@w, val) + end + + def size(width, height, hint : SizeHints) + LibWebView.set_size(@w, width, height, hint.value) + end + + # navigates WebView to the given URL. URL may be a data URI, i.e. + # "data:text/text,..". It is often ok not to url-encode it + # properlty, WebView will re-encode it for you. + def navigate(url) + LibWebView.navigate(@w, url) + end + + # posts a function to be executed on the main thread. You normally do no need + # to call this function, unless you want to tweak the native window. + def dispatch(&f : ->) + boxed = Box.box(f) + @@dispatchs[f] = boxed + + LibWebView.dispatch(@w, ->(_w, data) { + cb = Box(typeof(f)).unbox(data) + cb.call + @@dispatchs.delete(cb) + }, boxed) + end + + # injects Javascript code at the initialization of the new page. Every + # time the WebView will open the new page - this initialization code will + # be executed. It is guaranteed that code is executed before window.onload. + def init(js : String) + LibWebView.init(@w, js) + end + + # evaluates arbitrary Javascript code. Evaluation happens asynchronously, + # also the result of the expression is ignored. Use RPC bindings if you want + # to receive notifications about the result of the evaluation. + def eval(js : String) + LibWebView.init(@w, js) + end + + # binds a callback function so that it will appear under the given name + # as a global Javascript function. + def bind(name : String, fn : JSProc) + ctx = BindContext.new(@w, fn) + boxed = Box.box(ctx) + @@bindings[fn] = boxed + + LibWebView.bind(@w, name, ->(id, req, data) { + raw = JSON.parse(String.new(req)) + cb_ctx = Box(BindContext).unbox(data) + res = cb_ctx.cb.call(raw.as_a) + @@bindings.delete(cb_ctx.cb) + LibWebView.webview_return(cb_ctx.w, id, 0, res.to_s) + }, boxed) + end + end + + def self.window(width, height, hint, title, url, debug = false) + wv = Webview.new(debug, title) + wv.size(width, height, hint) + wv.title = title + wv.navigate(url) + wv + end +end + +require "./*"