├── .gitignore ├── pyproject.toml ├── LICENSE ├── README.md └── wkwebview.py /.gitignore: -------------------------------------------------------------------------------- 1 | /venv/ 2 | /.idea/ 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=2,<3"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "wkwebview" 7 | author = "Mikael Honkala" 8 | author-email = "mikael.honkala@gmail.com" 9 | home-page = "https://github.com/mikaelho/pythonista-webview" 10 | dist-name = "pythonista-wkwebview" 11 | description-file = "README.md" 12 | license = "TheUnlicense" 13 | classifiers = [ 14 | "Operating System :: iOS" 15 | ] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pythonista-webview 2 | 3 | WKWebView implementation for Pythonista. 4 | 5 | The underlying component used to implement ui.WebView in Pythonista is 6 | UIWebView, which has been deprecated since iOS 8. This module implements a 7 | Python webview API using the current iOS-provided view, WKWebView. Besides 8 | being Apple-supported, WKWebView brings other benefits such as better 9 | Javascript performance and an official communication channel from 10 | Javascript to Python. This implementation of a Python API also has the 11 | additional benefit of being inheritable. 12 | 13 | Available as a [single file](https://github.com/mikaelho/pythonista-webview) 14 | on GitHub, or install with: 15 | 16 | pip install pythonista-wkwebview 17 | 18 | in stash. 19 | 20 | Run the file as-is to try out some of the capabilities; check the end of the 21 | file for demo code. 22 | 23 | Credits: This would not exist without @JonB and @mithrendal (Pythonista 24 | forum handles). 25 | 26 | ## Basic usage 27 | 28 | WKWebView matches ui.WebView API as defined in Pythonista docs. For example: 29 | 30 | ``` 31 | v = WKWebView() 32 | v.present() 33 | v.load_html('Hello world') 34 | v.load_url('http://omz-software.com/pythonista/') 35 | ``` 36 | 37 | For compatibility, there is also the same delegate API that ui.WebView has, 38 | with `webview_should_start_load` etc. methods. 39 | 40 | ## Deviations from ui.WebView API 41 | 42 | ### Synchronous vs. asynchronous JS evaluation 43 | 44 | Apple's WKWebView only provides an async Javascript evaluation function. 45 | This is available as an `eval_js_async` method, with an optional `callback` 46 | argument that will be called with a single argument containing the result of 47 | the JS evaluation (or None). 48 | 49 | We also provide a synchronous `eval_js` method, which essentially waits for 50 | the callback before returning the result. For this to work, you have to call 51 | the `eval_js` method outside the main UI thread, e.g. from a method decorated 52 | with `ui.in_background`. 53 | 54 | ### Handling page scaling 55 | 56 | UIWebView had a property called `scales_page_to_fit`, WKWebView does not. See 57 | below for the various `disable` methods that can be used instead. 58 | 59 | ## Additional features and notes 60 | 61 | ### http allowed 62 | 63 | Looks like Pythonista has the specific plist entry required to allow fetching 64 | non-secure http urls. 65 | 66 | ### Cache and timeouts 67 | 68 | For remote (non-file) `load_url` requests, there are two additional options: 69 | 70 | * Set `no_cache` to `True` to skip the local cache, default is `False` 71 | * Set `timeout` to a specific timeout value, default is 10 (seconds) 72 | 73 | You can also explicitly clear all data types from the default data store with 74 | the `clear_cache` instance method. The method takes an optional parameter, a 75 | plain function that will be called when the async cache clearing operation is 76 | finished: 77 | 78 | def cleared(): 79 | print('Cache cleared') 80 | 81 | WKWebView().clear_cache(cleared) 82 | 83 | ### Media playback 84 | 85 | Following media playback options are available as WKWebView constructor 86 | parameters: 87 | 88 | * `inline_media` - whether HTML5 videos play inline or use the native 89 | full-screen controller. The default value for iPhone is False and the 90 | default value for iPad is True. 91 | * `airplay_media` - whether AirPlay is allowed. The default value is True. 92 | * `pip_media` - whether HTML5 videos can play picture-in-picture. 93 | The default value is True. 94 | 95 | ### Other url schemes 96 | 97 | If you try to open a url not natively supported by WKWebView, such as `tel:` 98 | for phone numbers, the `webbrowser` module is used to open it. 99 | 100 | ### Swipe navigation 101 | 102 | There is a new property, `swipe_navigation`, False by default. If set to True, 103 | horizontal swipes navigate backwards and forwards in the browsing history. 104 | 105 | Note that browsing history is only updated for calls to `load_url` - 106 | `load_html` is ignored (Apple feature that has some security justification). 107 | 108 | ### Data detection 109 | 110 | By default, no Apple data detectors are active for WKWebView. You can activate 111 | them by including one or a tuple of the following values as the 112 | `data_detectors` argument to the constructor: NONE, PHONE_NUMBER, LINK, 113 | ADDRESS, CALENDAR_EVENT, TRACKING_NUMBER, FLIGHT_NUMBER, LOOKUP_SUGGESTION, 114 | ALL. 115 | 116 | For example, activating just the phone and link detectors: 117 | 118 | v = WKWebView(data_detectors=(WKWebView.PHONE_NUMBER, WKWebView.LINK)) 119 | 120 | ### Messages from JS to Python 121 | 122 | WKWebView comes with support for JS-to-container messages. Use this by 123 | subclassing WKWebView and implementing methods that start with `on_` and 124 | accept one message argument. These methods are then callable from JS with the 125 | pithy `window.webkit.messageHandler..postMessage` call, where `` 126 | corresponds to whatever you have on the method name after the `on_` prefix. 127 | 128 | Here's a minimal example: 129 | 130 | class MagicWebView(WKWebView): 131 | 132 | def on_magic(self, message): 133 | print('WKWebView magic ' + message) 134 | 135 | html = ''' 136 | 137 | 140 | 141 | ''' 142 | 143 | v = MagicWebView() 144 | v.load_html(html) 145 | 146 | Note that JS postMessage must have a parameter, and the message argument to 147 | the Python handler is always a string version of that parameter. For 148 | structured data, you need to use e.g. JSON at both ends. 149 | 150 | ### User scripts a.k.a. script injection 151 | 152 | WKWebView supports defining JS scripts that will be automatically loaded with 153 | every page. 154 | 155 | Use the `add_script(js_script, add_to_end=True)` method for this. 156 | 157 | Scripts are added to all frames. Removing scripts is currently not implemented. 158 | 159 | Following two convenience methods are also available: 160 | 161 | * `add_style(css)` to add a style tag containing the given CSS style 162 | definition. 163 | * `add_meta(name, content)` to add a meta tag with the given name and content. 164 | 165 | ### Making a web page behave more like an app 166 | 167 | These methods set various style and meta tags to disable typical web 168 | interaction modes: 169 | 170 | * `disable_zoom` 171 | * `disable_user_selection` 172 | * `disable_font_resizing` 173 | * `disable_scrolling` (alias for setting `scroll_enabled` to False) 174 | 175 | There is also a convenience method, `disable_all`, which calls all of the 176 | above. 177 | 178 | Note that disabling user selection will also disable the automatic data 179 | detection of e.g. phone numbers, described earlier. 180 | 181 | ### Javascript debugging 182 | 183 | Javascript errors and console messages are sent to Python side and printed to 184 | Pythonista console. Supported JS console methods are `log`, `info`, `warn` and 185 | `error`. 186 | 187 | For further JS debugging and experimentation, there is a simple convenience 188 | command-line utility that can be used to evaluate load URLs and evaluate 189 | javascript. If you `present` your app as a 'panel', you can easily switch 190 | between the tabs for your web page and this console. 191 | 192 | Or you can just create a WKWebView manually for quick experimentation, like 193 | in the usage example below. 194 | 195 | >>> from wkwebview import * 196 | >>> v = WKWebView(name='Demo') 197 | >>> WKWebView.console() 198 | Welcome to WKWebView console. Evaluate javascript in any active WKWebView. 199 | Special commands: list, switch #, load , quit 200 | js> list 201 | 0 - Demo - 202 | js> load http://omz-software.com/pythonista/ 203 | js> list 204 | 0 - Demo - Pythonista for iOS 205 | js> document.title 206 | Pythonista for iOS 207 | js> quit 208 | >>> v2 = WKWebView(name='Other view') 209 | >>> WKWebView.console() 210 | Welcome to WKWebView console. Evaluate javascript in any active WKWebView. 211 | Special commands: list, switch #, load , quit 212 | js> list 213 | 0 - Demo - Pythonista for iOS 214 | 1 - Other view - 215 | js> switch 1 216 | js> load https://www.python.org 217 | js> document.title 218 | Welcome to Python.org 219 | js> window.doesNotExist.wrongFunction() 220 | ERROR: TypeError: undefined is not an object 221 | (evaluating 'window.doesNotExist.wrongFunction') 222 | (https://www.python.org/, line: 1, column: 20) 223 | None 224 | js> quit 225 | 226 | ### Setting a custom user agent 227 | 228 | WKWebView has a `user_agent` property that can be used to retrieve or set a 229 | value reported to the server when requesting pages. 230 | 231 | ### Customize Javascript popups 232 | 233 | Javascript alert, confirm and prompt dialogs are now implemented with simple 234 | Pythonista equivalents. If you need something fancier or e.g. 235 | internationalization support, subclass WKWebView and re-implement the 236 | following methods as needed: 237 | 238 | def _javascript_alert(self, host, message): 239 | console.alert(host, message, 'OK', hide_cancel_button=True) 240 | 241 | def _javascript_confirm(self, host, message): 242 | try: 243 | console.alert(host, message, 'OK') 244 | return True 245 | except KeyboardInterrupt: 246 | return False 247 | 248 | def _javascript_prompt(self, host, prompt, default_text): 249 | try: 250 | return console.input_alert(host, prompt, default_text, 'OK') 251 | except KeyboardInterrupt: 252 | return None 253 | 254 | -------------------------------------------------------------------------------- /wkwebview.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | ''' 3 | WKWebView - modern webview for Pythonista 4 | ''' 5 | 6 | __version__ = '1.0' 7 | 8 | from objc_util import * 9 | import ui, console, webbrowser 10 | import queue, weakref, ctypes, functools, time, os, json, re 11 | from types import SimpleNamespace 12 | 13 | 14 | # Helpers for invoking ObjC function blocks with no return value 15 | 16 | class _block_descriptor (Structure): 17 | _fields_ = [ 18 | ('reserved', c_ulong), 19 | ('size', c_ulong), 20 | ('copy_helper', c_void_p), 21 | ('dispose_helper', c_void_p), 22 | ('signature', c_char_p) 23 | ] 24 | 25 | def _block_literal_fields(*arg_types): 26 | return [ 27 | ('isa', c_void_p), 28 | ('flags', c_int), 29 | ('reserved', c_int), 30 | ('invoke', ctypes.CFUNCTYPE(c_void_p, c_void_p, *arg_types)), 31 | ('descriptor', _block_descriptor) 32 | ] 33 | 34 | 35 | class WKWebView(ui.View): 36 | 37 | # Data detector constants 38 | NONE = 0 39 | PHONE_NUMBER = 1 40 | LINK = 1 << 1 41 | ADDRESS = 1 << 2 42 | CALENDAR_EVENT = 1 << 3 43 | TRACKING_NUMBER = 1 << 4 44 | FLIGHT_NUMBER = 1 << 5 45 | LOOKUP_SUGGESTION = 1 << 6 46 | ALL = 18446744073709551615 # NSUIntegerMax 47 | 48 | # Global webview index for console 49 | webviews = [] 50 | console_view = UIApplication.sharedApplication().\ 51 | keyWindow().rootViewController().\ 52 | accessoryViewController().\ 53 | consoleViewController() 54 | 55 | def __init__(self, 56 | swipe_navigation=False, 57 | data_detectors=NONE, 58 | log_js_evals=False, 59 | respect_safe_areas=False, 60 | inline_media=None, 61 | airplay_media=True, 62 | pip_media=True, 63 | **kwargs): 64 | 65 | WKWebView.webviews.append(self) 66 | self.delegate = None 67 | self.log_js_evals = log_js_evals 68 | self.respect_safe_areas = respect_safe_areas 69 | super().__init__(**kwargs) 70 | 71 | self.eval_js_queue = queue.Queue() 72 | 73 | custom_message_handler = WKWebView.CustomMessageHandler.\ 74 | new().autorelease() 75 | retain_global(custom_message_handler) 76 | custom_message_handler._pythonistawebview = weakref.ref(self) 77 | 78 | user_content_controller = WKWebView.WKUserContentController.\ 79 | new().autorelease() 80 | self.user_content_controller = user_content_controller 81 | for key in dir(self): 82 | if key.startswith('on_'): 83 | message_name = key[3:] 84 | user_content_controller.addScriptMessageHandler_name_( 85 | custom_message_handler, message_name) 86 | 87 | self.add_script(WKWebView.js_logging_script) 88 | 89 | webview_config = WKWebView.WKWebViewConfiguration.new().autorelease() 90 | webview_config.userContentController = user_content_controller 91 | 92 | data_detectors = sum(data_detectors) if type(data_detectors) is tuple \ 93 | else data_detectors 94 | webview_config.setDataDetectorTypes_(data_detectors) 95 | 96 | # Must be set to True to get real js 97 | # errors, in combination with setting a 98 | # base directory in the case of load_html 99 | webview_config.preferences().setValue_forKey_(True, 100 | 'allowFileAccessFromFileURLs') 101 | 102 | if inline_media is not None: 103 | webview_config.allowsInlineMediaPlayback = inline_media 104 | webview_config.allowsAirPlayForMediaPlayback = airplay_media 105 | webview_config.allowsPictureInPictureMediaPlayback = pip_media 106 | 107 | nav_delegate = WKWebView.CustomNavigationDelegate.new() 108 | retain_global(nav_delegate) 109 | nav_delegate._pythonistawebview = weakref.ref(self) 110 | 111 | ui_delegate = WKWebView.CustomUIDelegate.new() 112 | retain_global(ui_delegate) 113 | ui_delegate._pythonistawebview = weakref.ref(self) 114 | 115 | self._create_webview(webview_config, nav_delegate, ui_delegate) 116 | 117 | self.swipe_navigation = swipe_navigation 118 | 119 | @on_main_thread 120 | def _create_webview(self, webview_config, nav_delegate, ui_delegate): 121 | self.webview = WKWebView.WKWebView.alloc().\ 122 | initWithFrame_configuration_( 123 | ((0,0), (self.width, self.height)), webview_config).autorelease() 124 | self.webview.autoresizingMask = 2 + 16 # WH 125 | self.webview.setNavigationDelegate_(nav_delegate) 126 | self.webview.setUIDelegate_(ui_delegate) 127 | self.objc_instance.addSubview_(self.webview) 128 | 129 | def layout(self): 130 | if self.respect_safe_areas: 131 | self.update_safe_area_insets() 132 | 133 | @on_main_thread 134 | def load_url(self, url, no_cache=False, timeout=10): 135 | """ Loads the contents of the given url 136 | asynchronously. 137 | 138 | If the url starts with `file://`, loads a local file. If the remaining 139 | url starts with `/`, path starts from Pythonista root. 140 | 141 | For remote (non-file) requests, there are 142 | two additional options: 143 | 144 | * Set `no_cache` to `True` to skip the local cache, default is `False` 145 | * Set `timeout` to a specific timeout value, default is 10 (seconds) 146 | """ 147 | if url.startswith('file://'): 148 | file_path = url[7:] 149 | if file_path.startswith('/'): 150 | root = os.path.expanduser('~') 151 | file_path = root + file_path 152 | else: 153 | current_working_directory = os.path.dirname(os.getcwd()) 154 | file_path = current_working_directory+'/' + file_path 155 | dir_only = os.path.dirname(file_path) 156 | file_path = NSURL.fileURLWithPath_(file_path) 157 | dir_only = NSURL.fileURLWithPath_(dir_only) 158 | self.webview.loadFileURL_allowingReadAccessToURL_( 159 | file_path, dir_only) 160 | else: 161 | cache_policy = 1 if no_cache else 0 162 | self.webview.loadRequest_( 163 | WKWebView.NSURLRequest. 164 | requestWithURL_cachePolicy_timeoutInterval_( 165 | nsurl(url), 166 | cache_policy, 167 | timeout)) 168 | 169 | @on_main_thread 170 | def load_html(self, html): 171 | # Need to set a base directory to get 172 | # real js errors 173 | current_working_directory = os.path.dirname(os.getcwd()) 174 | root_dir = NSURL.fileURLWithPath_(current_working_directory) 175 | self.webview.loadHTMLString_baseURL_(html, root_dir) 176 | 177 | def eval_js(self, js): 178 | self.eval_js_async(js, self._eval_js_sync_callback) 179 | value = self.eval_js_queue.get() 180 | return value 181 | 182 | evaluate_javascript = eval_js 183 | 184 | @on_main_thread 185 | def _eval_js_sync_callback(self, value): 186 | self.eval_js_queue.put(value) 187 | 188 | @on_main_thread 189 | def eval_js_async(self, js, callback=None): 190 | if self.log_js_evals: 191 | self.console.message({'level': 'code', 'content': js}) 192 | handler = functools.partial( 193 | WKWebView._handle_completion, callback, self) 194 | block = ObjCBlock( 195 | handler, restype=None, argtypes=[c_void_p, c_void_p, c_void_p]) 196 | retain_global(block) 197 | self.webview.evaluateJavaScript_completionHandler_(js, block) 198 | 199 | def clear_cache(self, completion_handler=None): 200 | store = WKWebView.WKWebsiteDataStore.defaultDataStore() 201 | data_types = WKWebView.WKWebsiteDataStore.allWebsiteDataTypes() 202 | from_start = WKWebView.NSDate.dateWithTimeIntervalSince1970_(0) 203 | def dummy_completion_handler(): 204 | pass 205 | store.removeDataOfTypes_modifiedSince_completionHandler_( 206 | data_types, from_start, 207 | completion_handler or dummy_completion_handler) 208 | 209 | # Javascript evaluation completion handler 210 | 211 | def _handle_completion(callback, webview, _cmd, _obj, _err): 212 | result = str(ObjCInstance(_obj)) if _obj else None 213 | if webview.log_js_evals: 214 | webview._message({'level': 'raw', 'content': str(result)}) 215 | if callback: 216 | callback(result) 217 | 218 | def add_script(self, js_script, add_to_end=True): 219 | location = 1 if add_to_end else 0 220 | wk_script = WKWebView.WKUserScript.alloc().\ 221 | initWithSource_injectionTime_forMainFrameOnly_( 222 | js_script, location, False) 223 | self.user_content_controller.addUserScript_(wk_script) 224 | 225 | def add_style(self, css): 226 | """ 227 | Convenience method to add a style tag with the given css, to every 228 | page loaded by the view. 229 | """ 230 | css = css.replace("'", "\'") 231 | js = f"var style = document.createElement('style');" 232 | "style.innerHTML = '{css}';" 233 | "document.getElementsByTagName('head')[0].appendChild(style);" 234 | self.add_script(js, add_to_end=True) 235 | 236 | def add_meta(self, name, content): 237 | """ 238 | Convenience method to add a meta tag with the given name and content, 239 | to every page loaded by the view." 240 | """ 241 | name = name.replace("'", "\'") 242 | content = content.replace("'", "\'") 243 | js = f"var meta = document.createElement('meta');" 244 | "meta.setAttribute('name', '{name}');" 245 | "meta.setAttribute('content', '{content}');" 246 | "document.getElementsByTagName('head')[0].appendChild(meta);" 247 | self.add_script(js, add_to_end=True) 248 | 249 | def disable_zoom(self): 250 | name = 'viewport' 251 | content = 'width=device-width, initial-scale=1.0,' 252 | 'maximum-scale=1.0, user-scalable=no' 253 | self.add_meta(name, content) 254 | 255 | def disable_user_selection(self): 256 | css = '* { -webkit-user-select: none; }' 257 | self.add_style(css) 258 | 259 | def disable_font_resizing(self): 260 | css = 'body { -webkit-text-size-adjust: none; }' 261 | self.add_style(css) 262 | 263 | def disable_scrolling(self): 264 | """ 265 | Included for consistency with the other `disable_x` methods, this is 266 | equivalent to setting `scroll_enabled` to false." 267 | """ 268 | self.scroll_enabled = False 269 | 270 | def disable_all(self): 271 | """ 272 | Convenience method that calls all the `disable_x` methods to make the 273 | loaded pages act more like an app." 274 | """ 275 | self.disable_zoom() 276 | self.disable_scrolling() 277 | self.disable_user_selection() 278 | self.disable_font_resizing() 279 | 280 | @property 281 | def user_agent(self): 282 | "Must be called outside main thread" 283 | return self.eval_js('navigator.userAgent') 284 | 285 | @on_main_thread 286 | def _get_user_agent2(self): 287 | return str(self.webview.customUserAgent()) 288 | 289 | @user_agent.setter 290 | def user_agent(self, value): 291 | value = str(value) 292 | self._set_user_agent(value) 293 | 294 | @on_main_thread 295 | def _set_user_agent(self, value): 296 | self.webview.setCustomUserAgent_(value) 297 | 298 | @on_main_thread 299 | def go_back(self): 300 | self.webview.goBack() 301 | 302 | @on_main_thread 303 | def go_forward(self): 304 | self.webview.goForward() 305 | 306 | @on_main_thread 307 | def reload(self): 308 | self.webview.reload() 309 | 310 | @on_main_thread 311 | def stop(self): 312 | self.webview.stopLoading() 313 | 314 | @property 315 | def scales_page_to_fit(self): 316 | raise NotImplementedError( 317 | 'Not supported on iOS. Use the "disable_" methods instead.') 318 | 319 | @scales_page_to_fit.setter 320 | def scales_page_to_fit(self, value): 321 | raise NotImplementedError( 322 | 'Not supported on iOS. Use the "disable_" methods instead.') 323 | 324 | @property 325 | def swipe_navigation(self): 326 | return self.webview.allowsBackForwardNavigationGestures() 327 | 328 | @swipe_navigation.setter 329 | def swipe_navigation(self, value): 330 | self.webview.setAllowsBackForwardNavigationGestures_(value == True) 331 | 332 | @property 333 | def scroll_enabled(self): 334 | """ 335 | Controls whether scrolling is enabled. 336 | Disabling scrolling is applicable for pages that need to look like an 337 | app. 338 | """ 339 | return self.webview.scrollView().scrollEnabled() 340 | 341 | @scroll_enabled.setter 342 | def scroll_enabled(self, value): 343 | self.webview.scrollView().setScrollEnabled_(value == True) 344 | 345 | def update_safe_area_insets(self): 346 | insets = self.objc_instance.safeAreaInsets() 347 | self.frame = self.frame.inset( 348 | insets.top, insets.left, insets.bottom, insets.right) 349 | 350 | def _javascript_alert(self, host, message): 351 | console.alert(host, message, 'OK', hide_cancel_button=True) 352 | 353 | def _javascript_confirm(self, host, message): 354 | try: 355 | console.alert(host, message, 'OK') 356 | return True 357 | except KeyboardInterrupt: 358 | return False 359 | 360 | def _javascript_prompt(self, host, prompt, default_text): 361 | try: 362 | return console.input_alert(host, prompt, default_text, 'OK') 363 | except KeyboardInterrupt: 364 | return None 365 | 366 | js_logging_script = '''console = new Object(); 367 | console.info = function(message) { 368 | window.webkit.messageHandlers.javascript_console_message.postMessage( 369 | JSON.stringify({ level: "info", content: message}) 370 | ); return false; }; 371 | console.log = function(message) { 372 | window.webkit.messageHandlers.javascript_console_message.postMessage( 373 | JSON.stringify({ level: "log", content: message}) 374 | ); return false; }; 375 | console.warn = function(message) { 376 | window.webkit.messageHandlers.javascript_console_message.postMessage( 377 | JSON.stringify({ level: "warn", content: message}) 378 | ); return false; }; 379 | console.error = function(message) { 380 | window.webkit.messageHandlers.javascript_console_message.postMessage( 381 | JSON.stringify({ level: "error", content: message}) 382 | ); return false; }; 383 | window.onerror = (function(error, url, line, col, errorobj) { 384 | console.error( 385 | "" + error + " (" + url + ", line: " + line + ", column: " + col + ")" 386 | ); 387 | });''' 388 | 389 | def on_javascript_console_message(self, message): 390 | log_message = json.loads(message) 391 | #self.console.message(log_message) 392 | self._message(log_message) 393 | 394 | def _message(self, message): 395 | level, content = message['level'], message['content'] 396 | if level == 'code': 397 | print('>>> ' + content) 398 | elif level == 'raw': 399 | print(content) 400 | else: 401 | print(level.upper() + ': ' + content) 402 | 403 | class Theme: 404 | 405 | @classmethod 406 | def get_theme(cls): 407 | theme_dict = json.loads(cls.clean_json(cls.get_theme_data())) 408 | theme = SimpleNamespace(**theme_dict) 409 | theme.dict = theme_dict 410 | return theme 411 | 412 | @classmethod 413 | def get_theme_data(cls): 414 | # Name of current theme 415 | defaults = ObjCClass("NSUserDefaults").standardUserDefaults() 416 | name = str(defaults.objectForKey_("ThemeName")) 417 | # Theme is user-created 418 | if name.startswith("User:"): 419 | home = os.getenv("CFFIXED_USER_HOME") 420 | user_themes_path = os.path.join(home, 421 | "Library/Application Support/Themes") 422 | theme_path = os.path.join(user_themes_path, name[5:] + ".json") 423 | # Theme is built-in 424 | else: 425 | res_path = str(ObjCClass("NSBundle").mainBundle(). 426 | resourcePath()) 427 | theme_path = os.path.join(res_path, "Themes2/%s.json" % name) 428 | # Read theme file 429 | with open(theme_path, "r") as f: 430 | data = f.read() 431 | # Return contents 432 | return data 433 | 434 | @classmethod 435 | def clean_json(cls, string): 436 | # From http://stackoverflow.com/questions/23705304 437 | string = re.sub(",[ \t\r\n]+}", "}", string) 438 | string = re.sub(",[ \t\r\n]+\]", "]", string) 439 | return string 440 | 441 | @classmethod 442 | def console(self, webview_index=0): 443 | webview = WKWebView.webviews[webview_index] 444 | theme = WKWebView.Theme.get_theme() 445 | 446 | print('Welcome to WKWebView console.') 447 | print('Evaluate javascript in any active WKWebView.') 448 | print('Special commands: list, switch #, load , quit') 449 | console.set_color(*ui.parse_color(theme.tint)[:3]) 450 | while True: 451 | value = input('js> ').strip() 452 | self.console_view.history().insertObject_atIndex_(ns(value+'\n'),0) 453 | if value == 'quit': 454 | break 455 | if value == 'list': 456 | for i in range(len(WKWebView.webviews)): 457 | wv = WKWebView.webviews[i] 458 | print(i, '-', wv.name, '-', wv.eval_js('document.title')) 459 | elif value.startswith('switch '): 460 | i = int(value[len('switch '):]) 461 | webview = WKWebView.webviews[i] 462 | elif value.startswith('load '): 463 | url = value[len('load '):] 464 | webview.load_url(url) 465 | else: 466 | print(webview.eval_js(value)) 467 | console.set_color(*ui.parse_color(theme.default_text)[:3]) 468 | 469 | 470 | # MAIN OBJC SECTION 471 | 472 | WKWebView = ObjCClass('WKWebView') 473 | UIViewController = ObjCClass('UIViewController') 474 | WKWebViewConfiguration = ObjCClass('WKWebViewConfiguration') 475 | WKUserContentController = ObjCClass('WKUserContentController') 476 | NSURLRequest = ObjCClass('NSURLRequest') 477 | WKUserScript = ObjCClass('WKUserScript') 478 | WKWebsiteDataStore = ObjCClass('WKWebsiteDataStore') 479 | NSDate = ObjCClass('NSDate') 480 | 481 | # Navigation delegate 482 | 483 | class _block_decision_handler(Structure): 484 | _fields_ = _block_literal_fields(ctypes.c_long) 485 | 486 | def webView_decidePolicyForNavigationAction_decisionHandler_( 487 | _self, _cmd, _webview, _navigation_action, _decision_handler): 488 | delegate_instance = ObjCInstance(_self) 489 | webview = delegate_instance._pythonistawebview() 490 | deleg = webview.delegate 491 | nav_action = ObjCInstance(_navigation_action) 492 | ns_url = nav_action.request().URL() 493 | url = str(ns_url) 494 | nav_type = int(nav_action.navigationType()) 495 | 496 | allow = True 497 | if deleg is not None: 498 | if hasattr(deleg, 'webview_should_start_load'): 499 | allow = deleg.webview_should_start_load(webview, url, nav_type) 500 | 501 | scheme = str(ns_url.scheme()) 502 | if not WKWebView.WKWebView.handlesURLScheme_(scheme): 503 | allow = False 504 | webbrowser.open(url) 505 | 506 | allow_or_cancel = 1 if allow else 0 507 | decision_handler = ObjCInstance(_decision_handler) 508 | retain_global(decision_handler) 509 | blk = WKWebView._block_decision_handler.from_address(_decision_handler) 510 | blk.invoke(_decision_handler, allow_or_cancel) 511 | 512 | f = webView_decidePolicyForNavigationAction_decisionHandler_ 513 | f.argtypes = [c_void_p]*3 514 | f.restype = None 515 | f.encoding = b'v@:@@@?' 516 | # https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html 517 | 518 | def webView_didCommitNavigation_(_self, _cmd, _webview, _navigation): 519 | delegate_instance = ObjCInstance(_self) 520 | webview = delegate_instance._pythonistawebview() 521 | deleg = webview.delegate 522 | if deleg is not None: 523 | if hasattr(deleg, 'webview_did_start_load'): 524 | deleg.webview_did_start_load(webview) 525 | 526 | def webView_didFinishNavigation_(_self, _cmd, _webview, _navigation): 527 | delegate_instance = ObjCInstance(_self) 528 | webview = delegate_instance._pythonistawebview() 529 | deleg = webview.delegate 530 | if deleg is not None: 531 | if hasattr(deleg, 'webview_did_finish_load'): 532 | deleg.webview_did_finish_load(webview) 533 | 534 | def webView_didFailNavigation_withError_( 535 | _self, _cmd, _webview, _navigation, _error): 536 | 537 | delegate_instance = ObjCInstance(_self) 538 | webview = delegate_instance._pythonistawebview() 539 | deleg = webview.delegate 540 | err = ObjCInstance(_error) 541 | error_code = int(err.code()) 542 | error_msg = str(err.localizedDescription()) 543 | if deleg is not None: 544 | if hasattr(deleg, 'webview_did_fail_load'): 545 | deleg.webview_did_fail_load(webview, error_code, error_msg) 546 | return 547 | raise RuntimeError( 548 | f'WKWebView load failed with code {error_code}: {error_msg}') 549 | 550 | def webView_didFailProvisionalNavigation_withError_( 551 | _self, _cmd, _webview, _navigation, _error): 552 | WKWebView.webView_didFailNavigation_withError_( 553 | _self, _cmd, _webview, _navigation, _error) 554 | 555 | CustomNavigationDelegate = create_objc_class( 556 | 'CustomNavigationDelegate', superclass=NSObject, methods=[ 557 | webView_didCommitNavigation_, 558 | webView_didFinishNavigation_, 559 | webView_didFailNavigation_withError_, 560 | webView_didFailProvisionalNavigation_withError_, 561 | webView_decidePolicyForNavigationAction_decisionHandler_ 562 | ], 563 | protocols=['WKNavigationDelegate']) 564 | 565 | # Script message handler 566 | 567 | def userContentController_didReceiveScriptMessage_( 568 | _self, _cmd, _userContentController, _message): 569 | controller_instance = ObjCInstance(_self) 570 | webview = controller_instance._pythonistawebview() 571 | wk_message = ObjCInstance(_message) 572 | name = str(wk_message.name()) 573 | content = str(wk_message.body()) 574 | handler = getattr(webview, 'on_'+name, None) 575 | if handler: 576 | handler(content) 577 | else: 578 | raise Exception( 579 | f'Unhandled message from script - name: {name}, ' 580 | 'content: {content}') 581 | 582 | CustomMessageHandler = create_objc_class( 583 | 'CustomMessageHandler', UIViewController, methods=[ 584 | userContentController_didReceiveScriptMessage_ 585 | ], protocols=['WKScriptMessageHandler']) 586 | 587 | 588 | # UI delegate (for alerts etc.) 589 | 590 | class _block_alert_completion(Structure): 591 | _fields_ = _block_literal_fields() 592 | 593 | def webView_runJavaScriptAlertPanelWithMessage_initiatedByFrame_completionHandler_( 594 | _self, _cmd, _webview, _message, _frame, _completion_handler): 595 | delegate_instance = ObjCInstance(_self) 596 | webview = delegate_instance._pythonistawebview() 597 | message = str(ObjCInstance(_message)) 598 | host = str(ObjCInstance(_frame).request().URL().host()) 599 | webview._javascript_alert(host, message) 600 | #console.alert(host, message, 'OK', hide_cancel_button=True) 601 | completion_handler = ObjCInstance(_completion_handler) 602 | retain_global(completion_handler) 603 | blk = WKWebView._block_alert_completion.from_address( 604 | _completion_handler) 605 | blk.invoke(_completion_handler) 606 | 607 | f = webView_runJavaScriptAlertPanelWithMessage_initiatedByFrame_completionHandler_ 608 | f.argtypes = [c_void_p]*4 609 | f.restype = None 610 | f.encoding = b'v@:@@@@?' 611 | 612 | 613 | class _block_confirm_completion(Structure): 614 | _fields_ = _block_literal_fields(ctypes.c_bool) 615 | 616 | def webView_runJavaScriptConfirmPanelWithMessage_initiatedByFrame_completionHandler_( 617 | _self, _cmd, _webview, _message, _frame, _completion_handler): 618 | delegate_instance = ObjCInstance(_self) 619 | webview = delegate_instance._pythonistawebview() 620 | message = str(ObjCInstance(_message)) 621 | host = str(ObjCInstance(_frame).request().URL().host()) 622 | result = webview._javascript_confirm(host, message) 623 | completion_handler = ObjCInstance(_completion_handler) 624 | retain_global(completion_handler) 625 | blk = WKWebView._block_confirm_completion.from_address(_completion_handler) 626 | blk.invoke(_completion_handler, result) 627 | 628 | f = webView_runJavaScriptConfirmPanelWithMessage_initiatedByFrame_completionHandler_ 629 | f.argtypes = [c_void_p]*4 630 | f.restype = None 631 | f.encoding = b'v@:@@@@?' 632 | 633 | 634 | class _block_text_completion(Structure): 635 | _fields_ = _block_literal_fields(c_void_p) 636 | 637 | def webView_runJavaScriptTextInputPanelWithPrompt_defaultText_initiatedByFrame_completionHandler_( 638 | _self, _cmd, _webview, _prompt, _default_text, _frame, 639 | _completion_handler): 640 | delegate_instance = ObjCInstance(_self) 641 | webview = delegate_instance._pythonistawebview() 642 | prompt = str(ObjCInstance(_prompt)) 643 | default_text = str(ObjCInstance(_default_text)) 644 | host = str(ObjCInstance(_frame).request().URL().host()) 645 | result = webview._javascript_prompt(host, prompt, default_text) 646 | completion_handler = ObjCInstance(_completion_handler) 647 | retain_global(completion_handler) 648 | blk = WKWebView._block_text_completion.from_address( 649 | _completion_handler) 650 | blk.invoke(_completion_handler, ns(result)) 651 | 652 | f = webView_runJavaScriptTextInputPanelWithPrompt_defaultText_initiatedByFrame_completionHandler_ 653 | f.argtypes = [c_void_p]*5 654 | f.restype = None 655 | f.encoding = b'v@:@@@@@?' 656 | 657 | CustomUIDelegate = create_objc_class( 658 | 'CustomUIDelegate', superclass=NSObject, methods=[ 659 | webView_runJavaScriptAlertPanelWithMessage_initiatedByFrame_completionHandler_, 660 | webView_runJavaScriptConfirmPanelWithMessage_initiatedByFrame_completionHandler_, 661 | webView_runJavaScriptTextInputPanelWithPrompt_defaultText_initiatedByFrame_completionHandler_ 662 | ], 663 | protocols=['WKUIDelegate']) 664 | 665 | 666 | if __name__ == '__main__': 667 | 668 | class MyWebViewDelegate: 669 | 670 | def webview_should_start_load(self, webview, url, nav_type): 671 | """ 672 | See nav_type options at 673 | https://developer.apple.com/documentation/webkit/wknavigationtype?language=objc 674 | """ 675 | print('Will start loading', url) 676 | return True 677 | 678 | def webview_did_start_load(self, webview): 679 | print('Started loading') 680 | 681 | @ui.in_background 682 | def webview_did_finish_load(self, webview): 683 | print('Finished loading ' + 684 | str(webview.eval_js('document.title'))) 685 | 686 | 687 | class MyWebView(WKWebView): 688 | 689 | def on_greeting(self, message): 690 | console.alert(message, 'Message passed to Python', 'OK', 691 | hide_cancel_button=True) 692 | 693 | 694 | html = ''' 695 | 696 | 697 | WKWebView tests 698 | 707 | 708 | 709 |

710 | Hello world 711 |

712 |

713 | Pythonista home page 714 |

715 |

716 | +358 40 1234567 717 |

718 |

719 | http://omz-software.com/pythonista/ 720 |

721 | 722 | ''' 723 | 724 | r = ui.View(background_color='black') 725 | 726 | v = MyWebView( 727 | name='DemoWKWebView', 728 | delegate=MyWebViewDelegate(), 729 | swipe_navigation=True, 730 | data_detectors=(WKWebView.PHONE_NUMBER,WKWebView.LINK), 731 | frame=r.bounds, flex='WH') 732 | r.add_subview(v) 733 | 734 | r.present() # Use 'panel' if you want to view console in another tab 735 | 736 | #v.disable_all() 737 | #v.load_html(html) 738 | v.load_url('http://omz-software.com/pythonista/', 739 | no_cache=False, timeout=5) 740 | #v.load_url('file://some/local/file.html') 741 | v.clear_cache() 742 | --------------------------------------------------------------------------------