├── LICENCE.txt ├── README.md ├── arkit ├── art.scnassets │ └── dragon │ │ ├── dragon.scn │ │ ├── dragon_DIFFUSE.png │ │ ├── fire_PARTICLE.png │ │ └── main.scn ├── assets │ └── dragon │ │ ├── dragon.scn │ │ ├── dragon_DIFFUSE.png │ │ ├── fire_PARTICLE.png │ │ └── main.scn ├── main.py └── myDebugToolKit.py ├── rshell └── rshell.py ├── webvr ├── Gestures.py ├── main.py └── templates │ └── control.html └── webvr_embedded ├── main.py ├── web └── static │ ├── index.html │ ├── js │ ├── aframe-stereo-component.js │ ├── aframe-stereo-component.min.js │ ├── aframe.js │ ├── aframe.min.js │ └── debug_utils.js │ ├── stream │ └── MaryOculus.mp4 │ ├── video_debug.html │ └── vr_embedded.html └── wkwebview.py /LICENCE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pythonista 2 | Here you can find things about Pythonista (a complete development environment for writing Python™ scripts on your iPad or iPhone). 3 | 4 | ## webVR 5 | As it's currently difficult to watch web VR content in full screen on iOS devices, i created this tool. 6 | It has some hidden features ;o) 7 | 8 | ![Live session](https://cloud.githubusercontent.com/assets/10347315/26284635/88eb498a-3e40-11e7-8798-8961e92da0cd.gif) 9 | 10 | * Some gestures allow you to adjust the presentation (vertical offset and scaling). 11 | 12 | * A long press allows you to deactivate momentarily the previous gestures and gives access to the web view content through touch events. 13 | 14 | * You can change the current content using a remote web browser connected to your iOS device. 15 | 16 | * The tool detects sketchfab content and activates automatically its VR view. A same mechanism exists for A-frame content but it's more experimental. 17 | 18 | ## ARKit 19 | 20 | My first attempt to use the ARKit framework within iOS 12.1 (and Pythonista 3.3)... 21 | **!! Caution, slippery floor !!** 22 | **Work in progress** 23 | 24 | ![Live session](https://user-images.githubusercontent.com/10347315/50767430-1986fe00-127d-11e9-8a67-1aaaca3c8d54.gif) 25 | 26 | 27 | ## RShell 28 | 29 | If you want to access to your stash opened on your iOS device from your desktop, you can use this script. 30 | You have to download it on your bin stash directory and call it from the stash prompt simply by using rshell with -l as argument. 31 | 32 | ``` 33 | stash> rshell -l 34 | ``` 35 | 36 | Then, on your desktop, install this script and call it with the ip address of your iOS device as argument and let the magic happen... 37 | ``` 38 | $> rshell 192.168.0.40 39 | ``` 40 | 41 | Personally, on my desktop, I prefer to call it from my PyCharm IDE because its console is smarter than the standard terminal to use the backspace key for example... 42 | 43 | ![Live session](https://user-images.githubusercontent.com/10347315/28498512-80baefa0-6f9f-11e7-8bf3-b9519a5fe4c3.gif) 44 | -------------------------------------------------------------------------------- /arkit/art.scnassets/dragon/dragon.scn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brun0oO/Pythonista/e3ae57b7894dfc328f075f61fc51fbc8697cfaa3/arkit/art.scnassets/dragon/dragon.scn -------------------------------------------------------------------------------- /arkit/art.scnassets/dragon/dragon_DIFFUSE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brun0oO/Pythonista/e3ae57b7894dfc328f075f61fc51fbc8697cfaa3/arkit/art.scnassets/dragon/dragon_DIFFUSE.png -------------------------------------------------------------------------------- /arkit/art.scnassets/dragon/fire_PARTICLE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brun0oO/Pythonista/e3ae57b7894dfc328f075f61fc51fbc8697cfaa3/arkit/art.scnassets/dragon/fire_PARTICLE.png -------------------------------------------------------------------------------- /arkit/art.scnassets/dragon/main.scn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brun0oO/Pythonista/e3ae57b7894dfc328f075f61fc51fbc8697cfaa3/arkit/art.scnassets/dragon/main.scn -------------------------------------------------------------------------------- /arkit/assets/dragon/dragon.scn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brun0oO/Pythonista/e3ae57b7894dfc328f075f61fc51fbc8697cfaa3/arkit/assets/dragon/dragon.scn -------------------------------------------------------------------------------- /arkit/assets/dragon/dragon_DIFFUSE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brun0oO/Pythonista/e3ae57b7894dfc328f075f61fc51fbc8697cfaa3/arkit/assets/dragon/dragon_DIFFUSE.png -------------------------------------------------------------------------------- /arkit/assets/dragon/fire_PARTICLE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brun0oO/Pythonista/e3ae57b7894dfc328f075f61fc51fbc8697cfaa3/arkit/assets/dragon/fire_PARTICLE.png -------------------------------------------------------------------------------- /arkit/assets/dragon/main.scn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brun0oO/Pythonista/e3ae57b7894dfc328f075f61fc51fbc8697cfaa3/arkit/assets/dragon/main.scn -------------------------------------------------------------------------------- /arkit/main.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import objc_util 3 | 4 | from objc_util import * 5 | import ui 6 | import os 7 | import sys 8 | 9 | #from myDebugToolKit import * 10 | import time 11 | from enum import IntFlag 12 | 13 | 14 | load_framework('SceneKit') 15 | load_framework('ARKit') 16 | 17 | # Some 'constants' used by ARkit 18 | # But i can't transfer them to the ARKit framework, why ????? 19 | 20 | class ARWorldAlignment(IntFlag): 21 | ARWorldAlignmentGravity = 0 22 | ARWorldAlignmentGravityAndHeading = 1 23 | ARWorldAlignmentCamera = 2 24 | 25 | class ARPlaneDetection(IntFlag): 26 | ARPlaneDetectionNone = 0 27 | ARPlaneDetectionHorizontal = 1 << 0 28 | ARPlaneDetectionVertical = 1 << 1 29 | 30 | # Work In Progress here, I'm deciphering the arkit constants... 31 | #class ARSCNDebugOption(IntFlag): 32 | # ARSCNDebugOptionNone = 0 33 | # ARSCNDebugOptionShowWorldOrigin = int("ffffffff80000000", 16) 34 | # ARSCNDebugOptionShowFeaturePoints = int("ffffffff40000000", 16) 35 | 36 | class ARSessionRunOptions(IntFlag): 37 | ARSessionRunOptionsNone = 0 38 | ARSessionRunOptionResetTracking = 1 << 0 39 | ARSessionRunOptionRemoveExistingAnchors = 1 << 1 40 | 41 | 42 | NSError = ObjCClass('NSError') 43 | SCNScene = ObjCClass('SCNScene') 44 | ARSCNView = ObjCClass('ARSCNView') 45 | ARWorldTrackingConfiguration = ObjCClass('ARWorldTrackingConfiguration') 46 | ARSession = ObjCClass('ARSession') 47 | UIViewController = ObjCClass('UIViewController') 48 | ARPlaneAnchor = ObjCClass('ARPlaneAnchor') 49 | 50 | 51 | 52 | # I should refactor te following line in a class but I need to learn more the create_objcc_class function 53 | sceneview = None 54 | 55 | # Here some set up functions used by the main class 56 | def createSampleScene(): 57 | # an empty scene 58 | scene = SCNScene.scene() 59 | return scene 60 | 61 | def setDebugOptions(arscn): 62 | # Work In Progress Here, I'm trying to decipher the arkit constants... 63 | #val = ARSCNDebugOption.ARSCNDebugOptionShowWorldOrigin | ARSCNDebugOption.ARSCNDebugOptionShowFeaturePoints 64 | val = int("fffffffffc000000", 16) # this value is a combination of ShowWorldOrigin and ShowFeaturePoints flags, but I can't isolate each flags.... 65 | print('Before calling setDebugOptions_(%s) : debugOptions=%s' %(hex(val), hex(arscn.debugOptions()))) 66 | arscn.setDebugOptions_(val) 67 | print('After calling setDebugOptions_(%s) : debugOptions=%s' % (hex(val),hex(arscn.debugOptions()))) 68 | 69 | 70 | def createARSceneView(x, y, w, h, debug=True): 71 | v = ARSCNView.alloc().initWithFrame_((CGRect(CGPoint(x, y), CGSize(w, h)))) 72 | v.setShowsStatistics_(debug) # I love statistics... 73 | return v 74 | 75 | # Some callback definitions used by create_objc_class 76 | def CustomViewController_touchesBegan_withEvent_(_self, _cmd, _touches, event): 77 | touches = ObjCInstance(_touches) 78 | for t in touches: 79 | loc = t.locationInView_(sceneview) 80 | sz = ui.get_screen_size() 81 | print(loc) 82 | 83 | @on_main_thread 84 | def runARSession(arsession): 85 | arconfiguration = ARWorldTrackingConfiguration.alloc().init() 86 | arconfiguration.setPlaneDetection_ (ARPlaneDetection.ARPlaneDetectionHorizontal) 87 | arconfiguration.setWorldAlignment_(ARWorldAlignment.ARWorldAlignmentGravity) # I do not use ARWorldAlignmentGravityAndHeading anymore because on my device, sometimes it fails to initialize the ar session because of an unitialized sensor (error 102). I think my magnetic phone casing plays tricks on me... 88 | 89 | arsession.runWithConfiguration_options_(arconfiguration, ARSessionRunOptions.ARSessionRunOptionResetTracking | ARSessionRunOptions.ARSessionRunOptionRemoveExistingAnchors ) 90 | 91 | time.sleep(0.5) # Let the system breathe ;o) Ok, that's the workarround I found to retrieve the ar session configuration (otherwise I got None).... 92 | print('configuration',arsession.configuration()) # Very usefull for the debuging (at least for me !) 93 | 94 | 95 | def CustomViewController_viewWillAppear_(_self, _cmd, animated): 96 | return 97 | 98 | def CustomViewController_viewWillDisappear_(_self, _cmd, animated): 99 | session = sceneview.session() 100 | session.pause() 101 | 102 | def MyARSCNViewDelegate_renderer_didAdd_for_(_self, _cmd, scenerenderer, node, anchor): 103 | if not isinstance(anchor, (ARPlaneAnchor)): 104 | return 105 | # to be implemented... 106 | 107 | 108 | def MyARSCNViewDelegate_session_didFailWithError_(_self,_cmd,_session,_error): 109 | print('error',_error,_cmd,_session) 110 | err_obj=ObjCInstance(_error) 111 | print(err_obj) # Again, very usefull for the debuging... 112 | 113 | # The main class... 114 | class MyARView(ui.View): 115 | def __init__(self): 116 | super().__init__(self) 117 | 118 | 119 | @on_main_thread 120 | def initialize(self): 121 | global sceneview 122 | self.flex = 'WH' 123 | 124 | screen = ui.get_screen_size() 125 | 126 | # set up the scene 127 | scene = createSampleScene() 128 | 129 | # set up the ar scene view delegate 130 | methods = [MyARSCNViewDelegate_renderer_didAdd_for_,MyARSCNViewDelegate_session_didFailWithError_] 131 | protocols = ['ARSCNViewDelegate'] 132 | MyARSCNViewDelegate = create_objc_class('MyARSCNViewDelegate', NSObject, methods=methods, protocols=protocols) 133 | delegate = MyARSCNViewDelegate.alloc().init() 134 | 135 | # set up the ar scene view 136 | sceneview = createARSceneView(0, 0, screen.width, screen.height) 137 | sceneview.scene = scene 138 | sceneview.setDelegate_(delegate) 139 | 140 | 141 | # set up the custom view controller 142 | methods = [CustomViewController_touchesBegan_withEvent_, CustomViewController_viewWillAppear_, CustomViewController_viewWillDisappear_] 143 | protocols = [] 144 | CustomViewController = create_objc_class('CustomViewController', UIViewController, methods=methods, protocols=protocols) 145 | cvc = CustomViewController.alloc().init() 146 | cvc.view = sceneview 147 | 148 | 149 | # internal kitchen 150 | self_objc = ObjCInstance(self) 151 | self_objc.nextResponder().addChildViewController_(cvc) 152 | self_objc.addSubview_(sceneview) 153 | cvc.didMoveToParentViewController_(self_objc) 154 | 155 | # here, we try... 156 | runARSession(sceneview.session()) # I call here this function because I'm trying to find the best place to run the ar session... 157 | 158 | setDebugOptions(sceneview) # I call here this function because I'm trying to find the best place to set the debuging options.... 159 | 160 | def will_close(self): 161 | session = sceneview.session() 162 | session.pause() 163 | 164 | 165 | 166 | if __name__ == '__main__': 167 | v = MyARView() 168 | v.present('full_screen', hide_title_bar=True, orientations=['portrait']) 169 | v.initialize() 170 | -------------------------------------------------------------------------------- /arkit/myDebugToolKit.py: -------------------------------------------------------------------------------- 1 | # My debug toolkit... 2 | # It allows me to copy some usefull informations to the clipboard. 3 | # Then, I use the free ios app ´CloudClip' to share these informations with my desktop. 4 | from objc_util import * 5 | from objc_util import class_copyMethodList, method_getName, sel_getName, free,ObjCInstanceMethodProxy, objc_getClass 6 | 7 | import clipboard 8 | import inspect 9 | from datetime import datetime 10 | import sound 11 | from inspect import signature 12 | 13 | def info(obj, obj_name, private=False): 14 | if obj_name in globals(): 15 | beep() 16 | callerframerecord = inspect.stack()[1] 17 | frame = callerframerecord[0] 18 | info = inspect.getframeinfo(frame) 19 | filename = info.filename 20 | filename = filename[filename.find('Documents'):] 21 | function = info.function 22 | lineno = info.lineno 23 | content = '' 24 | if inspect.ismodule(obj) or inspect.isclass(obj): 25 | methods = dir(obj) 26 | for method in methods: 27 | to_be_added = True 28 | if not private: 29 | to_be_added = not(method.startswith('__') and method.endswith('__')) 30 | if to_be_added: 31 | if content != '': 32 | content += '\n' 33 | content += '#\t'+ method 34 | content = '# List of its public method(s) :\n'+content 35 | elif inspect.ismethod(obj) or inspect.isfunction(obj): 36 | content = str(signature(obj)) 37 | elif isinstance(obj, (ObjCClass)) or isinstance(obj, (ObjCInstance)): 38 | methods = inspectObjc(obj) 39 | for method in methods: 40 | if content != '': 41 | content += '\n' 42 | content += '#\t'+method 43 | content = '# List of its method(s) :\n'+content 44 | timestamp = str(datetime.now()) 45 | text = "# timestamp = %s\n" % timestamp 46 | text += "# filename = %s\n" % filename 47 | text += "# function = %s\n" % function 48 | text += "# line number = %d\n" % lineno 49 | text += "# Inspected object = '%s'\n" % obj_name 50 | text += "# Type(object) = %s\n" % type(obj) 51 | text += content 52 | clipboard.set(text) 53 | 54 | def inspectObjc(obj): 55 | if isinstance(obj, (ObjCClass)): 56 | name = obj.class_name.decode('utf-8') 57 | elif isinstance(obj, (ObjCInstance)): 58 | name = obj._get_objc_classname().decode('utf-8') 59 | elif isinstance(obj, (str)): 60 | name = obj 61 | else: 62 | # not supported ! 63 | return [] 64 | 65 | py_methods = [] 66 | c = ObjCClass(name) 67 | num_methods = c_uint(0) 68 | method_list_ptr = class_copyMethodList(c.ptr, byref(num_methods)) 69 | for i in range(num_methods.value): 70 | selector = method_getName(method_list_ptr[i]) 71 | sel_name = sel_getName(selector) 72 | if not isinstance(sel_name, str): 73 | sel_name = sel_name.decode('ascii') 74 | py_method_name = sel_name.replace(':','_') 75 | if '.' not in py_method_name: 76 | py_methods.append(py_method_name) 77 | 78 | free(method_list_ptr) 79 | return py_methods 80 | 81 | def beep(): 82 | sound.play_effect('Ding_3', volume=1.0) 83 | -------------------------------------------------------------------------------- /rshell/rshell.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # coding: utf-8 3 | 4 | # RSHELL is a remote interactive access to Stash (a bash like shell for Pythonista). 5 | # It's based on the Guido Wesdorp's work for its remote interactive shell (ripshell). 6 | 7 | from __future__ import print_function 8 | import socket 9 | import sys 10 | import traceback 11 | import thread 12 | import time 13 | 14 | 15 | 16 | class config: 17 | server_ip = '0.0.0.0' 18 | port = 10101 19 | 20 | __version__ = '1.0' 21 | 22 | # get the local ip address of the current device 23 | def get_local_ip_addr(): 24 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # make a socket object 25 | s.connect(('8.8.8.8', 80)) # connect to google ;o) 26 | ip = s.getsockname()[0] # get our IP address from the socket 27 | s.close() # close the socket 28 | return ip # and return the IP address 29 | 30 | 31 | class STDFilePointers: 32 | def __init__(self, conn): 33 | self.conn = conn 34 | 35 | def write(self, s): 36 | self.conn.send(s) 37 | 38 | def read(self, l): 39 | r = self.conn.recv(l) 40 | if r: 41 | return r 42 | return ' ' 43 | 44 | def readlines(self): 45 | data = [] 46 | while 1: 47 | c = self.read(1) 48 | if c == '\n': 49 | line = ''.join(data) 50 | # well..here a little hack in order to intercept a quit command from the client.... 51 | if line == 'quit': 52 | raise SystemExit('quit') 53 | line += '\n' 54 | return line 55 | data.append(c) 56 | 57 | # 58 | # The server 59 | # 60 | # Launch Pythonista on your iOS device 61 | # Execute launch_stash.py 62 | # Type rshell.py 63 | # Look at the printed ip address on the console ;o) 64 | # 65 | class RSHELLServer: 66 | banner = ( 'RShell Server v%s\n' 67 | 'Type "help", "version" for more information.\n\n' 68 | '**To stop the server: quit\n' 69 | % ( __version__) 70 | ) 71 | 72 | # open a socket and start waiting for connections 73 | def __init__(self, config): 74 | self.config = config 75 | self.sock = s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 76 | try: 77 | s.bind((config.server_ip, config.port)) 78 | s.listen(1) 79 | while 1: 80 | conn, addr = s.accept() 81 | print('Connection from', addr) 82 | self.handle(conn, addr) 83 | conn.close() 84 | print('Connection closed') 85 | finally: 86 | print('Closing') 87 | s.close() 88 | 89 | # handle a new connection 90 | def handle(self, conn, addr): 91 | backup_stdin = sys.stdin 92 | backup_stdout = sys.stdout 93 | backup_stderr = sys.stderr 94 | stdfps = STDFilePointers(conn) 95 | sys.stdin = stdfps 96 | sys.stdout = stdfps 97 | sys.stderr = stdfps 98 | try: 99 | try: 100 | command = conn.recv(1) 101 | # dispatch depending on command (first char sent should be '-' 102 | # for the interactive interpreter loop, 'x' for executing code) 103 | if command == '-': 104 | self.interpreterloop(conn, addr) 105 | else: 106 | print('Unexpected input, exiting...') 107 | except SystemExit as e: 108 | # raise a SystemExit with message 'quit' to stop the server 109 | # from a client 110 | if str(e) == 'quit': 111 | print('Stopping server') # this string will be intercepted by the client... 112 | raise # kill the server 113 | print('SystemExit') 114 | except: 115 | exc, e, tb = sys.exc_info() 116 | try: 117 | print('%s - %s' % (exc, e)) 118 | print('\n'.join(traceback.format_tb(tb))) 119 | except: 120 | pass 121 | del tb 122 | print('Exception:', exc, '-', e, file=sys.__stdout__) 123 | finally: 124 | sys.stdin = backup_stdin 125 | sys.stdout = backup_stdout #sys.__stdout__ 126 | sys.stderr = backup_stderr #sys.__stderr__ 127 | 128 | # interpreter loop 129 | def interpreterloop(self, conn, addr): 130 | _stash = globals()['_stash'] 131 | _, current_state = _stash.runtime.get_current_worker_and_state() 132 | print(self.banner) 133 | while (1): 134 | _stash(sys.stdin.readlines(), persistent_level=1) 135 | time.sleep(0.5) 136 | 137 | # 138 | # The Client 139 | # 140 | # On your desktop, execute rshell.py ###.###.###.### (with the ip address of your server) 141 | # Then, you can type stash commands on the console and wait for their completion on your iOS device. 142 | # Their output is automatically displayed on your console. 143 | # Your desktop and your iOS device need to be on the same local network. 144 | # 145 | class RSHELLClient: 146 | # connect to the server and start the session 147 | def __init__(self, server_ip, config): 148 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 149 | 150 | try: 151 | s.settimeout(10.0) # 10 second timeout for connect 152 | try: 153 | s.connect((server_ip, config.port)) 154 | except socket.error as msg: 155 | print("Couldn't connect with the socket-server: %s" % msg) 156 | sys.exit(1) 157 | s.settimeout(None) 158 | 159 | self.interpreterloop(s) 160 | except SystemExit as e: 161 | if str(e) == 'quit': 162 | print('Stopping client') 163 | raise # kill the client 164 | print('SystemExit') 165 | finally: 166 | s.close() 167 | 168 | def interpreterloop(self, sock): 169 | sock.send(b'-') # tell the server we want to get a prompt 170 | thread.start_new_thread(self.readloop, (sock,)) 171 | self.writeloop(sock) 172 | 173 | def readloop(self, sock): 174 | while 1: 175 | try: 176 | sock.send(sys.stdin.read(1)) 177 | except socket.error: 178 | return 179 | 180 | def writeloop(self, sock): 181 | while 1: 182 | c = sock.recv(1) 183 | if not c: 184 | break 185 | 186 | # try to decode ANSI color sequences 187 | # need to use a buffer and apply a string replace before display the string 188 | end_main_loop = False 189 | buffer = "" 190 | while c != '\n': 191 | buffer += c 192 | c = sock.recv(1) 193 | if not c: 194 | end_main_loop = True 195 | break 196 | if not end_main_loop: 197 | # here an other hack in order to intercept the end of the server 198 | if buffer == "Stopping server": # sent from the server 199 | raise SystemExit('quit') # kill the client 200 | buffer += '\n' 201 | 202 | 203 | buffer = buffer.replace('\xc2\x9b', '\033[') 204 | 205 | sys.stdout.write(buffer) 206 | sys.stdout.flush() 207 | 208 | if end_main_loop: 209 | break 210 | 211 | if __name__ == '__main__': 212 | from optparse import OptionParser 213 | usage = ('usage: %prog [-p] -l | remote_server\n' 214 | '\n' 215 | 'RSHELL allows you to access STASH running on a remote\n' 216 | 'iOS device over an unencrypted socket. Use RSHELL in listening mode\n' 217 | '(use -l) or to connect to a RSHELL server') 218 | parser = OptionParser(usage) 219 | parser.add_option('-l', '--listen', action='store_true', dest='listen', default=False, 220 | help='Listen for a remote connection' ) 221 | parser.add_option('-p', '--port', action='store', type='int', dest='port', default=config.port, 222 | help='port for rshell to use') 223 | (options, args) = parser.parse_args() 224 | if options.port <= 0 or options.port >= 65535: 225 | parser.error('Invalid port specified') 226 | if options.listen: 227 | if len(args) > 0: 228 | parser.error("-l cannot use be specified with a hostname") 229 | local_ip = get_local_ip_addr() 230 | print('RShell server listening on %s:%d' % (local_ip, options.port)) 231 | config.port = options.port 232 | # blocks until done 233 | RSHELLServer(config) 234 | 235 | elif len(args) == 1: 236 | config.server_ip = args[0] 237 | config.port = options.port 238 | # blocks until done 239 | RSHELLClient(config.server_ip, config) 240 | else: 241 | parser.error('Invalid arguments specified.') 242 | -------------------------------------------------------------------------------- /webvr/Gestures.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | # Gestures for Pythonista 4 | # Copied from https://github.com/mikaelho/pythonista-gestures 5 | # As its author said, this is a convenience class for enabling gestures in Pythonista ui applications, 6 | # including built-in views. Main intent here has been to make them Python friendly, 7 | # hiding all the Objective-C stuff. 8 | 9 | import ui 10 | from objc_util import * 11 | import uuid 12 | 13 | # https://developer.apple.com/library/prerelease/ios/documentation/UIKit/Reference/UIGestureRecognizer_Class/index.html#//apple_ref/occ/cl/UIGestureRecognizer 14 | 15 | class Gestures(): 16 | 17 | TAP = b'UITapGestureRecognizer' 18 | PINCH = b'UIPinchGestureRecognizer' 19 | ROTATION = b'UIRotationGestureRecognizer' 20 | SWIPE = b'UISwipeGestureRecognizer' 21 | PAN = b'UIPanGestureRecognizer' 22 | SCREEN_EDGE_PAN = b'UIScreenEdgePanGestureRecognizer' 23 | LONG_PRESS = b'UILongPressGestureRecognizer' 24 | 25 | POSSIBLE = 0 26 | BEGAN = 1 27 | RECOGNIZED = 1 28 | CHANGED = 2 29 | ENDED = 3 30 | CANCELLED = 4 31 | FAILED = 5 32 | 33 | RIGHT = 1 34 | LEFT = 2 35 | UP = 4 36 | DOWN = 8 37 | 38 | EDGE_NONE = 0 39 | EDGE_TOP = 1 40 | EDGE_LEFT = 2 41 | EDGE_BOTTOM = 4 42 | EDGE_RIGHT = 8 43 | EDGE_ALL = 15 44 | 45 | def __init__(self, retain_global_reference = True): 46 | self.buttons = {} 47 | self.views = {} 48 | self.recognizers = {} 49 | self.actions = {} 50 | if retain_global_reference: 51 | retain_global(self) 52 | 53 | # Friendly delegate functions 54 | 55 | def recognize_simultaneously_default(gr_name, other_gr_name): 56 | return False 57 | self.recognize_simultaneously = recognize_simultaneously_default 58 | 59 | def fail_default(gr_name, other_gr_name): 60 | return False 61 | self.fail = fail_default 62 | 63 | def fail_other_default(gr_name, other_gr_name): 64 | return False 65 | self.fail_other = fail_other_default 66 | 67 | # ObjC delegate functions 68 | 69 | def simplify(func, gr, other_gr): 70 | gr_o = ObjCInstance(gr) 71 | other_gr_o = ObjCInstance(other_gr) 72 | if (gr_o.view() != other_gr_o.view()): 73 | return False 74 | gr_name = gr_o._get_objc_classname() 75 | other_gr_name = other_gr_o._get_objc_classname() 76 | return func(gr_name, other_gr_name) 77 | 78 | # Recognize simultaneously 79 | 80 | def gestureRecognizer_shouldRecognizeSimultaneouslyWithGestureRecognizer_(_self, _sel, gr, other_gr): 81 | return self.objc_should_recognize_simultaneously(self.recognize_simultaneously, gr, other_gr) 82 | 83 | def objc_should_recognize_simultaneously_default(func, gr, other_gr): 84 | return simplify(func, gr, other_gr) 85 | 86 | self.objc_should_recognize_simultaneously = objc_should_recognize_simultaneously_default 87 | 88 | # Fail other 89 | 90 | def gestureRecognizer_shouldRequireFailureOfGestureRecognizer_(_self, _sel, gr, other_gr): 91 | return self.objc_should_require_failure(self.fail_other, gr, other_gr) 92 | 93 | def objc_should_require_failure_default(func, gr, other_gr): 94 | return simplify(func, gr, other_gr) 95 | 96 | self.objc_should_require_failure = objc_should_require_failure_default 97 | 98 | # Fail 99 | 100 | def gestureRecognizer_shouldBeRequiredToFailByGestureRecognizer_(_self, _sel, gr, other_gr): 101 | return self.objc_should_fail(self.fail, gr, other_gr) 102 | 103 | def objc_should_fail_default(func, gr, other_gr): 104 | return simplify(func, gr, other_gr) 105 | 106 | self.objc_should_fail = objc_should_fail_default 107 | 108 | # Delegate 109 | 110 | try: 111 | PythonGestureDelegate = ObjCClass('PythonGestureDelegate') 112 | except: 113 | PythonGestureDelegate = create_objc_class('PythonistaGestureDelegate', 114 | superclass=NSObject, 115 | methods=[ 116 | gestureRecognizer_shouldRecognizeSimultaneouslyWithGestureRecognizer_, 117 | gestureRecognizer_shouldRequireFailureOfGestureRecognizer_, 118 | gestureRecognizer_shouldBeRequiredToFailByGestureRecognizer_], 119 | classmethods=[], 120 | protocols=['UIGestureRecognizerDelegate'], 121 | debug=True) 122 | self._delegate = PythonGestureDelegate.new() 123 | 124 | @on_main_thread 125 | def add_tap(self, view, action, number_of_taps_required = None, number_of_touches_required = None): 126 | recog = self._get_recog('UITapGestureRecognizer', view, self._general_action, action) 127 | 128 | if number_of_taps_required: 129 | recog.numberOfTapsRequired = number_of_taps_required 130 | if number_of_touches_required: 131 | recog.numberOfTouchesRequired = number_of_touches_required 132 | 133 | return recog 134 | 135 | @on_main_thread 136 | def add_long_press(self, view, action, number_of_taps_required = None, number_of_touches_required = None, minimum_press_duration = None, allowable_movement = None): 137 | recog = self._get_recog('UILongPressGestureRecognizer', view, self._general_action, action) 138 | 139 | if number_of_taps_required: 140 | recog.numberOfTapsRequired = number_of_taps_required 141 | if number_of_touches_required: 142 | recog.numberOfTouchesRequired = number_of_touches_required 143 | if minimum_press_duration: 144 | recog.minimumPressDuration = minimum_press_duration 145 | if allowable_movement: 146 | recog.allowableMovement = allowable_movement 147 | 148 | return recog 149 | 150 | @on_main_thread 151 | def add_pan(self, view, action, minimum_number_of_touches = None, maximum_number_of_touches = None, set_translation = None): 152 | recog = self._get_recog('UIPanGestureRecognizer', view, self._pan_action, action) 153 | 154 | if minimum_number_of_touches: 155 | recog.minimumNumberOfTouches = minimum_number_of_touches 156 | if maximum_number_of_touches: 157 | recog.maximumNumberOfTouches = maximum_number_of_touches 158 | if set_translation: 159 | recog.set_translation_(CGPoint(set_translation.x, set_translation.y), ObjCInstance(view)) 160 | 161 | return recog 162 | 163 | @on_main_thread 164 | def add_screen_edge_pan(self, view, action, edges = None): 165 | recog = self._get_recog('UIScreenEdgePanGestureRecognizer', view, self._pan_action, action) 166 | 167 | if edges: 168 | recog.edges = edges 169 | 170 | return recog 171 | 172 | @on_main_thread 173 | def add_pinch(self, view, action): 174 | recog = self._get_recog('UIPinchGestureRecognizer', view, self._pinch_action, action) 175 | 176 | return recog 177 | 178 | @on_main_thread 179 | def add_rotation(self, view, action): 180 | recog = self._get_recog('UIRotationGestureRecognizer', view, self._rotation_action, action) 181 | 182 | return recog 183 | 184 | @on_main_thread 185 | def add_swipe(self, view, action, direction = None, number_of_touches_required = None): 186 | recog = self._get_recog('UISwipeGestureRecognizer', view, self._general_action, action) 187 | 188 | if direction: 189 | combined_dir = direction 190 | if isinstance(direction, list): 191 | combined_dir = 0 192 | for one_direction in direction: 193 | combined_dir |= one_direction 194 | recog.direction = combined_dir 195 | if number_of_touches_required: 196 | recog.numberOfTouchesRequired = number_of_touches_required 197 | 198 | return recog 199 | 200 | @on_main_thread 201 | def remove(self, view, recognizer): 202 | key = None 203 | for id in self.recognizers: 204 | if self.recognizers[id] == recognizer: 205 | key = id 206 | break 207 | if key: 208 | del self.buttons[key] 209 | del self.views[key] 210 | del self.recognizers[key] 211 | del self.actions[key] 212 | ObjCInstance(view).removeGestureRecognizer_(recognizer) 213 | 214 | @on_main_thread 215 | def enable(self, recognizer): 216 | ObjCInstance(recognizer).enabled = True 217 | 218 | @on_main_thread 219 | def disable(self, recognizer): 220 | ObjCInstance(recognizer).enabled = False 221 | 222 | @on_main_thread 223 | def remove_all_gestures(self, view): 224 | gestures = ObjCInstance(view).gestureRecognizers() 225 | for recog in gestures: 226 | self.remove(view, recog) 227 | 228 | def _get_recog(self, recog_name, view, internal_action, final_handler): 229 | button = ui.Button() 230 | key = str(uuid.uuid4()) 231 | button.name = key 232 | button.action = internal_action 233 | self.buttons[key] = button 234 | self.views[key] = view 235 | recognizer = ObjCClass(recog_name).alloc().initWithTarget_action_(button, sel('invokeAction:')).autorelease() 236 | self.recognizers[key] = recognizer 237 | self.actions[key] = final_handler 238 | ObjCInstance(view).addGestureRecognizer_(recognizer) 239 | recognizer.delegate = self._delegate 240 | return recognizer 241 | 242 | class Data(): 243 | def __init__(self): 244 | self.recognizer = self.view = self.location = self.state = self.number_of_touches = self.scale = self.rotation = self.velocity = None 245 | 246 | def _context(self, button): 247 | key = button.name 248 | (view, recog, action) = (self.views[key], self.recognizers[key], self.actions[key]) 249 | data = Gestures.Data() 250 | data.recognizer = recog 251 | data.view = view 252 | data.location = self._location(view, recog) 253 | data.state = recog.state() 254 | data.number_of_touches = recog.numberOfTouches() 255 | return (data, action) 256 | 257 | def _location(self, view, recog): 258 | loc = recog.locationInView_(ObjCInstance(view)) 259 | return ui.Point(loc.x, loc.y) 260 | 261 | def _general_action(self, sender): 262 | (data, action) = self._context(sender) 263 | action(data) 264 | 265 | def _pan_action(self, sender): 266 | (data, action) = self._context(sender) 267 | 268 | trans = data.recognizer.translationInView_(ObjCInstance(data.view)) 269 | vel = data.recognizer.velocityInView_(ObjCInstance(data.view)) 270 | data.translation = ui.Point(trans.x, trans.y) 271 | data.velocity = ui.Point(vel.x, vel.y) 272 | 273 | action(data) 274 | 275 | def _pinch_action(self, sender): 276 | (data, action) = self._context(sender) 277 | 278 | data.scale = data.recognizer.scale() 279 | data.velocity = data.recognizer.velocity() 280 | 281 | action(data) 282 | 283 | def _rotation_action(self, sender): 284 | (data, action) = self._context(sender) 285 | 286 | data.rotation = data.recognizer.rotation() 287 | data.velocity = data.recognizer.velocity() 288 | 289 | action(data) 290 | 291 | # TESTING AND DEMONSTRATION 292 | 293 | if __name__ == "__main__": 294 | 295 | class EventDisplay(ui.View): 296 | def __init__(self): 297 | self.tv = ui.TextView(flex='WH') 298 | self.add_subview(self.tv) 299 | self.tv.frame = (0, 0, self.width, self.height) 300 | 301 | g = Gestures() 302 | 303 | g.recognize_simultaneously = lambda gr, other_gr: gr == Gestures.PAN and other_gr == Gestures.PINCH 304 | 305 | g.fail_other = lambda gr, other_gr: other_gr == Gestures.PINCH 306 | 307 | g.add_tap(self, self.general_handler) 308 | 309 | g.add_long_press(self.tv, self.long_press_handler) 310 | 311 | pan = g.add_pan(self, self.pan_handler) 312 | 313 | #g.add_screen_edge_pan(self.tv, self.pan_handler, edges = Gestures.EDGE_LEFT) 314 | 315 | #g.add_swipe(self.tv, self.general_handler, direction = [Gestures.DOWN]) 316 | 317 | g.add_pinch(self, self.pinch_handler) 318 | 319 | #g.add_rotation(self.tv, self.rotation_handler) 320 | 321 | def t(self, msg): 322 | self.tv.text = self.tv.text + msg + '\n' 323 | 324 | def general_handler(self, data): 325 | self.t('General: ' + str(data.location) + ' - state: ' + str(data.state) + ' - touches: ' + str(data.number_of_touches)) 326 | 327 | def long_press_handler(self, data): 328 | if data.state == Gestures.ENDED: 329 | self.t('Long press: ' + str(data.location) + ' - state: ' + str(data.state) + ' - touches: ' + str(data.number_of_touches)) 330 | 331 | def pan_handler(self, data): 332 | self.t('Pan: ' + str(data.translation) + ' - state: ' + str(data.state)) 333 | 334 | def pinch_handler(self, data): 335 | self.t('Pinch: ' + str(data.scale) + ' state: ' + str(data.state)) 336 | 337 | def rotation_handler(self, data): 338 | self.t('Rotation: ' + str(data.rotation)) 339 | 340 | view = EventDisplay() 341 | view.present() 342 | 343 | -------------------------------------------------------------------------------- /webvr/main.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | # This tool allows to open webvr content in true fullscreen mode on iOS devices using Pythonista. 4 | # Two vr contents are available : 5 | # - the first one comes from sketchfab and displays a 3D room. 6 | # - the second one comes from https://github.com/ryanbetts/dayframe . 7 | # It uses the web framework 'AFrame' for building vr experiences. The "more one thing" is the emulation of a daydream controller. 8 | # (when you choose this demo, try to use an other phone with a web browser opened on https://dayframe-demo.herokuapp.com/remote 9 | # but as this is a public demo and as the author mentioned it : only one person at a time can control the remote. If you join, you will disconnect the previously connected remote) 10 | # Three secret features are also available : 11 | # - you can trigger an url page loading on your device using a remote web browser connected to this device 12 | # - you can adjust the vertical offset and the scale of the web rendering using gestures (find them !). All adjustments are stored so if an url is reloaded, there are applyed again automatically... 13 | # - if you need to interact with the web view, make a long press until you feel a vibration, then you have 10 seconds to manipulate it before the top view takes the control again. You will feel a new vibration, and the top view will catch again the touch events (vertical offset and scale gestures) 14 | 15 | import ui, console, time, motion 16 | import threading, queue 17 | from contextlib import closing 18 | 19 | from datetime import datetime 20 | import re, urllib.request, socket 21 | 22 | from flask import Flask, request, render_template 23 | 24 | from objc_util import * 25 | import ctypes 26 | c=ctypes.CDLL(None) 27 | 28 | def vibrate(): 29 | p = c.AudioServicesPlaySystemSound 30 | p.restype, p.argtypes = None, [ctypes.c_int32] 31 | vibrate_id=0x00000fff 32 | p(vibrate_id) 33 | 34 | import requests 35 | from threading import Timer 36 | import httplib2 37 | from urllib.parse import urlparse 38 | 39 | from Gestures import Gestures 40 | import math 41 | import json, os 42 | REGISTRY_PATH='./.data/registry.txt' 43 | 44 | theDemoURLs = ["https://sketchfab.com/models/311d052a9f034ba8bce55a1a8296b6f9/embed?autostart=1&cardboard=1","https://dayframe-demo.herokuapp.com/scene"] 45 | theHttpPort = 8080 46 | theThread = None 47 | theSharing = {} 48 | theLock = threading.RLock() 49 | theApp = Flask(__name__) 50 | 51 | 52 | @theApp.route("/", methods=['GET', 'POST']) 53 | def index(): 54 | if request.method == 'GET': 55 | return render_template('control.html') 56 | else: 57 | theLock.acquire() 58 | try: 59 | q = theSharing['queue'] 60 | obj = q.get() 61 | obj.next_url = request.form['command'] 62 | q.task_done() 63 | finally: 64 | theLock.release() 65 | return render_template('control.html') 66 | 67 | 68 | LAST_REQUEST_MS = 0 69 | @theApp.before_request 70 | def update_last_request_ms(): 71 | global LAST_REQUEST_MS 72 | LAST_REQUEST_MS = time.time() * 1000 73 | 74 | @theApp.route('/seriouslykill', methods=['POST']) 75 | def seriouslykill(): 76 | func = request.environ.get('werkzeug.server.shutdown') 77 | if func is None: 78 | raise RuntimeError('Not running with the Werkzeug Server') 79 | func() 80 | return "Shutting down..." 81 | 82 | @theApp.route('/kill', methods=['POST']) 83 | def kill(): 84 | last_ms = LAST_REQUEST_MS 85 | def shutdown(): 86 | if LAST_REQUEST_MS <= last_ms: # subsequent requests abort shutdown 87 | requests.post('http://localhost:%d/seriouslykill' % theHttpPort) 88 | else: 89 | pass 90 | 91 | Timer(1.0, shutdown).start() # wait 1 second 92 | return "Shutting down..." 93 | 94 | def get_local_ip_addr(): # Get the local ip address of the device 95 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # Make a socket object 96 | s.connect(('8.8.8.8', 80)) # Connect to google 97 | ip = s.getsockname()[0] # Get our IP address from the socket 98 | s.close() # Close the socket 99 | return ip # And return the IP address 100 | 101 | def check_if_url_is_valid(value): 102 | h = httplib2.Http() 103 | value = unshorten_url(value) 104 | resp = h.request(value, 'HEAD') 105 | return int(resp[0]['status']) < 400 106 | 107 | def unshorten_url(url): 108 | r = requests.head(url, allow_redirects=True) 109 | return r.url 110 | 111 | 112 | 113 | # thread worker 114 | class workerThread(threading.Thread): 115 | def __init__(self): 116 | threading.Thread.__init__(self) 117 | self.daemon = True 118 | 119 | def run(self): 120 | NSNetService = ObjCClass('NSNetService') # Bonjour publication 121 | service = NSNetService.alloc().initWithDomain_type_name_port_('', '_http._tcp', 'iOS webVR Viewer', theHttpPort) 122 | try: 123 | service.publish() 124 | theApp.run(host='0.0.0.0', port=theHttpPort) 125 | finally: 126 | service.stop() 127 | service.release() 128 | 129 | def stop(self): 130 | requests.post('http://localhost:%d/kill' % theHttpPort) 131 | 132 | 133 | 134 | # As it's important to hold the phone in landscape mode before creating the view, 135 | # a dedicated function has been created... 136 | def waitForLandscapeMode(): 137 | msg = 'Please, hold your phone in landscape mode' 138 | console.hud_alert(msg, duration = 3) 139 | motion.start_updates() 140 | try: 141 | count=0 142 | while True: 143 | x, y, z = motion.get_gravity() 144 | count+=1 145 | if count>2: 146 | if abs(x) > abs(y): 147 | break 148 | else: 149 | console.hud_alert(msg, duration = 2) 150 | time.sleep(0.5) 151 | finally: 152 | motion.stop_updates() 153 | time.sleep(1) 154 | 155 | # the main class 156 | class MyWebVRView(ui.View): 157 | def __init__(self, url): 158 | self.width, self.height = ui.get_window_size() 159 | self.background_color= 'black' 160 | # the webview 161 | self.wv = ui.WebView(frame=self.bounds) 162 | self.wv.background_color= 'black' 163 | self.finished = False 164 | self.current_url = None 165 | self.next_url = "" 166 | self.start_workerThread() 167 | self.add_subview(self.wv) 168 | # a top view for catching gesture events 169 | self.gv = ui.View(frame=self.bounds) 170 | self.gv.alpha = 0.05 171 | self.gv.background_color = 'white' 172 | self.add_subview(self.gv) 173 | self.gv.bring_to_front() 174 | # some variables for setting the layout 175 | self.ty=-27 176 | self.sx=1 177 | self.applyVerticalOffset() 178 | self.applyScale() 179 | # view adjustment per url are saved here 180 | self.registry={} 181 | self.readRegistry() 182 | # gesture setup 183 | g = Gestures() 184 | g.add_pan(self.gv, self.pan_handler,1,1) 185 | g.add_pinch(self.gv, self.pinch_handler) 186 | g.add_long_press(self.gv, self.long_press_handler) 187 | # launch the layout 188 | self.present("full_screen", hide_title_bar=True, orientations=['landscape']) 189 | # load the url 190 | self.loadURL(url) 191 | 192 | def get_pan_x_limits(self): 193 | range = self.width*0.1 194 | x_min= (self.width-range)*0.5 195 | x_max = (self.width+range)*0.5 196 | return x_min, x_max 197 | 198 | def pan_handler(self, data): 199 | # get the safe area for this gesture 200 | x_min, x_max = self.get_pan_x_limits() 201 | x = data.location.x 202 | if (x >= x_min) and (x <= x_max): 203 | self.ty += int(data.velocity.y*1.0/50) 204 | self.applyVerticalOffset() 205 | self.saveInfoToRegistry(self.current_url, self.ty, self.sx) 206 | 207 | 208 | def pinch_handler(self, data): 209 | self.sx += data.velocity/50 210 | self.applyScale() 211 | self.saveInfoToRegistry(self.current_url, self.ty, self.sx) 212 | 213 | 214 | def long_press_handler(self, data): 215 | # get the safe area for this gesture 216 | x_min, x_max = self.get_pan_x_limits() 217 | x = data.location.x 218 | if (x <= x_min) or (x >= x_max): 219 | if data.state==Gestures.BEGAN: 220 | # a little feedback... 221 | vibrate() 222 | # ...and we disable the top view using the alpha parameter of this view 223 | self.gv.alpha = 0 224 | ui.delay(self.restoreAlpha, 10) 225 | 226 | 227 | def restoreAlpha(self): 228 | # restore the alpha parameter of the top view. Notice, it's a small small value ;) 229 | self.gv.alpha=0.05 230 | vibrate() 231 | 232 | # small persistent storage mechanism 233 | def writeRegistry(self): 234 | with open(REGISTRY_PATH, 'w') as outfile: 235 | json.dump(self.registry, outfile, indent=2, sort_keys=True) 236 | 237 | 238 | def readRegistry(self): 239 | directory = os.path.dirname(REGISTRY_PATH) 240 | if not os.path.exists(directory): 241 | os.makedirs(directory) 242 | return 243 | if os.path.exists(REGISTRY_PATH): 244 | with open(REGISTRY_PATH) as json_file: 245 | self.registry = json.load(json_file) 246 | 247 | 248 | def readInfoFromRegistry(self, url): 249 | key = self.buildKeyFromURL(url) 250 | if key in self.registry: 251 | return self.registry[key] 252 | return (self.ty, self.sx) 253 | 254 | def saveInfoToRegistry(self, url, pos, scale): 255 | key = self.buildKeyFromURL(url) 256 | self.registry[key] = (pos,scale) 257 | self.writeRegistry() 258 | 259 | # create a key from an url 260 | def buildKeyFromURL(self, url): 261 | pos = url.find("://") 262 | tokens = url[pos+3:].split('/') 263 | return tokens[0] 264 | 265 | # a little function to set the web view layout 266 | def applyVerticalOffset(self): 267 | self.wv.y = self.ty 268 | 269 | def applyScale(self): 270 | self.wv.transform = ui.Transform().scale(self.sx, self.sx) 271 | 272 | # here we observe the exit 273 | def will_close(self): 274 | self.finished = True 275 | 276 | # some thread management used to 277 | # react to the remote change 278 | # of the current url 279 | def start_workerThread(self): 280 | global theThread 281 | global theSharing 282 | with theLock: 283 | theSharing['queue'] = queue.Queue(1) 284 | theThread = workerThread() 285 | theThread.start() 286 | 287 | 288 | def stop_workerThread(self): 289 | if theThread is None: 290 | return 291 | theThread.stop() 292 | while theThread and theThread.isAlive(): 293 | with theLock: 294 | q = theSharing['queue'] 295 | if q.empty(): 296 | q.put(self) 297 | time.sleep(1.0/60) 298 | 299 | # this method allows to maintain a communication with the worker thread using a queue 300 | def update(self): 301 | url = "" 302 | with theLock: 303 | q = theSharing['queue'] 304 | if q.empty(): 305 | q.put(self) 306 | url = self.next_url 307 | if url != "": 308 | self.loadURL(url) 309 | 310 | def run(self): 311 | while not self.finished: 312 | self.update() 313 | time.sleep(1.0/60) 314 | self.stop_workerThread() 315 | 316 | 317 | 318 | def loadURL(self, url): 319 | if url=="": # force reload/refresh the current url 320 | url=self.current_url 321 | self.current_url=None 322 | 323 | url = self.patch_SKETCHFAB_page(url) 324 | if check_if_url_is_valid(url): 325 | if self.current_url is None or (self.current_url != url): 326 | print("loading %s" % url) 327 | self.current_url = url 328 | self.wv.load_url(self.current_url) 329 | self.patch_AFRAME_page() 330 | self.ty, self.sx = self.readInfoFromRegistry(url) 331 | self.applyVerticalOffset() 332 | self.applyScale() 333 | 334 | # The following function returns the given url but in case of a sketchfab url, it adds the auto cardboard view parameter at the end of string... 335 | def patch_SKETCHFAB_page(self, url): 336 | result = url.lower() 337 | if result.startswith("https://sketchfab.com/models/"): 338 | if not result.endswith("/embed?autostart=1&cardboard=1"): 339 | result += "/embed?autostart=1&cardboard=1" 340 | return result 341 | 342 | 343 | 344 | # An other trick in case of a aframe url, it will inject a custom javascript code in order to force the enterVR trigger... 345 | # but sometimes, the following hack seems to be wrong... 346 | # The screen stays in desktop mode, you have to restart the demo or click on the cardboard icon. 347 | # Perhaps, my delay is too short or something goes wrong with the browser cache... 348 | 349 | def patch_AFRAME_page(self): 350 | js_code = """ 351 | function customEnterVR () { 352 | var scene = document.getElementById('scene'); 353 | if (scene) { 354 | if (scene.hasLoaded) { 355 | scene.enterVR(); 356 | } else { 357 | scene.addEventListener('loaded', scene.enterVR); 358 | } 359 | } 360 | } 361 | customEnterVR(); 362 | """ 363 | searchITEM = "scene" 364 | searchID = self.wv.evaluate_javascript('document.getElementById("%s").id' % searchITEM) 365 | searchCount = 0 366 | while not searchID == "%s" % searchITEM: 367 | time.sleep(1) # wait for 1 second before searching again 368 | searchID = self.wv.evaluate_javascript('document.getElementById("%s").id' % searchITEM) 369 | searchCount += 1 370 | if searchCount>2: # max two attempts... 371 | break 372 | if searchID == searchITEM: 373 | res=self.wv.eval_js(js_code) 374 | 375 | if __name__ == '__main__': 376 | # disable the ios screensaver 377 | console.set_idle_timer_disabled(True) 378 | 379 | # ask the user for the first url loading. 380 | # he has the choice between a sketchfab or an a-frame scene 381 | demoID = console.alert('Select a demo','(%s:%d)'% (get_local_ip_addr(), theHttpPort),'sketchfab','a-frame') 382 | url = theDemoURLs[demoID-1] 383 | 384 | # it's very important to hold the phone in landscape mode before the ui.view creation so ... 385 | waitForLandscapeMode() 386 | 387 | # fasten your seatbelts, start the engine and let's get doing!... 388 | MyWebVRView(url).run() 389 | 390 | # restore the ios screensaver 391 | console.set_idle_timer_disabled(False) 392 | -------------------------------------------------------------------------------- /webvr/templates/control.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | iOS webVR Viewer Panel 7 | 34 | 37 | 38 | 39 |
40 |
41 |
42 |
43 | 44 |
45 |
46 |
47 |
48 | 49 | -------------------------------------------------------------------------------- /webvr_embedded/main.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import ui, console, time, motion 4 | import threading, queue 5 | from contextlib import closing 6 | 7 | from datetime import datetime 8 | import re, urllib.request, socket 9 | import urllib 10 | 11 | import os 12 | 13 | from flask import Flask, request, Response, abort 14 | from flask import send_from_directory 15 | 16 | import mimetypes 17 | 18 | 19 | import wkwebview 20 | 21 | 22 | static_file_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'web/static') 23 | 24 | 25 | 26 | from objc_util import * 27 | 28 | 29 | import requests 30 | from threading import Timer 31 | import httplib2 32 | from urllib.parse import urlparse 33 | 34 | import math 35 | import json, os 36 | 37 | theHttpPort = 8080 38 | theThread = None 39 | theApp = Flask(__name__) 40 | 41 | MB = 1 << 20 42 | BUFF_SIZE = 10 * MB 43 | 44 | # setting routes 45 | @theApp.route("/", methods=['GET']) 46 | def serve_home(): 47 | return send_from_directory(static_file_dir, 'index.html') 48 | 49 | @theApp.route('/stream/', methods=['GET']) 50 | def stream_file_in_dir(path): 51 | fullpath = os.path.join(static_file_dir, "stream",path) 52 | if not os.path.isfile(fullpath): abort(404) 53 | start, end = get_range(request) 54 | return partial_response(fullpath, start, end) 55 | 56 | @theApp.route('/', methods=['GET']) 57 | def serve_file_in_dir(path): 58 | if not os.path.isfile(os.path.join(static_file_dir, path)): abort(404) 59 | return send_from_directory(static_file_dir, path) 60 | 61 | LAST_REQUEST_MS = 0 62 | @theApp.before_request 63 | def update_last_request_ms(): 64 | global LAST_REQUEST_MS 65 | LAST_REQUEST_MS = time.time() * 1000 66 | 67 | @theApp.route('/seriouslykill', methods=['POST']) 68 | def seriouslykill(): 69 | func = request.environ.get('werkzeug.server.shutdown') 70 | if func is None: 71 | raise RuntimeError('Not running with the Werkzeug Server') 72 | func() 73 | return "Shutting down..." 74 | 75 | @theApp.route('/kill', methods=['POST']) 76 | def kill(): 77 | last_ms = LAST_REQUEST_MS 78 | def shutdown(): 79 | if LAST_REQUEST_MS <= last_ms: # subsequent requests abort shutdown 80 | requests.post('http://localhost:%d/seriouslykill' % theHttpPort) 81 | else: 82 | pass 83 | 84 | Timer(1.0, shutdown).start() # wait 1 second 85 | return "Shutting down..." 86 | 87 | # streaming implementation... 88 | def partial_response(path, start, end=None): 89 | file_size = os.path.getsize(path) 90 | 91 | # Determine (end, length) 92 | if end is None: 93 | end = start + BUFF_SIZE - 1 94 | end = min(end, file_size - 1) 95 | end = min(end, start + BUFF_SIZE - 1) 96 | length = end - start + 1 97 | 98 | # Read file 99 | with open(path, 'rb') as fd: 100 | fd.seek(start) 101 | bytes = fd.read(length) 102 | assert(len(bytes) == length) 103 | 104 | response = Response( 105 | bytes, 106 | 206, 107 | mimetype=mimetypes.guess_type(path)[0], 108 | direct_passthrough=True, 109 | ) 110 | response.headers.add( 111 | 'Content-Range', 'bytes {0}-{1}/{2}'.format( 112 | start, end, file_size, 113 | ), 114 | ) 115 | response.headers.add( 116 | 'Accept-Ranges', 'bytes' 117 | ) 118 | response.headers.add( 119 | 'Access-Control-Allow-Origin', '*' 120 | ) 121 | response.headers.add( 122 | 'Vary', 'Accept-Encoding' 123 | ) 124 | return response 125 | 126 | 127 | def get_range(request): 128 | range = request.headers.get('Range') 129 | m = re.match('bytes=(?P\d+)-(?P\d+)?', range) 130 | if m: 131 | start = m.group('start') 132 | end = m.group('end') 133 | start = int(start) 134 | if end is not None: 135 | end = int(end) 136 | return start, end 137 | else: 138 | return 0, None 139 | 140 | 141 | # thread worker 142 | class workerThread(threading.Thread): 143 | def __init__(self): 144 | threading.Thread.__init__(self) 145 | self.daemon = True 146 | 147 | def run(self): 148 | NSNetService = ObjCClass('NSNetService') # Bonjour publication 149 | service = NSNetService.alloc().initWithDomain_type_name_port_('', '_http._tcp', 'iOS webVR Viewer', theHttpPort) 150 | try: 151 | service.publish() 152 | theApp.run(host='0.0.0.0', port=theHttpPort) 153 | finally: 154 | service.stop() 155 | service.release() 156 | 157 | def stop(self): 158 | requests.post('http://localhost:%d/kill' % theHttpPort) 159 | 160 | 161 | 162 | # webview delegate ... 163 | class MyWebViewDelegate (object): 164 | def __init__(self, webview): 165 | self.wv = webview 166 | def webview_should_start_load(self, webview, url, nav_type): 167 | if url.startswith('ios-log'): 168 | txt = urllib.parse.unquote(url) 169 | # hiding some messages 170 | if 'Invalid timestamps detected.' in txt: 171 | pass 172 | else: 173 | print(txt) 174 | return True 175 | def webview_did_start_load(self, webview): 176 | pass 177 | 178 | def webview_did_finish_load(self, webview): 179 | print("webview_did_finish_load") 180 | 181 | def webview_did_fail_load(self, webview, error_code, error_msg): 182 | pass 183 | 184 | # the main class 185 | class MyWebVRView(ui.View): 186 | def __init__(self, url): 187 | self.finished = False 188 | self.start_workerThread() 189 | self.width, self.height = ui.get_window_size() 190 | self.background_color= 'black' 191 | # the webview 192 | self.wv = wkwebview.WKWebView(frame=self.bounds, flex='WH') 193 | self.wv.delegate = MyWebViewDelegate(self.wv) 194 | 195 | self.wv.background_color= 'black' 196 | self.add_subview(self.wv) 197 | 198 | bi_back = ui.ButtonItem(image=ui.Image.named('iob:ios7_arrow_back_32'), action=self.goBack) 199 | bi_forward = ui.ButtonItem(image=ui.Image.named('iob:ios7_arrow_forward_32'), action=self.goForward) 200 | self.right_button_items = [bi_forward, bi_back] 201 | 202 | self.clearCache() 203 | self.loadURL(url) 204 | 205 | # launch the layout 206 | self.present("full_screen", hide_title_bar=False) 207 | 208 | 209 | def goBack(self, bi): 210 | self.wv.go_back() 211 | 212 | def goForward(self, bi): 213 | self.wv.go_forward() 214 | 215 | # here we observe the exit 216 | def will_close(self): 217 | self.finished = True 218 | 219 | # some thread management used to 220 | # react to the remote change 221 | # of the current url 222 | def start_workerThread(self): 223 | global theThread 224 | theThread = workerThread() 225 | theThread.start() 226 | 227 | 228 | def stop_workerThread(self): 229 | if theThread is None: 230 | return 231 | theThread.stop() 232 | 233 | # main loop... 234 | def run(self): 235 | while not self.finished: 236 | time.sleep(1.0/60) 237 | self.stop_workerThread() 238 | 239 | 240 | def clearCache(self): 241 | js_code = """window.location.reload(true);""" 242 | res=self.wv.eval_js(js_code) 243 | 244 | def loadURL(self, url): 245 | self.wv.load_url(url, no_cache=True) 246 | 247 | 248 | 249 | 250 | if __name__ == '__main__': 251 | # disable the ios screensaver 252 | console.set_idle_timer_disabled(True) 253 | 254 | #access to localhost 255 | url = "http://localhost:%d/" % theHttpPort 256 | 257 | # fasten your seatbelts, start the engine and let's get doing!... 258 | MyWebVRView(url).run() 259 | 260 | # restore the ios screensaver 261 | console.set_idle_timer_disabled(False) 262 | -------------------------------------------------------------------------------- /webvr_embedded/web/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | My embedded VR 5 | 23 | 24 | 25 |

