├── .gitignore ├── LICENSE ├── README.md ├── client.js ├── demo.js ├── index.html └── wsproxy.js /.gitignore: -------------------------------------------------------------------------------- 1 | manifest.js 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Piotr Dobrowolski 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ssap-web 2 | ======== 3 | 4 | A hacky implementation of webOS remote control API on the web. 5 | 6 | How it works 7 | ------------ 8 | 9 | By default a server implementing SSAP on webOS prevents web origins from 10 | accessing its websocket. However there are exceptions to allow communication 11 | from chrome extensions *and `file://` origins*. Both `file://` and `data:` 12 | origins present themselves to remote server as `Origin: null`. We use that to 13 | allow http-based origin to communicate with SSAP server using a hidden iframe 14 | with `src="data:..."` communicating back and forth with the main `http://` 15 | frame. This is implemented in `wsproxy.js`. 16 | 17 | Limitations 18 | ----------- 19 | 20 | Currently this client can't be effectively used on `https://` origins due to 21 | mixed content security policies. Alternatively, user can be requested to 22 | manually approve self-signed certificate used for `wss://:3001` server exposed 23 | by webOS. 24 | 25 | Demo 26 | ---- 27 | 28 | Demo app in `index.html` can be used to preview current screen contents and 29 | perform basic remote control (arrow keys, enter = OK, escape = Back, home = Home) 30 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | const defaultAppManifest = {"manifestVersion":1,"appVersion":"1.1","signed":{"created":"20140509","appId":"com.lge.test","vendorId":"com.lge","localizedAppNames":{"":"LG Remote App","ko-KR":"\ub9ac\ubaa8\ucee8 \uc571","zxx-XX":"\u041b\u0413 R\u044d\u043cot\u044d A\u041f\u041f"},"localizedVendorNames":{"":"LG Electronics"},"permissions":["TEST_SECURE","CONTROL_INPUT_TEXT","CONTROL_MOUSE_AND_KEYBOARD","READ_INSTALLED_APPS","READ_LGE_SDX","READ_NOTIFICATIONS","SEARCH","WRITE_SETTINGS","WRITE_NOTIFICATION_ALERT","CONTROL_POWER","READ_CURRENT_CHANNEL","READ_RUNNING_APPS","READ_UPDATE_INFO","UPDATE_FROM_REMOTE_APP","READ_LGE_TV_INPUT_EVENTS","READ_TV_CURRENT_TIME"],"serial":"2f930e2d2cfe083771f68e4fe7bb07"},"permissions":["LAUNCH","LAUNCH_WEBAPP","APP_TO_APP","CLOSE","TEST_OPEN","TEST_PROTECTED","CONTROL_AUDIO","CONTROL_DISPLAY","CONTROL_INPUT_JOYSTICK","CONTROL_INPUT_MEDIA_RECORDING","CONTROL_INPUT_MEDIA_PLAYBACK","CONTROL_INPUT_TV","CONTROL_POWER","READ_APP_STATUS","READ_CURRENT_CHANNEL","READ_INPUT_DEVICE_LIST","READ_NETWORK_STATE","READ_RUNNING_APPS","READ_TV_CHANNEL_LIST","WRITE_NOTIFICATION_TOAST","READ_POWER_STATE","READ_COUNTRY_INFO","READ_SETTINGS","CONTROL_TV_SCREEN","CONTROL_TV_STANBY","CONTROL_FAVORITE_GROUP","CONTROL_USER_INFO","CHECK_BLUETOOTH_DEVICE","CONTROL_BLUETOOTH","CONTROL_TIMER_INFO","STB_INTERNAL_CONNECTION","CONTROL_RECORDING","READ_RECORDING_STATE","WRITE_RECORDING_LIST","READ_RECORDING_LIST","READ_RECORDING_SCHEDULE","WRITE_RECORDING_SCHEDULE","READ_STORAGE_DEVICE_LIST","READ_TV_PROGRAM_INFO","CONTROL_BOX_CHANNEL","READ_TV_ACR_AUTH_TOKEN","READ_TV_CONTENT_STATE","READ_TV_CURRENT_TIME","ADD_LAUNCHER_CHANNEL","SET_CHANNEL_SKIP","RELEASE_CHANNEL_SKIP","CONTROL_CHANNEL_BLOCK","DELETE_SELECT_CHANNEL","CONTROL_CHANNEL_GROUP","SCAN_TV_CHANNELS","CONTROL_TV_POWER","CONTROL_WOL"],"signatures":[{"signatureVersion":1,"signature":"eyJhbGdvcml0aG0iOiJSU0EtU0hBMjU2Iiwia2V5SWQiOiJ0ZXN0LXNpZ25pbmctY2VydCIsInNpZ25hdHVyZVZlcnNpb24iOjF9.hrVRgjCwXVvE2OOSpDZ58hR+59aFNwYDyjQgKk3auukd7pcegmE2CzPCa0bJ0ZsRAcKkCTJrWo5iDzNhMBWRyaMOv5zWSrthlf7G128qvIlpMT0YNY+n/FaOHE73uLrS/g7swl3/qH/BGFG2Hu4RlL48eb3lLKqTt2xKHdCs6Cd4RMfJPYnzgvI4BNrFUKsjkcu+WD4OO2A27Pq1n50cMchmcaXadJhGrOqH5YmHdOCj5NSHzJYrsW0HPlpuAx/ECMeIZYDh6RMqaFM2DXzdKX9NmmyqzJ3o/0lkk/N97gfVRLW5hA29yeAwaCViZNCP8iC9aO0q9fQojoa7NQnAtw=="}]}; 2 | 3 | class SSAPClient { 4 | constructor(target, key, useTLS = false) { 5 | this.target = target; 6 | this.key = key; 7 | this.useTLS = useTLS; 8 | this.pendingRequests = new Map(); 9 | } 10 | 11 | close() { 12 | this.conn.close(); 13 | } 14 | 15 | connect() { 16 | return new Promise((resolve, reject) => { 17 | this.conn = new ProxyWebSocket(this.useTLS ? `wss://${this.target}:3001` : `ws://${this.target}:3000`); 18 | this.conn.onopen = () => resolve(); 19 | this.conn.onclose = () => reject(new Error('Connection closed')); 20 | this.conn.onerror = (err) => reject(new Error('Connection error')); 21 | this.conn.onmessage = (evt) => this.handleMessage(evt.data); 22 | }); 23 | } 24 | 25 | handleMessage(msg) { 26 | const m = JSON.parse(msg); 27 | if (this.pendingRequests.has(m.id)) { 28 | const {resolve, reject, type} = this.pendingRequests.get(m.id); 29 | if (type === 'register' && m.type !== 'registered') { 30 | console.info('register confirmation...'); 31 | } else { 32 | this.pendingRequests.delete(m.id); 33 | resolve(m); 34 | } 35 | } else { 36 | console.warn('Unexpected message', m); 37 | } 38 | } 39 | 40 | request(msg) { 41 | return new Promise((resolve, reject) => { 42 | const p = { 43 | type: 'request', 44 | id: this.genid(), 45 | payload: {}, 46 | ...msg, 47 | }; 48 | this.pendingRequests.set(p.id, {resolve, reject, type: p.type}); 49 | this.conn.send(JSON.stringify(p)); 50 | }); 51 | } 52 | 53 | genid() { 54 | return String(Math.floor(Math.random() * 10000000)); 55 | } 56 | 57 | async register(appManifest = defaultAppManifest) { 58 | const result = await this.request({ 59 | "type": "register", 60 | "payload": { 61 | "forcePairing": false, 62 | "pairingType": "PROMPT", 63 | "manifest": appManifest, 64 | "client-key": this.key, 65 | }, 66 | }); 67 | return result.payload['client-key']; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /demo.js: -------------------------------------------------------------------------------- 1 | function wait(ms) { 2 | return new Promise((resolve, reject) => { 3 | setTimeout(() => resolve(), ms); 4 | }); 5 | } 6 | 7 | function log(msg) { 8 | const l = document.querySelector('#log'); 9 | l.innerText = '[' + new Date() + '] ' + msg + '\n' + l.innerText; 10 | } 11 | 12 | let client = null; 13 | 14 | let inputSocket = null; 15 | document.addEventListener('keydown', (evt) => { 16 | const keys = { 17 | 'Enter': 'ENTER', 18 | 'ArrowLeft': 'LEFT', 19 | 'ArrowRight': 'RIGHT', 20 | 'ArrowUp': 'UP', 21 | 'ArrowDown': 'DOWN', 22 | 'Escape': 'BACK', 23 | 'Home': 'HOME', 24 | }; 25 | 26 | if (evt.key in keys && inputSocket) { 27 | inputSocket.send(Object.entries({type: 'button', name: keys[evt.key]}).map(([k, v]) => `${k}:${v}`).join('\n') + '\n\n'); 28 | } 29 | }); 30 | 31 | document.querySelector('#run').addEventListener('click', async () => { 32 | if (client) { 33 | client.close(); 34 | client = null; 35 | } 36 | 37 | const target = document.querySelector('#ip').value; 38 | try { 39 | client = new SSAPClient(target, window.localStorage['client-key-' + target]); 40 | log('connecting...'); 41 | await client.connect(); 42 | log('registering...'); 43 | let manifest = defaultAppManifest; 44 | try { 45 | manifest = JSON.parse(window.localStorage['ssap-app-manifest']); 46 | log("using custom manifest"); 47 | } catch (err) {} 48 | window.localStorage['client-key-' + target] = await client.register(manifest); 49 | log('connected'); 50 | 51 | if (document.querySelector(':focus')) document.querySelector(':focus').blur(); 52 | 53 | (async () => { 54 | const sock = await client.request({ 55 | uri: 'ssap://com.webos.service.networkinput/getPointerInputSocket' 56 | }); 57 | 58 | inputSocket = new WebSocket(sock.payload.socketPath); 59 | inputSocket.onopen = () => { 60 | log('input open'); 61 | } 62 | inputSocket.onerror = (err) => log('input err ' + err.msg); 63 | inputSocket.onclose = () => log('input close'); 64 | })(); 65 | 66 | while (true) { 67 | const res = await client.request({ 68 | uri: 'ssap://tv/executeOneShot', 69 | payload: {}, 70 | }); 71 | 72 | const oldb = document.querySelector('.capture .back'); 73 | if (oldb) { 74 | oldb.remove(); 75 | } 76 | 77 | const oldf = document.querySelector('.capture .front'); 78 | if (oldf) { 79 | oldf.classList.remove('front'); 80 | oldf.classList.add('back') 81 | } 82 | 83 | // This is bad. We do some """double-buffering""" of dynamically-generated 84 | // SVG objects in order to bust Chrome's cache for preview image. `imageUri` 85 | // here is static for all responses, and doesn't accept any extra query 86 | // arguments. This hack seems to solve it on Chrome. (Firefox seems to 87 | // update the if hash-part of an URL changes) 88 | const newf = document.createElement('object'); 89 | newf.classList.add('front'); 90 | newf.setAttribute('type', 'image/svg+xml'); 91 | newf.setAttribute('data', 'data:image/svg+xml;base64,' + btoa(``)); // res.payload.imageUri + '#' + Date.now()); 92 | document.querySelector('.capture').appendChild(newf); 93 | 94 | await wait(100); 95 | } 96 | } catch (err) { log('error: ' + err); console.info(err); } 97 | }); 98 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 33 | 34 | 35 | 36 |
37 | 38 | 39 |
40 |

