|
| 1 | +/* |
| 2 | + * GNU AGPL-3.0 License |
| 3 | + * |
| 4 | + * Copyright (c) 2021 - present core.ai . All rights reserved. |
| 5 | + * Original work Copyright (c) 2014 - 2021 Adobe Systems Incorporated. All rights reserved. |
| 6 | + * |
| 7 | + * This program is free software: you can redistribute it and/or modify it |
| 8 | + * under the terms of the GNU Affero General Public License as published by |
| 9 | + * the Free Software Foundation, either version 3 of the License, or |
| 10 | + * (at your option) any later version. |
| 11 | + * |
| 12 | + * This program is distributed in the hope that it will be useful, but WITHOUT |
| 13 | + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
| 14 | + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License |
| 15 | + * for more details. |
| 16 | + * |
| 17 | + * You should have received a copy of the GNU Affero General Public License |
| 18 | + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. |
| 19 | + * |
| 20 | + */ |
| 21 | + |
| 22 | +// This is a transport injected into the browser via a script that handles the low |
| 23 | +// level communication between the live development protocol handlers on both sides. |
| 24 | +// The actual communication to phoenix is done via the loaded web worker below. We just post/receive all |
| 25 | +// messages that should be sent/received by live preview to the worker. The worker will use broadcast |
| 26 | +// channels in browser and web sockets in desktop builds to rely on the message to phoenix. |
| 27 | +/** |
| 28 | + * Communication Architecture in PHCode.dev Browser Environment |
| 29 | + * ------------------------------------------------------------ |
| 30 | + * |
| 31 | + * First of all I like to apologize for this complexity, it is how it is due to the browser standards security |
| 32 | + * policy, intelligent tracking prevention in browsers and the inherent multiprocess communication problem. |
| 33 | + * The dining philosophers can however take rest as the mechanism is fully lockless thanks to how js handles events. |
| 34 | + * |
| 35 | + * Overview: |
| 36 | + * PHCode.dev operates with a multi-iframe setup to facilitate communication between different components |
| 37 | + * within the same domain(phcode.dev) and cross domain(phcode.dev<>phcode.live). Live previews have to be domain |
| 38 | + * isolated to phcode.live domain so that malicious project live previews doesn't steal phcode.dev cookies and |
| 39 | + * take control of the users account by just opening a live preview. |
| 40 | + * This setup includes a preview page(phcode.dev/live-preview-loader.html), a server iframe (phcode.live), and an actual |
| 41 | + * preview iframe where the user's code is rendered(phcode.live/user/projoject/live/preview.html). |
| 42 | + * |
| 43 | + * Components: |
| 44 | + * 1. Preview Page (phcode.dev): |
| 45 | + * - Serves as the primary interface for the user. The actual tab. |
| 46 | + * - Hosts two iframes: the server iframe and the actual preview iframe. |
| 47 | + * |
| 48 | + * 2. Server Iframe (phcode.live): |
| 49 | + * - Responsible for installing a service worker for virtual server, sandboxed to its specific tab. |
| 50 | + * - Acts as an intermediary in the communication chain. |
| 51 | + * |
| 52 | + * 3. Actual Preview Iframe: (phcode.live/user/projoject/live/preview.html) |
| 53 | + * - Renders the user's code. |
| 54 | + * - Utilizes a broadcast channel within the web worker to send messages. We use a web worker so |
| 55 | + * that live preview tab hearbeat messages are sent to the editor even if the user is debugging |
| 56 | + * the page causing js execution to halt in the debugging thread but not the worker thread. |
| 57 | + * |
| 58 | + * Communication Flow: |
| 59 | + * 1. Messages originate from the Actual Preview Iframe, where the user's script is loaded. |
| 60 | + * 2. These messages are sent to the Live Preview Server Iframe via a broadcast channel in the service worker. |
| 61 | + * 3. The Server Iframe then relays these messages to the parent PHCode.dev frame. |
| 62 | + * 4. Finally, the PHCode.dev frame forwards these messages to the PHCode.dev editor page. |
| 63 | + * - This step occurs if the editor page is loaded in a different tab and not as an in-editor live preview panel. |
| 64 | + * |
| 65 | + * Note on Communication Constraints and Solutions: |
| 66 | + * ------------------------------------------------ |
| 67 | + * Cross-Domain Communication Limitations: |
| 68 | + * - The default security model of web browsers restricts cross-domain communication as a measure to preserve security. |
| 69 | + * - This means that iframes from different domains cannot freely communicate with each other due to |
| 70 | + * browser-enforced sandboxing. |
| 71 | + * |
| 72 | + * Use of Broadcast Channels within the Same Domain: |
| 73 | + * - To circumvent these cross-domain communication restrictions, PHCode.dev employs broadcast channels within |
| 74 | + * the same domain. |
| 75 | + * |
| 76 | + * Solution for Cross-Domain Communication: |
| 77 | + * - The architecture is designed to avoid direct cross-domain communication, which is restricted by |
| 78 | + * the browser's security model. |
| 79 | + * - Instead, a 'hoola hoop' method is used where the server Iframe (phcode.live) relays broadcast channel |
| 80 | + * messages in phcode.live to its cross domain parent window phcode.dev through window post message apis. |
| 81 | + * - The parent PHCode.dev frame further communicates with the PHCode.dev editor page, if its in a different tab. |
| 82 | + * |
| 83 | + * Working within Browser Security Framework: |
| 84 | + * - This approach allows the system to operate within the browser's security constraints. |
| 85 | + * - It eliminates the need for server-side assistance, thus enabling instant live preview |
| 86 | + * feedback in a purely client-side setting. |
| 87 | + **/ |
| 88 | + |
| 89 | + |
| 90 | +(function (global) { |
| 91 | + |
| 92 | + // The below line will be replaced with the transport scripts provided by the static server at |
| 93 | + // LivePreviewTransport.js:getRemoteScript() This is so that the actual live preview page doesnt get hold of |
| 94 | + // any phoenix web socket or broadcast channel ids from this closure programatically for security. |
| 95 | + |
| 96 | + //Replace dynamic section start |
| 97 | + const TRANSPORT_CONFIG={}; |
| 98 | + //Replace dynamic section end |
| 99 | + |
| 100 | + function _debugLog(...args) { |
| 101 | + if(window.LIVE_PREVIEW_DEBUG_ENABLED) { |
| 102 | + console.log(...args); |
| 103 | + } |
| 104 | + } |
| 105 | + |
| 106 | + const clientID = "" + Math.round( Math.random()*1000000000); |
| 107 | + |
| 108 | + const worker = new Worker(TRANSPORT_CONFIG.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME); |
| 109 | + let _workerMessageProcessor; |
| 110 | + worker.onmessage = (event) => { |
| 111 | + const type = event.data.type; |
| 112 | + switch (type) { |
| 113 | + case 'REDIRECT_PAGE': location.href = event.data.URL; break; |
| 114 | + default: |
| 115 | + if(_workerMessageProcessor){ |
| 116 | + return _workerMessageProcessor(event); |
| 117 | + } |
| 118 | + console.error("Live Preview page loader: received unknown message from worker:", event); |
| 119 | + } |
| 120 | + }; |
| 121 | + // message channel to phoenix connect on load itself. The channel id is injected from phoenix |
| 122 | + // via LivePreviewTransport.js while serving the instrumented html file |
| 123 | + worker.postMessage({ |
| 124 | + type: "setupPhoenixComm", |
| 125 | + livePreviewDebugModeEnabled: TRANSPORT_CONFIG.LIVE_PREVIEW_DEBUG_ENABLED, |
| 126 | + broadcastChannel: TRANSPORT_CONFIG.LIVE_PREVIEW_BROADCAST_CHANNEL_ID, // in browser this will be present, but not in tauri |
| 127 | + websocketChannelURL: TRANSPORT_CONFIG.LIVE_PREVIEW_WEBSOCKET_CHANNEL_URL, // in tauri this will be present. not in browser |
| 128 | + clientID |
| 129 | + }); |
| 130 | + function _postLivePreviewMessage(message) { |
| 131 | + worker.postMessage({type: "livePreview", message}); |
| 132 | + } |
| 133 | + let sentTitle, sentFavIconURL; |
| 134 | + |
| 135 | + function convertImgToBase64(url, callback) { |
| 136 | + if(!url){ |
| 137 | + callback(null); |
| 138 | + return; |
| 139 | + } |
| 140 | + let canvas = document.createElement('CANVAS'); |
| 141 | + const ctx = canvas.getContext('2d'); |
| 142 | + const img = new Image(); |
| 143 | + img.crossOrigin = 'Anonymous'; |
| 144 | + img.onload = function() { |
| 145 | + canvas.height = img.height; |
| 146 | + canvas.width = img.width; |
| 147 | + ctx.drawImage(img, 0, 0); |
| 148 | + const dataURL = canvas.toDataURL(); |
| 149 | + callback(dataURL); |
| 150 | + canvas = null; |
| 151 | + }; |
| 152 | + img.src = url; |
| 153 | + } |
| 154 | + |
| 155 | + setInterval(()=>{ |
| 156 | + const favIcon = document.querySelector("link[rel~='icon']"); |
| 157 | + const faviconUrl = favIcon && favIcon.href; |
| 158 | + if(sentFavIconURL !== faviconUrl){ |
| 159 | + sentFavIconURL = faviconUrl; |
| 160 | + convertImgToBase64(faviconUrl, function(base64) { |
| 161 | + if(!base64){ |
| 162 | + base64 = "favicon.ico"; |
| 163 | + } |
| 164 | + worker.postMessage({ |
| 165 | + type: "updateTitleIcon", |
| 166 | + faviconBase64: base64 |
| 167 | + }); |
| 168 | + }); |
| 169 | + } |
| 170 | + |
| 171 | + if(sentTitle!== document.title) { |
| 172 | + sentTitle = document.title; |
| 173 | + worker.postMessage({ |
| 174 | + type: "updateTitleIcon", |
| 175 | + title: document.title |
| 176 | + }); |
| 177 | + } |
| 178 | + }, 1000); |
| 179 | + |
| 180 | + global._Brackets_LiveDev_Transport = { |
| 181 | + _channelOpen: false, |
| 182 | + |
| 183 | + /** |
| 184 | + * @private |
| 185 | + * An object that contains callbacks to handle various transport events. See `setCallbacks()`. |
| 186 | + * @type {?{connect: ?function, message: ?function(string), close: ?function}} |
| 187 | + */ |
| 188 | + _callbacks: null, |
| 189 | + |
| 190 | + /** |
| 191 | + * Sets the callbacks that should be called when various transport events occur. All callbacks |
| 192 | + * are optional, but you should at least implement "message" or nothing interesting will happen :) |
| 193 | + * @param {?{connect: ?function, message: ?function(string), close: ?function}} callbacks |
| 194 | + * The callbacks to set. |
| 195 | + * connect - called when a connection is established to Brackets |
| 196 | + * message(msgStr) - called with a string message sent from Brackets |
| 197 | + * close - called when Brackets closes the connection |
| 198 | + */ |
| 199 | + setCallbacks: function (callbacks) { |
| 200 | + this._callbacks = callbacks; |
| 201 | + }, |
| 202 | + |
| 203 | + /** |
| 204 | + * Connects to the LivePreviewTransport in Brackets. |
| 205 | + */ |
| 206 | + connect: function () { |
| 207 | + const self = this; |
| 208 | + |
| 209 | + // Listen to the response |
| 210 | + _workerMessageProcessor = (event) => { |
| 211 | + // Print the result |
| 212 | + _debugLog("Live Preview: Browser received event from Phoenix: ", JSON.stringify(event.data)); |
| 213 | + const type = event.data.type; |
| 214 | + switch (type) { |
| 215 | + case 'BROWSER_CONNECT': break; // do nothing. This is a loopback message from another live preview tab |
| 216 | + case 'BROWSER_MESSAGE': break; // do nothing. This is a loopback message from another live preview tab |
| 217 | + case 'BROWSER_CLOSE': break; // do nothing. This is a loopback message from another live preview tab |
| 218 | + case 'MESSAGE_FROM_PHOENIX': |
| 219 | + if (self._callbacks && self._callbacks.message) { |
| 220 | + const clientIDs = event.data.clientIDs, |
| 221 | + message = event.data.message; |
| 222 | + if(clientIDs.includes(clientID) || clientIDs.length === 0){ |
| 223 | + // clientIDs.length = 0 if the message is intended for all clients |
| 224 | + self._callbacks.message(message); |
| 225 | + } |
| 226 | + } |
| 227 | + break; |
| 228 | + case 'PHOENIX_CLOSE': |
| 229 | + self._channelOpen = false; |
| 230 | + if (self._callbacks && self._callbacks.close) { |
| 231 | + self._callbacks.close(); |
| 232 | + } |
| 233 | + break; |
| 234 | + } |
| 235 | + }; |
| 236 | + _postLivePreviewMessage({ |
| 237 | + type: 'BROWSER_CONNECT', |
| 238 | + url: global.location.href, |
| 239 | + clientID: clientID |
| 240 | + }); |
| 241 | + self._channelOpen = true; |
| 242 | + if (self._callbacks && self._callbacks.connect) { |
| 243 | + self._callbacks.connect(); |
| 244 | + } |
| 245 | + |
| 246 | + // attach to browser tab/window closing event so that we send a cleanup request |
| 247 | + // to the service worker for the comm ports |
| 248 | + addEventListener( 'beforeunload', function() { |
| 249 | + if(self._channelOpen){ |
| 250 | + self._channelOpen = false; |
| 251 | + _postLivePreviewMessage({ |
| 252 | + type: 'BROWSER_CLOSE', |
| 253 | + clientID: clientID |
| 254 | + }); |
| 255 | + } |
| 256 | + }); |
| 257 | + }, |
| 258 | + |
| 259 | + /** |
| 260 | + * Sends a message over the transport. |
| 261 | + * @param {string} msgStr The message to send. |
| 262 | + */ |
| 263 | + send: function (msgStr) { |
| 264 | + _postLivePreviewMessage({ |
| 265 | + type: 'BROWSER_MESSAGE', |
| 266 | + clientID: clientID, |
| 267 | + message: msgStr |
| 268 | + }); |
| 269 | + }, |
| 270 | + |
| 271 | + /** |
| 272 | + * Establish web socket connection. |
| 273 | + */ |
| 274 | + enable: function () { |
| 275 | + this.connect(); |
| 276 | + } |
| 277 | + }; |
| 278 | + |
| 279 | + function getAbsoluteUrl(url) { |
| 280 | + // Check if the URL is already absolute |
| 281 | + if (/^(?:[a-z]+:)?\/\//i.test(url)) { |
| 282 | + return url; // The URL is already absolute |
| 283 | + } |
| 284 | + |
| 285 | + // If not, create an absolute URL using the current page's location as the base |
| 286 | + const absoluteUrl = new URL(url, window.location.href); |
| 287 | + return absoluteUrl.href; |
| 288 | + } |
| 289 | + |
| 290 | + // This is only for tauri builds where the live preview is embedded in the phoenix editor iframe. on clicking |
| 291 | + // any urls that needs to be open in a browser window, we execute this. In browser, this is no-op as there is |
| 292 | + // no corresponding listener attached in phoenix browser server. |
| 293 | + document.addEventListener('click', function(event) { |
| 294 | + let targetElement = event.target; |
| 295 | + // Traverse one level up the DOM to find an anchor element if the target is not the anchor itself |
| 296 | + // eg when image inside anchor elements etc..: <a><img></img></a> |
| 297 | + if (targetElement !== null && targetElement.tagName !== 'A') { |
| 298 | + targetElement = targetElement.parentElement; |
| 299 | + } |
| 300 | + |
| 301 | + if (targetElement && targetElement.tagName === 'A' && (targetElement.target === '_blank')) { |
| 302 | + const href = getAbsoluteUrl(targetElement.getAttribute('href')); |
| 303 | + window.parent.postMessage({ |
| 304 | + handlerName: "ph-liveServer", |
| 305 | + eventName: 'embeddedIframeHrefClick', |
| 306 | + href: href |
| 307 | + }, "*"); |
| 308 | + } |
| 309 | + }); |
| 310 | + document.addEventListener('contextmenu', function(event) { |
| 311 | + (document.activeElement || document.body).focus(); |
| 312 | + }); |
| 313 | + document.addEventListener('keydown', function(event) { |
| 314 | + if (event.key === 'Escape' || event.key === 'Esc') { // Check for Escape key |
| 315 | + // Perform the desired action for the Escape key |
| 316 | + window.parent.postMessage({ |
| 317 | + handlerName: "ph-liveServer", |
| 318 | + eventName: 'embeddedEscapeKeyPressed' |
| 319 | + }, "*"); |
| 320 | + } |
| 321 | + }); |
| 322 | +}(this)); |
0 commit comments