Debug

26 | Show Stereoscopic video (embedded) 27 | Show Stereoscopic video (linked) 28 | Test video playing 29 | 30 | -------------------------------------------------------------------------------- /webvr_embedded/web/static/js/aframe-stereo-component.js: -------------------------------------------------------------------------------- 1 | /******/ (function(modules) { // webpackBootstrap 2 | /******/ // The module cache 3 | /******/ var installedModules = {}; 4 | 5 | /******/ // The require function 6 | /******/ function __webpack_require__(moduleId) { 7 | 8 | /******/ // Check if module is in cache 9 | /******/ if(installedModules[moduleId]) 10 | /******/ return installedModules[moduleId].exports; 11 | 12 | /******/ // Create a new module (and put it into the cache) 13 | /******/ var module = installedModules[moduleId] = { 14 | /******/ exports: {}, 15 | /******/ id: moduleId, 16 | /******/ loaded: false 17 | /******/ }; 18 | 19 | /******/ // Execute the module function 20 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 21 | 22 | /******/ // Flag the module as loaded 23 | /******/ module.loaded = true; 24 | 25 | /******/ // Return the exports of the module 26 | /******/ return module.exports; 27 | /******/ } 28 | 29 | 30 | /******/ // expose the modules object (__webpack_modules__) 31 | /******/ __webpack_require__.m = modules; 32 | 33 | /******/ // expose the module cache 34 | /******/ __webpack_require__.c = installedModules; 35 | 36 | /******/ // __webpack_public_path__ 37 | /******/ __webpack_require__.p = ""; 38 | 39 | /******/ // Load entry module and return exports 40 | /******/ return __webpack_require__(0); 41 | /******/ }) 42 | /************************************************************************/ 43 | /******/ ([ 44 | /* 0 */ 45 | /***/ (function(module, exports, __webpack_require__) { 46 | 47 | // Browser distrubution of the A-Frame component. 48 | (function () { 49 | if (!AFRAME) { 50 | console.error('Component attempted to register before AFRAME was available.'); 51 | return; 52 | } 53 | 54 | // Register all components here. 55 | var components = { 56 | stereo: __webpack_require__(1).stereo_component, 57 | stereocam: __webpack_require__(1).stereocam_component 58 | }; 59 | 60 | Object.keys(components).forEach(function (name) { 61 | if (AFRAME.aframeCore) { 62 | AFRAME.aframeCore.registerComponent(name, components[name]); 63 | } else { 64 | AFRAME.registerComponent(name, components[name]); 65 | } 66 | }); 67 | })(); 68 | 69 | 70 | 71 | /***/ }), 72 | /* 1 */ 73 | /***/ (function(module, exports) { 74 | 75 | module.exports = { 76 | 77 | // Put an object into left, right or both eyes. 78 | // If it's a video sphere, take care of correct stereo mapping for both eyes (if full dome) 79 | // or half the sphere (if half dome) 80 | 81 | 'stereo_component' : { 82 | schema: { 83 | eye: { type: 'string', default: "left"}, 84 | mode: { type: 'string', default: "full"}, 85 | split: { type: 'string', default: "horizontal"}, 86 | playOnClick: { type: 'boolean', default: true }, 87 | }, 88 | init: function(){ 89 | 90 | // Flag to acknowledge if 'click' on video has been attached to canvas 91 | // Keep in mind that canvas is the last thing initialized on a scene so have to wait for the event 92 | // or just check in every tick if is not undefined 93 | 94 | this.video_click_event_added = false; 95 | 96 | this.material_is_a_video = false; 97 | 98 | // Check if material is a video from html tag (object3D.material.map instanceof THREE.VideoTexture does not 99 | // always work 100 | 101 | if(this.el.getAttribute("material")!==null && 'src' in this.el.getAttribute("material") && this.el.getAttribute("material").src !== "") { 102 | var src = this.el.getAttribute("material").src; 103 | 104 | // If src is an object and its tagName is video... 105 | 106 | if (typeof src === 'object' && ('tagName' in src && src.tagName === "VIDEO")) { 107 | this.material_is_a_video = true; 108 | } 109 | } 110 | 111 | var object3D = this.el.object3D.children[0]; 112 | 113 | // In A-Frame 0.2.0, objects are all groups so sphere is the first children 114 | // Check if it's a sphere w/ video material, and if so 115 | // Note that in A-Frame 0.2.0, sphere entities are THREE.SphereBufferGeometry, while in A-Frame 0.3.0, 116 | // sphere entities are THREE.BufferGeometry. 117 | 118 | var validGeometries = [THREE.SphereGeometry, THREE.SphereBufferGeometry, THREE.BufferGeometry]; 119 | var isValidGeometry = validGeometries.some(function(geometry) { 120 | return object3D.geometry instanceof geometry; 121 | }); 122 | 123 | if (isValidGeometry && this.material_is_a_video) { 124 | 125 | // if half-dome mode, rebuild geometry (with default 100, radius, 64 width segments and 64 height segments) 126 | 127 | if (this.data.mode === "half") { 128 | 129 | var geo_def = this.el.getAttribute("geometry"); 130 | var geometry = new THREE.SphereGeometry(geo_def.radius || 100, geo_def.segmentsWidth || 64, geo_def.segmentsHeight || 64, Math.PI / 2, Math.PI, 0, Math.PI); 131 | 132 | } 133 | else { 134 | var geo_def = this.el.getAttribute("geometry"); 135 | var geometry = new THREE.SphereGeometry(geo_def.radius || 100, geo_def.segmentsWidth || 64, geo_def.segmentsHeight || 64); 136 | } 137 | 138 | // Panorama in front 139 | 140 | object3D.rotation.y = Math.PI / 2; 141 | 142 | // If left eye is set, and the split is horizontal, take the left half of the video texture. If the split 143 | // is set to vertical, take the top/upper half of the video texture. 144 | 145 | if (this.data.eye === "left") { 146 | var uvs = geometry.faceVertexUvs[ 0 ]; 147 | var axis = this.data.split === "vertical" ? "y" : "x"; 148 | for (var i = 0; i < uvs.length; i++) { 149 | for (var j = 0; j < 3; j++) { 150 | if (axis == "x") { 151 | uvs[ i ][ j ][ axis ] *= 0.5; 152 | } 153 | else { 154 | uvs[ i ][ j ][ axis ] *= 0.5; 155 | uvs[ i ][ j ][ axis ] += 0.5; 156 | } 157 | } 158 | } 159 | } 160 | 161 | // If right eye is set, and the split is horizontal, take the right half of the video texture. If the split 162 | // is set to vertical, take the bottom/lower half of the video texture. 163 | 164 | if (this.data.eye === "right") { 165 | var uvs = geometry.faceVertexUvs[ 0 ]; 166 | var axis = this.data.split === "vertical" ? "y" : "x"; 167 | for (var i = 0; i < uvs.length; i++) { 168 | for (var j = 0; j < 3; j++) { 169 | if (axis == "x") { 170 | uvs[ i ][ j ][ axis ] *= 0.5; 171 | uvs[ i ][ j ][ axis ] += 0.5; 172 | } 173 | else { 174 | uvs[ i ][ j ][ axis ] *= 0.5; 175 | } 176 | } 177 | } 178 | } 179 | 180 | // As AFrame 0.2.0 builds bufferspheres from sphere entities, transform 181 | // into buffergeometry for coherence 182 | 183 | object3D.geometry = new THREE.BufferGeometry().fromGeometry(geometry); 184 | 185 | } 186 | else{ 187 | 188 | // No need to attach video click if not a sphere and not a video, set this to true 189 | 190 | this.video_click_event_added = true; 191 | 192 | } 193 | 194 | 195 | }, 196 | 197 | // On element update, put in the right layer, 0:both, 1:left, 2:right (spheres or not) 198 | 199 | update: function(oldData){ 200 | 201 | var object3D = this.el.object3D.children[0]; 202 | var data = this.data; 203 | 204 | if(data.eye === "both"){ 205 | object3D.layers.set(0); 206 | } 207 | else{ 208 | object3D.layers.set(data.eye === 'left' ? 1:2); 209 | } 210 | 211 | }, 212 | 213 | tick: function(time){ 214 | 215 | // If this value is false, it means that (a) this is a video on a sphere [see init method] 216 | // and (b) of course, tick is not added 217 | 218 | if(!this.video_click_event_added && this.data.playOnClick){ 219 | if(typeof(this.el.sceneEl.canvas) !== 'undefined'){ 220 | 221 | // Get video DOM 222 | 223 | this.videoEl = this.el.object3D.children[0].material.map.image; 224 | 225 | // On canvas click, play video element. Use self to not lose track of object into event handler 226 | 227 | var self = this; 228 | 229 | this.el.sceneEl.canvas.onclick = function () { 230 | var playPromise = self.videoEl.play(); 231 | if (playPromise !== undefined) { 232 | playPromise.then(function() { 233 | // Automatic playback started! 234 | }).catch(function(error) { 235 | // Automatic playback failed. 236 | // Show a UI element to let the user manually start playback. 237 | console.log(error); 238 | }); 239 | } 240 | }; 241 | 242 | // Signal that click event is added 243 | this.video_click_event_added = true; 244 | 245 | } 246 | } 247 | 248 | } 249 | }, 250 | 251 | // Sets the 'default' eye viewed by camera in non-VR mode 252 | 253 | 'stereocam_component':{ 254 | 255 | schema: { 256 | eye: { type: 'string', default: "left"} 257 | }, 258 | 259 | // Cam is not attached on init, so use a flag to do this once at 'tick' 260 | 261 | // Use update every tick if flagged as 'not changed yet' 262 | 263 | init: function(){ 264 | // Flag to register if cam layer has already changed 265 | this.layer_changed = false; 266 | }, 267 | 268 | tick: function(time){ 269 | 270 | var originalData = this.data; 271 | 272 | // If layer never changed 273 | 274 | if(!this.layer_changed){ 275 | 276 | // because stereocam component should be attached to an a-camera element 277 | // need to get down to the root PerspectiveCamera before addressing layers 278 | 279 | // Gather the children of this a-camera and identify types 280 | 281 | var childrenTypes = []; 282 | 283 | this.el.object3D.children.forEach( function (item, index, array) { 284 | childrenTypes[index] = item.type; 285 | }); 286 | 287 | // Retrieve the PerspectiveCamera 288 | var rootIndex = childrenTypes.indexOf("PerspectiveCamera"); 289 | var rootCam = this.el.object3D.children[rootIndex]; 290 | 291 | if(originalData.eye === "both"){ 292 | rootCam.layers.enable( 1 ); 293 | rootCam.layers.enable( 2 ); 294 | } 295 | else{ 296 | rootCam.layers.enable(originalData.eye === 'left' ? 1:2); 297 | } 298 | } 299 | } 300 | 301 | } 302 | }; 303 | 304 | 305 | /***/ }) 306 | /******/ ]); -------------------------------------------------------------------------------- /webvr_embedded/web/static/js/aframe-stereo-component.min.js: -------------------------------------------------------------------------------- 1 | !function(e){function t(a){if(i[a])return i[a].exports;var r=i[a]={exports:{},id:a,loaded:!1};return e[a].call(r.exports,r,r.exports,t),r.loaded=!0,r.exports}var i={};return t.m=e,t.c=i,t.p="",t(0)}([function(e,t,i){!function(){if(!AFRAME)return void console.error("Component attempted to register before AFRAME was available.");var e={stereo:i(1).stereo_component,stereocam:i(1).stereocam_component};Object.keys(e).forEach(function(t){AFRAME.aframeCore?AFRAME.aframeCore.registerComponent(t,e[t]):AFRAME.registerComponent(t,e[t])})}()},function(e,t){e.exports={stereo_component:{schema:{eye:{type:"string",default:"left"},mode:{type:"string",default:"full"},split:{type:"string",default:"horizontal"},playOnClick:{type:"boolean",default:!0}},init:function(){if(this.video_click_event_added=!1,this.material_is_a_video=!1,null!==this.el.getAttribute("material")&&"src"in this.el.getAttribute("material")&&""!==this.el.getAttribute("material").src){var e=this.el.getAttribute("material").src;"object"==typeof e&&"tagName"in e&&"VIDEO"===e.tagName&&(this.material_is_a_video=!0)}var t=this.el.object3D.children[0],i=[THREE.SphereGeometry,THREE.SphereBufferGeometry,THREE.BufferGeometry],a=i.some(function(e){return t.geometry instanceof e});if(a&&this.material_is_a_video){if("half"===this.data.mode)var r=this.el.getAttribute("geometry"),o=new THREE.SphereGeometry(r.radius||100,r.segmentsWidth||64,r.segmentsHeight||64,Math.PI/2,Math.PI,0,Math.PI);else var r=this.el.getAttribute("geometry"),o=new THREE.SphereGeometry(r.radius||100,r.segmentsWidth||64,r.segmentsHeight||64);if(t.rotation.y=Math.PI/2,"left"===this.data.eye)for(var n=o.faceVertexUvs[0],s="vertical"===this.data.split?"y":"x",l=0;l 2 | 3 | My A-Frame Scene 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /webvr_embedded/web/static/vr_embedded.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | My embedded 360 Video scene 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 28 | 29 | 30 | 31 | 32 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /webvr_embedded/wkwebview.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | ''' 3 | # WKWebView - modern webview for Pythonista 4 | ''' 5 | 6 | from objc_util import * 7 | import ui, console, webbrowser 8 | import queue, weakref, ctypes, functools, time, os, json, re 9 | from types import SimpleNamespace 10 | 11 | 12 | # Helpers for invoking ObjC function blocks with no return value 13 | 14 | class _block_descriptor (Structure): 15 | _fields_ = [('reserved', c_ulong), ('size', c_ulong), ('copy_helper', c_void_p), ('dispose_helper', c_void_p), ('signature', c_char_p)] 16 | 17 | def _block_literal_fields(*arg_types): 18 | return [('isa', c_void_p), ('flags', c_int), ('reserved', c_int), ('invoke', ctypes.CFUNCTYPE(c_void_p, c_void_p, *arg_types)), ('descriptor', _block_descriptor)] 19 | 20 | 21 | class WKWebView(ui.View): 22 | 23 | # Data detector constants 24 | NONE = 0 25 | PHONE_NUMBER = 1 26 | LINK = 1 << 1 27 | ADDRESS = 1 << 2 28 | CALENDAR_EVENT = 1 << 3 29 | TRACKING_NUMBER = 1 << 4 30 | FLIGHT_NUMBER = 1 << 5 31 | LOOKUP_SUGGESTION = 1 << 6 32 | ALL = 18446744073709551615 # NSUIntegerMax 33 | 34 | # Global webview index for console 35 | webviews = [] 36 | console_view = UIApplication.sharedApplication().\ 37 | keyWindow().rootViewController().\ 38 | accessoryViewController().\ 39 | consoleViewController() 40 | 41 | #>Brun0oO 42 | def __init__(self, swipe_navigation=False, allowsInlineMediaPlayback=True, data_detectors=NONE, log_js_evals=False, respect_safe_areas=False, **kwargs): 43 | #Brun0oO 67 | webview_config.allowsInlineMediaPlayback = allowsInlineMediaPlayback 68 | webview_config.preferences().setValue_forKey_(True, "developerExtrasEnabled") 69 | #webview_config.allowsLinkPreview=True 70 | #webview_config.allowsPictureInPictureMediaPlayback=True 71 | #webview_config.mediaTypesRequiringUserActionForPlayback=False 72 | #bruno 321 | level = message['level'] 322 | content = '' 323 | if 'content' in message.keys(): 324 | content = message['content'] 325 | if type(content) is dict: 326 | content = ','.join("{!s}={!r}".format(key,val) for (key,val) in content.items()) 327 | #>> ' + content) 330 | elif level == 'raw': 331 | print(content) 332 | else: 333 | print(level.upper() + ': ' + content) 334 | 335 | class Theme: 336 | 337 | @classmethod 338 | def get_theme(cls): 339 | theme_dict = json.loads(cls.clean_json(cls.get_theme_data())) 340 | theme = SimpleNamespace(**theme_dict) 341 | theme.dict = theme_dict 342 | return theme 343 | 344 | @classmethod 345 | def get_theme_data(cls): 346 | # Name of current theme 347 | defaults = ObjCClass("NSUserDefaults").standardUserDefaults() 348 | name = str(defaults.objectForKey_("ThemeName")) 349 | # Theme is user-created 350 | if name.startswith("User:"): 351 | home = os.getenv("CFFIXED_USER_HOME") 352 | user_themes_path = os.path.join(home, 353 | "Library/Application Support/Themes") 354 | theme_path = os.path.join(user_themes_path, name[5:] + ".json") 355 | # Theme is built-in 356 | else: 357 | res_path = str(ObjCClass("NSBundle").mainBundle().resourcePath()) 358 | theme_path = os.path.join(res_path, "Themes2/%s.json" % name) 359 | # Read theme file 360 | with open(theme_path, "r") as f: 361 | data = f.read() 362 | # Return contents 363 | return data 364 | 365 | @classmethod 366 | def clean_json(cls, string): 367 | # From http://stackoverflow.com/questions/23705304 368 | string = re.sub(",[ \t\r\n]+}", "}", string) 369 | string = re.sub(",[ \t\r\n]+\]", "]", string) 370 | return string 371 | 372 | @classmethod 373 | def console(self, webview_index=0): 374 | webview = WKWebView.webviews[webview_index] 375 | theme = WKWebView.Theme.get_theme() 376 | 377 | print('Welcome to WKWebView console.') 378 | print('Evaluate javascript in any active WKWebView.') 379 | print('Special commands: list, switch #, load , quit') 380 | console.set_color(*ui.parse_color(theme.tint)[:3]) 381 | while True: 382 | value = input('js> ').strip() 383 | self.console_view.history().insertObject_atIndex_(ns(value+'\n'),0) 384 | if value == 'quit': 385 | break 386 | if value == 'list': 387 | for i in range(len(WKWebView.webviews)): 388 | wv = WKWebView.webviews[i] 389 | print(i, '-', wv.name, '-', wv.eval_js('document.title')) 390 | elif value.startswith('switch '): 391 | i = int(value[len('switch '):]) 392 | webview = WKWebView.webviews[i] 393 | elif value.startswith('load '): 394 | url = value[len('load '):] 395 | webview.load_url(url) 396 | else: 397 | print(webview.eval_js(value)) 398 | console.set_color(*ui.parse_color(theme.default_text)[:3]) 399 | 400 | 401 | # MAIN OBJC SECTION 402 | 403 | WKWebView = ObjCClass('WKWebView') 404 | UIViewController = ObjCClass('UIViewController') 405 | WKWebViewConfiguration = ObjCClass('WKWebViewConfiguration') 406 | WKUserContentController = ObjCClass('WKUserContentController') 407 | NSURLRequest = ObjCClass('NSURLRequest') 408 | WKUserScript = ObjCClass('WKUserScript') 409 | 410 | # Navigation delegate 411 | 412 | class _block_decision_handler(Structure): 413 | _fields_ = _block_literal_fields(ctypes.c_long) 414 | 415 | def webView_decidePolicyForNavigationAction_decisionHandler_(_self, _cmd, _webview, _navigation_action, _decision_handler): 416 | delegate_instance = ObjCInstance(_self) 417 | webview = delegate_instance._pythonistawebview() 418 | deleg = webview.delegate 419 | nav_action = ObjCInstance(_navigation_action) 420 | ns_url = nav_action.request().URL() 421 | url = str(ns_url) 422 | nav_type = int(nav_action.navigationType()) 423 | 424 | allow = True 425 | if deleg is not None: 426 | if hasattr(deleg, 'webview_should_start_load'): 427 | allow = deleg.webview_should_start_load(webview, url, nav_type) 428 | 429 | scheme = str(ns_url.scheme()) 430 | if not WKWebView.WKWebView.handlesURLScheme_(scheme): 431 | allow = False 432 | webbrowser.open(url) 433 | 434 | allow_or_cancel = 1 if allow else 0 435 | decision_handler = ObjCInstance(_decision_handler) 436 | retain_global(decision_handler) 437 | blk = WKWebView._block_decision_handler.from_address(_decision_handler) 438 | blk.invoke(_decision_handler, allow_or_cancel) 439 | 440 | f = webView_decidePolicyForNavigationAction_decisionHandler_ 441 | f.argtypes = [c_void_p]*3 442 | f.restype = None 443 | f.encoding = b'v@:@@@?' 444 | # https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html 445 | 446 | def webView_didCommitNavigation_(_self, _cmd, _webview, _navigation): 447 | delegate_instance = ObjCInstance(_self) 448 | webview = delegate_instance._pythonistawebview() 449 | deleg = webview.delegate 450 | if deleg is not None: 451 | if hasattr(deleg, 'webview_did_start_load'): 452 | deleg.webview_did_start_load(webview) 453 | 454 | def webView_didFinishNavigation_(_self, _cmd, _webview, _navigation): 455 | delegate_instance = ObjCInstance(_self) 456 | webview = delegate_instance._pythonistawebview() 457 | deleg = webview.delegate 458 | if deleg is not None: 459 | if hasattr(deleg, 'webview_did_finish_load'): 460 | deleg.webview_did_finish_load(webview) 461 | 462 | def webView_didFailNavigation_withError_(_self, _cmd, _webview, _navigation, _error): 463 | 464 | delegate_instance = ObjCInstance(_self) 465 | webview = delegate_instance._pythonistawebview() 466 | deleg = webview.delegate 467 | err = ObjCInstance(_error) 468 | error_code = int(err.code()) 469 | error_msg = str(err.localizedDescription()) 470 | if deleg is not None: 471 | if hasattr(deleg, 'webview_did_fail_load'): 472 | deleg.webview_did_fail_load(webview, error_code, error_msg) 473 | return 474 | raise RuntimeError(f'WKWebView load failed with code {error_code}: {error_msg}') 475 | 476 | def webView_didFailProvisionalNavigation_withError_(_self, _cmd, _webview, _navigation, _error): 477 | WKWebView.webView_didFailNavigation_withError_(_self, _cmd, _webview, _navigation, _error) 478 | 479 | CustomNavigationDelegate = create_objc_class('CustomNavigationDelegate', superclass=NSObject, methods=[ 480 | webView_didCommitNavigation_, 481 | webView_didFinishNavigation_, 482 | webView_didFailNavigation_withError_, 483 | webView_didFailProvisionalNavigation_withError_, 484 | webView_decidePolicyForNavigationAction_decisionHandler_ 485 | ], 486 | protocols=['WKNavigationDelegate']) 487 | 488 | # Script message handler 489 | 490 | def userContentController_didReceiveScriptMessage_(_self, _cmd, _userContentController, _message): 491 | controller_instance = ObjCInstance(_self) 492 | webview = controller_instance._pythonistawebview() 493 | wk_message = ObjCInstance(_message) 494 | name = str(wk_message.name()) 495 | content = str(wk_message.body()) 496 | handler = getattr(webview, 'on_'+name, None) 497 | if handler: 498 | handler(content) 499 | else: 500 | raise Exception(f'Unhandled message from script - name: {name}, content: {content}') 501 | 502 | CustomMessageHandler = create_objc_class('CustomMessageHandler', UIViewController, methods=[ 503 | userContentController_didReceiveScriptMessage_ 504 | ], protocols=['WKScriptMessageHandler']) 505 | 506 | 507 | # UI delegate (for alerts etc.) 508 | 509 | class _block_alert_completion(Structure): 510 | _fields_ = _block_literal_fields() 511 | 512 | def webView_runJavaScriptAlertPanelWithMessage_initiatedByFrame_completionHandler_(_self, _cmd, _webview, _message, _frame, _completion_handler): 513 | delegate_instance = ObjCInstance(_self) 514 | webview = delegate_instance._pythonistawebview() 515 | message = str(ObjCInstance(_message)) 516 | host = str(ObjCInstance(_frame).request().URL().host()) 517 | webview._javascript_alert(host, message) 518 | #console.alert(host, message, 'OK', hide_cancel_button=True) 519 | completion_handler = ObjCInstance(_completion_handler) 520 | retain_global(completion_handler) 521 | blk = WKWebView._block_alert_completion.from_address(_completion_handler) 522 | blk.invoke(_completion_handler) 523 | 524 | f = webView_runJavaScriptAlertPanelWithMessage_initiatedByFrame_completionHandler_ 525 | f.argtypes = [c_void_p]*4 526 | f.restype = None 527 | f.encoding = b'v@:@@@@?' 528 | 529 | 530 | class _block_confirm_completion(Structure): 531 | _fields_ = _block_literal_fields(ctypes.c_bool) 532 | 533 | def webView_runJavaScriptConfirmPanelWithMessage_initiatedByFrame_completionHandler_(_self, _cmd, _webview, _message, _frame, _completion_handler): 534 | delegate_instance = ObjCInstance(_self) 535 | webview = delegate_instance._pythonistawebview() 536 | message = str(ObjCInstance(_message)) 537 | host = str(ObjCInstance(_frame).request().URL().host()) 538 | result = webview._javascript_confirm(host, message) 539 | completion_handler = ObjCInstance(_completion_handler) 540 | retain_global(completion_handler) 541 | blk = WKWebView._block_confirm_completion.from_address(_completion_handler) 542 | blk.invoke(_completion_handler, result) 543 | 544 | f = webView_runJavaScriptConfirmPanelWithMessage_initiatedByFrame_completionHandler_ 545 | f.argtypes = [c_void_p]*4 546 | f.restype = None 547 | f.encoding = b'v@:@@@@?' 548 | 549 | 550 | class _block_text_completion(Structure): 551 | _fields_ = _block_literal_fields(c_void_p) 552 | 553 | def webView_runJavaScriptTextInputPanelWithPrompt_defaultText_initiatedByFrame_completionHandler_(_self, _cmd, _webview, _prompt, _default_text, _frame, _completion_handler): 554 | delegate_instance = ObjCInstance(_self) 555 | webview = delegate_instance._pythonistawebview() 556 | prompt = str(ObjCInstance(_prompt)) 557 | default_text = str(ObjCInstance(_default_text)) 558 | host = str(ObjCInstance(_frame).request().URL().host()) 559 | result = webview._javascript_prompt(host, prompt, default_text) 560 | completion_handler = ObjCInstance(_completion_handler) 561 | retain_global(completion_handler) 562 | blk = WKWebView._block_text_completion.from_address(_completion_handler) 563 | blk.invoke(_completion_handler, ns(result)) 564 | 565 | f = webView_runJavaScriptTextInputPanelWithPrompt_defaultText_initiatedByFrame_completionHandler_ 566 | f.argtypes = [c_void_p]*5 567 | f.restype = None 568 | f.encoding = b'v@:@@@@@?' 569 | 570 | CustomUIDelegate = create_objc_class('CustomUIDelegate', superclass=NSObject, methods=[ 571 | webView_runJavaScriptAlertPanelWithMessage_initiatedByFrame_completionHandler_, 572 | webView_runJavaScriptConfirmPanelWithMessage_initiatedByFrame_completionHandler_, 573 | webView_runJavaScriptTextInputPanelWithPrompt_defaultText_initiatedByFrame_completionHandler_ 574 | ], 575 | protocols=['WKUIDelegate']) 576 | 577 | 578 | if __name__ == '__main__': 579 | 580 | class MyWebViewDelegate: 581 | 582 | def webview_should_start_load(self, webview, url, nav_type): 583 | "See nav_type options at https://developer.apple.com/documentation/webkit/wknavigationtype?language=objc" 584 | print('Will start loading', url) 585 | return True 586 | 587 | def webview_did_start_load(self, webview): 588 | print('Started loading') 589 | 590 | @ui.in_background 591 | def webview_did_finish_load(self, webview): 592 | print('Finished loading ' + webview.eval_js('document.title')) 593 | 594 | 595 | class MyWebView(WKWebView): 596 | 597 | def on_greeting(self, message): 598 | console.alert(message, 'Message passed to Python', 'OK', hide_cancel_button=True) 599 | 600 | 601 | html = ''' 602 | 603 | 604 | WKWebView tests 605 | 613 | 614 | 615 |

616 | Hello world 617 |

618 |

619 | Pythonista home page 620 |

621 |

622 | +358 40 1234567 623 |

624 |

625 | http://omz-software.com/pythonista/ 626 |

627 | 628 | ''' 629 | 630 | r = ui.View(background_color='black') 631 | 632 | v = MyWebView(name='DemoWKWebView', delegate=MyWebViewDelegate(), swipe_navigation=True, data_detectors=(WKWebView.PHONE_NUMBER,WKWebView.LINK), frame=r.bounds, flex='WH') 633 | r.add_subview(v) 634 | 635 | r.present() # Use 'panel' if you want to view console in another tab 636 | 637 | #v.disable_all() 638 | v.load_html(html) 639 | #v.load_url('http://omz-software.com/pythonista/', no_cache=True, timeout=5) 640 | #v.load_url('file://some/local/file.html') 641 | --------------------------------------------------------------------------------