41 |   
42 |
43 | 44 | 45 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /wsproxy.js: -------------------------------------------------------------------------------- 1 | function proxyPayload(address) { 2 | function forward(type, data) { 3 | window.parent.postMessage(JSON.stringify({type: type, data: data}), "*"); 4 | } 5 | const conn = new WebSocket(address); 6 | conn.onopen = function (evt) {forward('open', evt);} 7 | conn.onclose = function (evt) {forward('close', evt);} 8 | conn.onerror = function (evt) {forward('error', evt);} 9 | conn.onmessage = function (evt) {forward('message', {data: evt.data});} 10 | window.addEventListener("message", function (event) { 11 | const msg = (JSON.parse(event.data)); 12 | if (msg.type === 'send') { 13 | conn.send(msg.data); 14 | } else if (msg.type === 'close') { 15 | conn.close(); 16 | } 17 | }, false); 18 | } 19 | 20 | class ProxyWebSocket { 21 | constructor(target) { 22 | this.proxy = document.createElement('iframe'); 23 | this.proxy.style.display = 'none'; 24 | this.proxy.setAttribute('src', 'data:text/html;base64,' + btoa('

hello

')); 25 | window.addEventListener("message", (event) => { 26 | if (event.source !== this.proxy.contentWindow) return; 27 | 28 | const msg = JSON.parse(event.data); 29 | const type = msg.type; const data = msg.data; 30 | 31 | if (type === 'message' && this.onmessage) this.onmessage(data); 32 | if (type === 'close' && this.onclose) { 33 | this.proxy.remove(); 34 | this.onclose(data); 35 | } 36 | 37 | if (type === 'error' && this.onerror) this.onerror(data); 38 | if (type === 'open' && this.onopen) this.onopen(data); 39 | if (type === 'log') console.info('proxy:', data); 40 | }, false); 41 | document.querySelector('body').appendChild(this.proxy); 42 | } 43 | 44 | send(data) { 45 | this.proxy.contentWindow.postMessage(JSON.stringify({type: 'send', data: data}), '*'); 46 | } 47 | 48 | close() { 49 | this.proxy.contentWindow.postMessage(JSON.stringify({type: 'close'}), '*'); 50 | } 51 | } 52 | --------------------------------------------------------------------------------