├── README.md ├── background.html ├── cws └── screenshot.png ├── images ├── icon-rec.png ├── icon.png ├── pause.png ├── play.png ├── still.png └── video.png ├── manifest.json ├── oauth2 ├── README.md ├── adapters │ ├── facebook.js │ ├── github.js │ ├── google.js │ └── sample.js ├── oauth2.html ├── oauth2.js └── oauth2_inject.js ├── picasa.js ├── playback.css ├── playback.html ├── playback.js ├── screencast.md └── screenshot.js /README.md: -------------------------------------------------------------------------------- 1 | # Video screencast sample 2 | 3 | Make video screencasts of a chrome tab, and play them back right in the 4 | browser. Share stills to Picasa. 5 | -------------------------------------------------------------------------------- /background.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Web Flow Background 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | -------------------------------------------------------------------------------- /cws/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borismus/chrome-screencast/466b0faab989c65dc2121ed41e2df15781eea8b5/cws/screenshot.png -------------------------------------------------------------------------------- /images/icon-rec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borismus/chrome-screencast/466b0faab989c65dc2121ed41e2df15781eea8b5/images/icon-rec.png -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borismus/chrome-screencast/466b0faab989c65dc2121ed41e2df15781eea8b5/images/icon.png -------------------------------------------------------------------------------- /images/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borismus/chrome-screencast/466b0faab989c65dc2121ed41e2df15781eea8b5/images/pause.png -------------------------------------------------------------------------------- /images/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borismus/chrome-screencast/466b0faab989c65dc2121ed41e2df15781eea8b5/images/play.png -------------------------------------------------------------------------------- /images/still.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borismus/chrome-screencast/466b0faab989c65dc2121ed41e2df15781eea8b5/images/still.png -------------------------------------------------------------------------------- /images/video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borismus/chrome-screencast/466b0faab989c65dc2121ed41e2df15781eea8b5/images/video.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Screencast", 3 | "version": "0.3", 4 | "description": "Records a video of your chrome browser. Lets you share out the video or stills of the video.", 5 | "icons": { 6 | "128": "images/icon.png", 7 | "48": "images/icon.png", 8 | "16": "images/icon.png" 9 | }, 10 | "background_page": "background.html", 11 | "browser_action": { 12 | "default_title": "Start recording.", 13 | "default_icon": "images/icon.png" 14 | }, 15 | "content_scripts": [ 16 | { 17 | "matches": ["http://www.google.com/robots.txt*"], 18 | "js": ["oauth2/oauth2_inject.js"] 19 | } 20 | ], 21 | "permissions": ["tabs", ""] 22 | } 23 | -------------------------------------------------------------------------------- /oauth2/README.md: -------------------------------------------------------------------------------- 1 | OAuth 2.0 Library for Chrome Extensions 2 | 3 | # Goals 4 | 5 | 1. Avoid background pages 6 | 2. Support a variety of OAuth 2.0 providers that implement the spec 7 | 3. Allow multiple applications to use different OAuth 2.0 endpoints from 8 | one chrome extension. 9 | 10 | TODO: 11 | * Proper error handling 12 | * Unit tests based on Google's implementation at least 13 | 14 | # Flow 15 | 16 | 1. Open popup to get an authorizationCode. Specify an implementation 17 | specific redirect (ex. google.com/robots.txt) 18 | 2. User fills out a form (or is already logged in) and then allows 19 | access to the Allow button 20 | 3. OAuth 2.0 server calls back with REDIRECT?code=THE_CODE which is 21 | intercepted by an injected script which then calls back to the 22 | extension itself. Also provided (along with code and/or errors) is 23 | the invoking URL. 24 | 4. Now in oauth2.html, look up which adapter we called (by doing a 25 | lookup based on the invoking URL) 26 | 27 | # User setup 28 | 29 | * Add oauth2_inject.js to http://www.oauth.net/robots.txt injected 30 | scripts in the manifest: 31 | 32 | "content_scripts": [ 33 | { 34 | "matches": ["http://www.oauth.net/robots.txt*"], 35 | "js": ["oauth2_inject.js"] 36 | } 37 | ], 38 | 39 | * Initialize the OAuth 2.0 sessions you care about: 40 | 41 | var myAuth = new OAuth2(adapterName, { 42 | clientId: foo, 43 | clientSecret: bar, 44 | apiScope: baz, 45 | }); 46 | 47 | which may open an Allow dialog. Can also do 48 | 49 | var myAuth = new OAuth2(adapterName); 50 | 51 | if relying on previously saved credentials. 52 | 53 | * Authorize the sessions 54 | 55 | myAuth.authorize(function() { 56 | // Successfully authorized 57 | }); 58 | 59 | * Get token via accessToken = myAuth.getAccessToken() 60 | 61 | # Implementation-specific information 62 | 63 | * Authorization Code URL format (authorizationCodeURLFormat) 64 | 65 | https://accounts.google.com/o/oauth2/auth? 66 | client_id={{CLIENT_ID}& 67 | redirect_uri={{REDIRECT_URI}}& 68 | scope={{SCOPE}}& 69 | response_type=code 70 | 71 | or 72 | 73 | https://www.facebook.com/dialog/oauth? 74 | client_id={{CLIENT_ID}}&redirect_uri={{REDIRECT_URI -- suffixed with ?type=code}} 75 | 76 | * Redirect URL prefix (redirectURLPrefix) 77 | 78 | http://www.oauth.net/robots.txt 79 | 80 | * A function to parse the response given by the URL and handle errors 81 | (parseAuthorizationCode) 82 | 83 | https://www.example.com/back?code=4/P7q7W91a-oMsCeLvIaQm6bTrgtp6 84 | 85 | or 86 | 87 | http://www.example.com/foo?code=73iuhfHx98FKJr 88 | 89 | 90 | * URL and params for fetching access tokens (accessTokenURL, 91 | accessTokenMethod, accessTokenParams) 92 | 93 | POST https://accounts.google.com/o/oauth2/token 94 | 95 | code={{AUTHORIZATION_CODE}} 96 | client_id={{CLIENT_ID}} 97 | client_secret={{CLIENT_SECRET}} 98 | redirect_uri={{REDIRECT_URI -- suffixed with ?type=token}} 99 | grant_type=authorization_code 100 | 101 | 102 | or 103 | 104 | https://graph.facebook.com/oauth/access_token? 105 | client_id={{CLIENT_ID}}&redirect_uri={{REDIRECT_URI}}& 106 | client_secret={{CLIENT_SECRET}}&code={{AUTHORIZATION_CODE}} 107 | 108 | 109 | * A function to parse the response given by the access token endpoint 110 | (parseAccessToken) 111 | 112 | { 113 | "access_token":"1/fFAGRNJru1FTz70BzhT3Zg", 114 | "expires_in":3920, 115 | "refresh_token":"1/6BMfW9j53gdGImsixUH6kU5RsR4zwI9lUVX-tqf8JXQ" 116 | } 117 | 118 | or 119 | 120 | access_token=135198374987134|JKHASf7868v&expires=5108 121 | -------------------------------------------------------------------------------- /oauth2/adapters/facebook.js: -------------------------------------------------------------------------------- 1 | OAuth2.adapter('facebook', { 2 | authorizationCodeURL: function(config) { 3 | return 'https://www.facebook.com/dialog/oauth? \ 4 | client_id={{CLIENT_ID}}&redirect_uri={{REDIRECT_URI}}&scope={{API_SCOPE}}' 5 | .replace('{{CLIENT_ID}}', config.clientId) 6 | .replace('{{REDIRECT_URI}}', this.redirectURL(config)) 7 | .replace('{{API_SCOPE}}', config.apiScope); 8 | }, 9 | 10 | redirectURL: function(config) { 11 | return 'http://www.facebook.com/robots.txt'; 12 | }, 13 | 14 | parseAuthorizationCode: function(url) { 15 | // TODO: error handling (URL may have 16 | // ?error=asfasfasiof&error_code=43 etc) 17 | return url.match(/\?code=(.+)/)[1]; 18 | }, 19 | 20 | accessTokenURL: function() { 21 | return 'https://graph.facebook.com/oauth/access_token'; 22 | }, 23 | 24 | accessTokenMethod: function() { 25 | return 'GET'; 26 | }, 27 | 28 | accessTokenParams: function(authorizationCode, config) { 29 | return { 30 | code: authorizationCode, 31 | client_id: config.clientId, 32 | client_secret: config.clientSecret, 33 | redirect_uri: this.redirectURL(config) 34 | } 35 | }, 36 | 37 | parseAccessToken: function(response) { 38 | return { 39 | accessToken: response.match(/access_token=([^&]*)/)[1], 40 | expiresIn: response.match(/expires=([^&]*)/)[1] 41 | } 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /oauth2/adapters/github.js: -------------------------------------------------------------------------------- 1 | OAuth2.adapter('github', { 2 | /** 3 | * @return {URL} URL to the page that returns the authorization code 4 | */ 5 | authorizationCodeURL: function(config) { 6 | return 'https://github.com/login/oauth/authorize?\ 7 | client_id={{CLIENT_ID}}&redirect_uri={{REDIRECT_URI}}' 8 | .replace('{{CLIENT_ID}}', config.clientId) 9 | .replace('{{REDIRECT_URI}}', this.redirectURL(config)); 10 | }, 11 | 12 | /** 13 | * @return {URL} URL to the page that we use to inject the content 14 | * script into 15 | */ 16 | redirectURL: function(config) { 17 | return 'https://github.com/robots.txt'; 18 | }, 19 | 20 | /** 21 | * @return {String} Authorization code for fetching the access token 22 | */ 23 | parseAuthorizationCode: function(url) { 24 | var error = url.match(/\?error=(.+)/); 25 | if (error) { 26 | throw 'Error getting authorization code: ' + error[1]; 27 | } 28 | return url.match(/\?code=([\w\/\-]+)/)[1]; 29 | }, 30 | 31 | /** 32 | * @return {URL} URL to the access token providing endpoint 33 | */ 34 | accessTokenURL: function() { 35 | return 'https://github.com/login/oauth/access_token'; 36 | }, 37 | 38 | /** 39 | * @return {String} HTTP method to use to get access tokens 40 | */ 41 | accessTokenMethod: function() { 42 | return 'POST'; 43 | }, 44 | 45 | /** 46 | * @return {Object} The payload to use when getting the access token 47 | */ 48 | accessTokenParams: function(authorizationCode, config) { 49 | return { 50 | code: authorizationCode, 51 | client_id: config.clientId, 52 | client_secret: config.clientSecret, 53 | redirect_uri: this.redirectURL(config), 54 | grant_type: 'authorization_code' 55 | } 56 | }, 57 | 58 | /** 59 | * @return {Object} Object containing accessToken {String}, 60 | * refreshToken {String} and expiresIn {Int} 61 | */ 62 | parseAccessToken: function(response) { 63 | return { 64 | accessToken: response.match(/access_token=([^&]*)/)[1], 65 | expiresIn: Number.MAX_VALUE 66 | } 67 | } 68 | }); 69 | -------------------------------------------------------------------------------- /oauth2/adapters/google.js: -------------------------------------------------------------------------------- 1 | OAuth2.adapter('google', { 2 | authorizationCodeURL: function(config) { 3 | return 'https://accounts.google.com/o/oauth2/auth?\ 4 | client_id={{CLIENT_ID}}&\ 5 | redirect_uri={{REDIRECT_URI}}&\ 6 | scope={{API_SCOPE}}&\ 7 | response_type=code' 8 | .replace('{{CLIENT_ID}}', config.clientId) 9 | .replace('{{REDIRECT_URI}}', this.redirectURL(config)) 10 | .replace('{{API_SCOPE}}', config.apiScope); 11 | }, 12 | 13 | redirectURL: function(config) { 14 | return 'http://www.google.com/robots.txt'; 15 | }, 16 | 17 | parseAuthorizationCode: function(url) { 18 | var error = url.match(/\?error=(.+)/); 19 | if (error) { 20 | throw 'Error getting authorization code: ' + error[1]; 21 | } 22 | return url.match(/\?code=([\w\/\-]+)/)[1]; 23 | }, 24 | 25 | accessTokenURL: function() { 26 | return 'https://accounts.google.com/o/oauth2/token'; 27 | }, 28 | 29 | accessTokenMethod: function() { 30 | return 'POST'; 31 | }, 32 | 33 | accessTokenParams: function(authorizationCode, config) { 34 | return { 35 | code: authorizationCode, 36 | client_id: config.clientId, 37 | client_secret: config.clientSecret, 38 | redirect_uri: this.redirectURL(config), 39 | grant_type: 'authorization_code' 40 | } 41 | }, 42 | 43 | parseAccessToken: function(response) { 44 | var parsedResponse = JSON.parse(response); 45 | return { 46 | accessToken: parsedResponse.access_token, 47 | refreshToken: parsedResponse.refresh_token, 48 | expiresIn: parsedResponse.expires_in 49 | } 50 | } 51 | }); 52 | -------------------------------------------------------------------------------- /oauth2/adapters/sample.js: -------------------------------------------------------------------------------- 1 | OAuth2.adapter('sample', { 2 | /** 3 | * @return {URL} URL to the page that returns the authorization code 4 | */ 5 | authorizationCodeURL: function(config) { 6 | return ''; 7 | }, 8 | 9 | /** 10 | * @return {URL} URL to the page that we use to inject the content 11 | * script into 12 | */ 13 | redirectURL: function(config) { 14 | return ''; 15 | }, 16 | 17 | /** 18 | * @return {String} Authorization code for fetching the access token 19 | */ 20 | parseAuthorizationCode: function(url) { 21 | return ''; 22 | }, 23 | 24 | /** 25 | * @return {URL} URL to the access token providing endpoint 26 | */ 27 | accessTokenURL: function() { 28 | return 'https://accounts.google.com/o/oauth2/token'; 29 | }, 30 | 31 | /** 32 | * @return {String} HTTP method to use to get access tokens 33 | */ 34 | accessTokenMethod: function() { 35 | return 'POST'; 36 | }, 37 | 38 | /** 39 | * @return {Object} The payload to use when getting the access token 40 | */ 41 | accessTokenParams: function(authorizationCode, config) { 42 | return {}; 43 | }, 44 | 45 | /** 46 | * @return {Object} Object containing accessToken {String}, 47 | * refreshToken {String} and expiresIn {Int} 48 | */ 49 | parseAccessToken: function(response) { 50 | return { 51 | accessToken: '', 52 | refreshToken: '', 53 | expiresIn: 0 54 | } 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /oauth2/oauth2.html: -------------------------------------------------------------------------------- 1 | 2 | OAuth 2.0 Finish Page 3 | 5 | 6 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /oauth2/oauth2.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Constructor 3 | * 4 | * @param {String} adapterName name of the adapter to use for this OAuth 2 5 | * @param {Object} config Containing clientId, clientSecret and apiScope 6 | * @param {String} config Alternatively, OAuth2.FINISH for the finish flow 7 | */ 8 | var OAuth2 = function(adapterName, config, callback) { 9 | this.adapterName = adapterName; 10 | var that = this; 11 | OAuth2.loadAdapter(adapterName, function() { 12 | that.adapter = OAuth2.adapters[adapterName]; 13 | if (config == OAuth2.FINISH) { 14 | that.finishAuth(); 15 | return; 16 | } else if (config) { 17 | that.set('clientId', config.client_id); 18 | that.set('clientSecret', config.client_secret); 19 | that.set('apiScope', config.api_scope); 20 | } 21 | }); 22 | }; 23 | 24 | /** 25 | * 26 | */ 27 | OAuth2.FINISH = 'finish'; 28 | 29 | /** 30 | * OAuth 2.0 endpoint adapters known to the library 31 | */ 32 | OAuth2.adapters = {}; 33 | OAuth2.adapterReverse = localStorage.adapterReverse && 34 | JSON.parse(localStorage.adapterReverse) || {}; 35 | 36 | /** 37 | * Opens up an authorization popup window. This starts the OAuth 2.0 flow. 38 | */ 39 | OAuth2.prototype.openAuthorizationCodePopup = function() { 40 | // Create a new tab with the OAuth 2.0 prompt 41 | chrome.tabs.create({url: this.adapter.authorizationCodeURL(this.getConfig())}, 42 | function(tab) { 43 | // 1. user grants permission for the application to access the OAuth 2.0 44 | // endpoint 45 | // 2. the endpoint redirects to the redirect URL. 46 | // 3. the extension injects a script into that redirect URL 47 | // 4. the injected script redirects back to oauth2.html, also passing 48 | // the redirect URL 49 | // 5. oauth2.html uses redirect URL to know what OAuth 2.0 flow to finish 50 | // (if there are multiple OAuth 2.0 adapters) 51 | // 6. Finally, the flow is finished and client code can call 52 | // myAuth.getAccessToken() to get a valid access token. 53 | }); 54 | }; 55 | 56 | /** 57 | * Gets access and refresh (if provided by endpoint) tokens 58 | * 59 | * @param {String} authorizationCode Retrieved from the first step in the process 60 | * @param {Function} callback Called back with 3 params: 61 | * access token, refresh token and expiry time 62 | */ 63 | OAuth2.prototype.getAccessAndRefreshTokens = function(authorizationCode, callback) { 64 | var that = this; 65 | // Make an XHR to get the token 66 | var xhr = new XMLHttpRequest(); 67 | xhr.onreadystatechange = function(event) { 68 | if (xhr.readyState == 4) { 69 | if(xhr.status == 200) { 70 | var obj = that.adapter.parseAccessToken(xhr.responseText); 71 | // Callback with the tokens 72 | callback(obj.accessToken, obj.refreshToken, obj.expiresIn); 73 | } 74 | } 75 | }; 76 | 77 | var method = that.adapter.accessTokenMethod(); 78 | var items = that.adapter.accessTokenParams(authorizationCode, that.getConfig()); 79 | var key = null; 80 | if (method == 'POST') { 81 | var formData = new FormData(); 82 | for (key in items) { 83 | formData.append(key, items[key]); 84 | } 85 | xhr.open(method, that.adapter.accessTokenURL(), true); 86 | xhr.send(formData); 87 | } else if (method == 'GET') { 88 | var url = that.adapter.accessTokenURL(); 89 | var params = '?'; 90 | for (key in items) { 91 | params += key + '=' + items[key] + '&'; 92 | } 93 | xhr.open(method, url + params, true); 94 | xhr.send(); 95 | } else { 96 | throw method + ' is an unknown method'; 97 | } 98 | }; 99 | 100 | /** 101 | * Refreshes the access token using the currently stored refresh token 102 | * Note: this only happens for the Google adapter since all other OAuth 2.0 103 | * endpoints don't implement refresh tokens. 104 | * 105 | * @param {String} refreshToken A valid refresh token 106 | * @param {Function} callback On success, called with access token and expiry time 107 | */ 108 | OAuth2.prototype.refreshAccessToken = function(refreshToken, callback) { 109 | var xhr = new XMLHttpRequest(); 110 | xhr.onreadystatechange = function(event) { 111 | if (xhr.readyState == 4) { 112 | if(xhr.status == 200) { 113 | console.log(xhr.responseText); 114 | // Parse response with JSON 115 | obj = JSON.parse(xhr.responseText); 116 | // Callback with the tokens 117 | callback(obj.access_token, obj.expires_in); 118 | } 119 | } 120 | }; 121 | 122 | var formData = new FormData(); 123 | formData.append('client_id', this.get('clientId')); 124 | formData.append('client_secret', this.get('clientSecret')); 125 | formData.append('refresh_token', this.get('refreshToken')); 126 | formData.append('grant_type', 'refresh_token'); 127 | xhr.open('POST', this.adapter.accessTokenURL(), true); 128 | xhr.send(formData); 129 | }; 130 | 131 | /** 132 | * Extracts authorizationCode from the URL and makes a request to the last 133 | * leg of the OAuth 2.0 process. 134 | */ 135 | OAuth2.prototype.finishAuth = function() { 136 | var that = this; 137 | var authorizationCode = that.adapter.parseAuthorizationCode(window.location.href); 138 | console.log(authorizationCode); 139 | that.getAccessAndRefreshTokens(authorizationCode, function(at, rt, exp) { 140 | that.set('accessToken', at); 141 | that.set('expiresIn', exp); 142 | // Most OAuth 2.0 providers don't have a refresh token 143 | if (rt) { 144 | that.set('refreshToken', rt); 145 | } 146 | that.set('accessTokenDate', (new Date()).valueOf()); 147 | 148 | // Once we get here, close the current tab and we're good to go. 149 | window.open('', '_self', ''); //bug fix 150 | window.close(); 151 | }); 152 | }; 153 | 154 | /** 155 | * @return True iff the current access token has expired 156 | */ 157 | OAuth2.prototype.isAccessTokenExpired = function() { 158 | return (new Date().valueOf() - this.get('accessTokenDate')) > 159 | this.get('expiresIn') * 1000; 160 | }; 161 | 162 | /** 163 | * Wrapper around the localStorage object that gets variables prefixed 164 | * by the adapter name 165 | * 166 | * @param {String} key The key to use for lookup 167 | * @return {String} The value 168 | */ 169 | OAuth2.prototype.get = function(key) { 170 | return localStorage[this.adapterName + '_' + key]; 171 | }; 172 | 173 | /** 174 | * Wrapper around the localStorage object that sets variables prefixed 175 | * by the adapter name 176 | * 177 | * @param {String} key The key to store with 178 | * @param {String} value The value to store 179 | */ 180 | OAuth2.prototype.set = function(key, value) { 181 | localStorage[this.adapterName + '_' + key] = value; 182 | }; 183 | 184 | /** 185 | * The configuration parameters that are passed to the adapter 186 | * 187 | * @returns {Object} Containing clientId, clientSecret and apiScope 188 | */ 189 | OAuth2.prototype.getConfig = function() { 190 | return { 191 | clientId: this.get('clientId'), 192 | clientSecret: this.get('clientSecret'), 193 | apiScope: this.get('apiScope') 194 | } 195 | }; 196 | 197 | /*********************************** 198 | * 199 | * STATIC ADAPTER RELATED METHODS 200 | * 201 | ***********************************/ 202 | 203 | /** 204 | * Loads an OAuth 2.0 adapter and calls back when it's loaded 205 | * 206 | * @param adapterName {String} The name of the JS file 207 | * @param callback {Function} Called as soon as the adapter has been loaded 208 | */ 209 | OAuth2.loadAdapter = function(adapterName, callback) { 210 | // If it's already loaded, don't load it again 211 | if (OAuth2.adapters[adapterName]) { 212 | callback(); 213 | return; 214 | } 215 | var head = document.querySelector('head'); 216 | var script = document.createElement('script'); 217 | script.type = 'text/javascript'; 218 | script.src = '/oauth2/adapters/' + adapterName + '.js'; 219 | script.addEventListener('load', function() { 220 | callback(); 221 | }); 222 | head.appendChild(script); 223 | }; 224 | 225 | /** 226 | * Registers an adapter with the library. This call is used by each adapter 227 | * 228 | * @param {String} name The adapter name 229 | * @param {Object} impl The adapter implementation 230 | * 231 | * @throws {String} If the specified adapter is invalid 232 | */ 233 | OAuth2.adapter = function(name, impl) { 234 | var implementing = 'authorizationCodeURL redirectURL \ 235 | accessTokenURL accessTokenMethod accessTokenParams accessToken'; 236 | 237 | // Check for missing methods 238 | implementing.split(' ').forEach(function(method, index) { 239 | if (!method in impl) { 240 | throw 'Invalid adapter! Missing method: ' + method; 241 | } 242 | }); 243 | 244 | // Save the adapter in the adapter registry 245 | OAuth2.adapters[name] = impl; 246 | // Make an entry in the adapter lookup table 247 | OAuth2.adapterReverse[impl.redirectURL()] = name; 248 | // Store the the adapter lookup table in localStorage 249 | localStorage.adapterReverse = JSON.stringify(OAuth2.adapterReverse); 250 | }; 251 | 252 | /** 253 | * Looks up the adapter name based on the redirect URL. Used by oauth2.html 254 | * in the second part of the OAuth 2.0 flow. 255 | * 256 | * @param {String} url The url that called oauth2.html 257 | * @return The adapter for the current page 258 | */ 259 | OAuth2.lookupAdapterName = function(url) { 260 | var adapterReverse = JSON.parse(localStorage.adapterReverse); 261 | return adapterReverse[url]; 262 | }; 263 | 264 | /*********************************** 265 | * 266 | * PUBLIC API 267 | * 268 | ***********************************/ 269 | 270 | /** 271 | * Authorizes the OAuth authenticator instance. 272 | * 273 | * @param {Function} callback Tries to callback when auth is successful 274 | * Note: does not callback if grant popup required 275 | */ 276 | OAuth2.prototype.authorize = function(callback) { 277 | var that = this; 278 | OAuth2.loadAdapter(that.adapterName, function() { 279 | that.adapter = OAuth2.adapters[that.adapterName]; 280 | if (!that.get('accessToken')) { 281 | // There's no access token yet. Start the authorizationCode flow 282 | that.openAuthorizationCodePopup(); 283 | } else if (that.isAccessTokenExpired()) { 284 | // There's an existing access token but it's expired 285 | if (that.get('refreshToken')) { 286 | that.refreshAccessToken(that.get('refreshToken'), function(at, exp) { 287 | that.set('accessToken', at); 288 | that.set('expiresIn', exp); 289 | that.set('accessTokenDate', (new Date()).valueOf()); 290 | // Callback when we finish refreshing 291 | if (callback) { 292 | callback(); 293 | } 294 | }); 295 | } else { 296 | // No refresh token... just do the popup thing again 297 | that.openAuthorizationCodePopup(); 298 | } 299 | } else { 300 | // We have an access token, and it's not expired yet 301 | if (callback) { 302 | callback(); 303 | } 304 | } 305 | }); 306 | } 307 | 308 | /** 309 | * @returns A valid access token. 310 | */ 311 | OAuth2.prototype.getAccessToken = function() { 312 | return this.get('accessToken'); 313 | }; 314 | -------------------------------------------------------------------------------- /oauth2/oauth2_inject.js: -------------------------------------------------------------------------------- 1 | // This script servers as an intermediary between oauth2.js and 2 | // oauth2.html 3 | 4 | // Get all ? params from this URL 5 | var url = window.location.href; 6 | var params = url.substring(url.indexOf('?')); 7 | 8 | // Also append the current URL to the params 9 | params += '&from=' + escape(url); 10 | 11 | // Redirect back to the extension itself so that we have priveledged 12 | // access again 13 | var redirect = chrome.extension.getURL('oauth2/oauth2.html'); 14 | window.location = redirect + params; 15 | -------------------------------------------------------------------------------- /picasa.js: -------------------------------------------------------------------------------- 1 | var google = new OAuth2('google', { 2 | client_id: '952993494713-h12m6utvq8g8d8et8n2i68plbrr6cr4d.apps.googleusercontent.com', 3 | client_secret: 'IZ4hBSbosuhoWAX4lyAomm-R', 4 | api_scope: 'https://www.googleapis.com/auth/photos' 5 | }); 6 | 7 | var ALBUM_NAME = 'screenshots'; 8 | var CREATE_ALBUM_URL = 'https://picasaweb.google.com/data/feed/api/user/default?alt=json'; 9 | var LIST_ALBUM_URL = 'https://picasaweb.google.com/data/feed/api/user/default?alt=json'; 10 | var CREATE_PHOTO_URL = 'https://picasaweb.google.com/data/feed/api/user/default/albumid/{{albumId}}?alt=json'; 11 | var SHORTENER_URL = 'https://www.googleapis.com/urlshortener/v1/url'; 12 | 13 | function createAlbum(albumName, callback) { 14 | google.authorize(function() { 15 | var xhr = new XMLHttpRequest(); 16 | xhr.open('POST', CREATE_ALBUM_URL, true); 17 | xhr.setRequestHeader('Authorization', 'OAuth ' + google.getAccessToken()); 18 | xhr.setRequestHeader('Content-Type', 'application/json'); 19 | xhr.onload = function(e) { 20 | if (this.status == 200) { 21 | var data = JSON.parse(xhr.responseText); 22 | console.log('created album', data); 23 | if (callback) { 24 | callback(data); 25 | } 26 | } 27 | }; 28 | xhr.send(JSON.stringify({ 29 | displayName: albumName 30 | })); 31 | }); 32 | } 33 | 34 | function getAlbum(albumName, callback) { 35 | google.authorize(function() { 36 | // List all albums and check if any are called albumName 37 | var xhr = new XMLHttpRequest(); 38 | xhr.open('GET', LIST_ALBUM_URL, true); 39 | xhr.setRequestHeader('Authorization', 'OAuth ' + google.getAccessToken()); 40 | xhr.onload = function(e) { 41 | if (this.status == 200) { 42 | var data = JSON.parse(xhr.responseText); 43 | var entries = data.feed.entry; 44 | //console.log('fetched albums', data); 45 | for (var i = 0; i < entries.length; i++) { 46 | var item = entries[i]; 47 | if (item.title.$t == albumName) { 48 | //console.log(albumName); 49 | callback(item); 50 | return; 51 | } 52 | } 53 | callback(); 54 | } 55 | }; 56 | xhr.send(); 57 | }); 58 | } 59 | 60 | function getOrCreateAlbum(albumName, callback) { 61 | var albumId = null; 62 | getAlbum(albumName, function(album) { 63 | if (album) { 64 | albumId = album.gphoto$id.$t; 65 | callback(albumId); 66 | } else { 67 | createAlbum(albumName, function(album) { 68 | albumId = album.id; 69 | callback(albumId); 70 | }); 71 | } 72 | }); 73 | } 74 | 75 | function uploadPhoto(albumId, dataURI, callback, progressCallback) { 76 | google.authorize(function() { 77 | var url = CREATE_PHOTO_URL.replace('{{albumId}}', albumId); 78 | var xhr = new XMLHttpRequest(); 79 | xhr.open('POST', url, true); 80 | xhr.setRequestHeader('Authorization', 'OAuth ' + google.getAccessToken()); 81 | xhr.setRequestHeader('Content-Type', 'image/jpeg'); 82 | xhr.onload = function(e) { 83 | if (this.status == 201) { 84 | var data = JSON.parse(xhr.responseText); 85 | // Get the URL to the raw image 86 | var url = getRawUrl(data); 87 | // Shorten the URL 88 | shortenURL(url, function(shortenedUrl) { 89 | callback({url: shortenedUrl}); 90 | }); 91 | } 92 | }; 93 | if (progressCallback) { 94 | xhr.upload.onprogress = function(e) { 95 | console.log('progress', e.lengthComputable, e.loaded, e.total); 96 | if (e.lengthComputable) { 97 | progressCallback(e.loaded, e.total); 98 | } 99 | }; 100 | } 101 | var array = convertDataURIToBinary(dataURI); 102 | xhr.send(array.buffer); 103 | }); 104 | } 105 | 106 | function shortenURL(url, callback) { 107 | var xhr = new XMLHttpRequest(); 108 | xhr.open('POST', SHORTENER_URL, true); 109 | xhr.setRequestHeader('Content-Type', 'application/json'); 110 | xhr.onload = function(e) { 111 | if (this.status == 200) { 112 | var response = JSON.parse(xhr.responseText); 113 | callback(response.id); 114 | } 115 | }; 116 | xhr.send(JSON.stringify({longUrl: url})); 117 | } 118 | 119 | 120 | var BASE64_MARKER = ';base64,'; 121 | function convertDataURIToBinary(dataURI) { 122 | var base64Index = dataURI.indexOf(BASE64_MARKER) + BASE64_MARKER.length; 123 | var base64 = dataURI.substring(base64Index); 124 | var raw = window.atob(base64); 125 | var rawLength = raw.length; 126 | var array = new Uint8Array(new ArrayBuffer(rawLength)); 127 | 128 | for(i = 0; i < rawLength; i++) { 129 | array[i] = raw.charCodeAt(i); 130 | } 131 | return array; 132 | } 133 | 134 | function getRawUrl(data) { 135 | var links = data.entry.link; 136 | for (var i = 0; i < links.length; i++) { 137 | var link = links[i]; 138 | if (link.type == 'image/jpeg') { 139 | return link.href; 140 | } 141 | } 142 | return null; 143 | } 144 | -------------------------------------------------------------------------------- /playback.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #333; 3 | } 4 | 5 | #container { 6 | width: 980px; 7 | margin: 20px auto; 8 | position: relative; 9 | } 10 | 11 | #image { 12 | width: 100%; 13 | -webkit-box-shadow: 10px 10px 5px #000; 14 | } 15 | 16 | #controls_container { 17 | position: absolute; 18 | bottom: 40px; 19 | width: 100%; 20 | } 21 | 22 | #controls { 23 | background-image: -webkit-gradient( 24 | linear, 25 | left bottom, 26 | left top, 27 | color-stop(0.26, rgba(30,30,30,50)), 28 | color-stop(0.63, rgba(70,70,70,50)) 29 | ); 30 | width: 600px; 31 | margin: 0 auto; 32 | border-radius: 5px; 33 | padding-bottom: 20px; 34 | } 35 | 36 | #share { 37 | float: right; 38 | } 39 | 40 | input[type=range] { 41 | -webkit-appearance: none; 42 | background-color: black; 43 | width: 600px; 44 | margin: 0 auto; 45 | height: 10px; 46 | } 47 | 48 | input[type="range"]::-webkit-slider-thumb { 49 | -webkit-appearance: none; 50 | background-color: silver; 51 | opacity: 0.9; 52 | width: 7px; 53 | height: 20px; 54 | } 55 | 56 | 57 | button { 58 | width: 48px; 59 | height: 48px; 60 | border: 0; 61 | opacity: 1; 62 | background-repeat: no-repeat; 63 | background-size: 32px; 64 | background-position: 8px 8px; 65 | background-color: transparent; 66 | border-radius: 10px; 67 | margin: 5px; 68 | } 69 | button:active { 70 | opacity: 0.5; 71 | } 72 | button:hover { 73 | -webkit-box-shadow: inset 0 0 20px #666; 74 | } 75 | 76 | #playpause { 77 | float: left; 78 | width: 64px; 79 | height: 64px; 80 | background-size: 48px; 81 | margin-left: 263px; 82 | } 83 | 84 | #playpause.play { background-image: url(images/play.png); } 85 | #playpause.pause { background-image: url(images/pause.png); } 86 | #still { background-image: url(images/still.png); } 87 | #video { background-image: url(images/video.png); } 88 | 89 | progress { width: 100% } 90 | 91 | #bottom { 92 | color: #ccc; 93 | font-family: Verdana, sans-serif; 94 | font-weight: bold; 95 | font-size: 16px; 96 | } 97 | #bottom > * { clear: both; display: none; } 98 | #bottom .playback { display: block; } 99 | 100 | #bottom .shared { padding: 15px; padding-bottom: 0; } 101 | #bottom .shared .view { text-align: right; } 102 | #bottom .shared .copy { font-size: 12px; font-weight: normal; } 103 | #bottom .shared .message { float: left; } 104 | 105 | #bottom .upload .progress { 106 | background-color: hsl(100, 57%, 40%); 107 | background-image: -webkit-repeating-linear-gradient(transparent, transparent 50px, rgba(0,0,0,.4) 50px, rgba(0,0,0,.4) 53px, transparent 53px, transparent 63px, rgba(0,0,0,.4) 63px, rgba(0,0,0,.4) 66px, transparent 66px, transparent 116px, rgba(0,0,0,.5) 116px, rgba(0,0,0,.5) 166px, rgba(255,255,255,.2) 166px, rgba(255,255,255,.2) 169px, rgba(0,0,0,.5) 169px, rgba(0,0,0,.5) 179px, rgba(255,255,255,.2) 179px, rgba(255,255,255,.2) 182px, rgba(0,0,0,.5) 182px, rgba(0,0,0,.5) 232px, transparent 232px), 108 | -webkit-repeating-linear-gradient(-35deg, transparent, transparent 2px, rgba(0,0,0,.2) 2px, rgba(0,0,0,.2) 3px, transparent 3px, transparent 5px, rgba(0,0,0,.2) 5px); 109 | 110 | } 111 | -------------------------------------------------------------------------------- /playback.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Web Flow Playback 5 | 6 | 7 | 8 | 9 |
10 | 11 |
12 |
13 | 14 |
15 | 16 | 17 |
18 |
19 |
20 | 21 |
22 | 23 |
 
24 |
25 | 26 |
Message
27 |
28 | 29 |
Press ⌘C to copy.
30 |
31 |
32 |
33 |
34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /playback.js: -------------------------------------------------------------------------------- 1 | function $(id) { return document.querySelector(id); } 2 | 3 | var background = chrome.extension.getBackgroundPage(); 4 | // Images from the screen capture 5 | var images = background.images; 6 | var currentIndex = 0; 7 | // Where to render the image 8 | var $image = $('#image'); 9 | // Playback timer 10 | var timer = null; 11 | 12 | document.addEventListener('DOMContentLoaded', function() { 13 | // Setup listeners for rew, pp, ff and slider 14 | $('#slider').addEventListener('change', function(event) { 15 | var ratio = $('#slider').value / 100; 16 | setIndex(parseInt((images.length - 1) * ratio, 10)); 17 | pause(); 18 | }); 19 | 20 | $('#playpause').addEventListener('click', function(event) { 21 | playpause(); 22 | }); 23 | 24 | $('#still').addEventListener('click', function(event) { 25 | shareStill(); 26 | }); 27 | 28 | $('#video').addEventListener('click', function(event) { 29 | alert('Sorry, video sharing is not implemented yet'); 30 | //shareVideo(); 31 | }); 32 | 33 | document.addEventListener('keydown', function(event) { 34 | if (event.keyCode == 32) { 35 | playpause(); 36 | } 37 | }); 38 | 39 | // Set the first frame 40 | setIndex(currentIndex); 41 | // Set the state to playback 42 | setState('playback'); 43 | }); 44 | 45 | function updateSliderPosition() { 46 | setState('playback'); 47 | var percent = parseInt(currentIndex * 100 / (images.length - 1), 10); 48 | $('#slider').value = percent; 49 | } 50 | 51 | function playpause() { 52 | if (timer) { 53 | pause(); 54 | } else { 55 | play(); 56 | } 57 | } 58 | 59 | function play() { 60 | // Update icon 61 | $('#playpause').className = 'pause'; 62 | // If already at the end, restart 63 | if (currentIndex == images.length - 1) { 64 | setIndex(0); 65 | updateSliderPosition(); 66 | } 67 | // Load images and render them in sequence 68 | timer = setInterval(function() { 69 | if (currentIndex >= images.length - 1) { 70 | pause(); 71 | return; 72 | } 73 | setIndex(currentIndex + 1); 74 | updateSliderPosition(); 75 | }, 1000 / background.FPS); 76 | } 77 | 78 | function pause() { 79 | $('#playpause').className = 'play'; 80 | clearInterval(timer); 81 | timer = null; 82 | } 83 | 84 | function setIndex(index) { 85 | if (index >= images.length) { 86 | console.error('Index out of bounds'); 87 | return; 88 | } 89 | currentIndex = index; 90 | // TODO: validate index 91 | $image.src = images[index]; 92 | } 93 | 94 | function shareVideo() { 95 | setState('upload'); 96 | setProgress(0); 97 | var converter = new ImagesToVideo(background.FPS); 98 | // First, upload all of the images to the server 99 | (function addImage(index) { 100 | if (index < images.length) { 101 | // While there are images left, add them to the converter 102 | var image = images[index]; 103 | converter.addImage(index, image, function() { 104 | addImage(index + 1); 105 | setProgress(100 * index / images.length); 106 | }); 107 | } else { 108 | setProgress(95); 109 | // Once all are gone, call the server 110 | converter.getVideo(function(data) { 111 | setSharedInfo(data.url, 'Screen capture uploaded!', '#'); 112 | setState('shared'); 113 | }); 114 | } 115 | })(0); 116 | } 117 | 118 | function shareStill() { 119 | setState('upload'); 120 | setProgress(0); 121 | // Get the current image 122 | var dataUri = images[currentIndex]; 123 | setProgress(10); 124 | // Upload it 125 | getOrCreateAlbum('screenshot', function(albumId) { 126 | uploadPhoto(albumId, dataUri, function(data) { 127 | setProgress(100); 128 | console.log('upload success', data); 129 | setSharedInfo(data.url, 'Still image uploaded!'); 130 | setState('shared'); 131 | }, function(loaded, total) { 132 | var percent = 10 + loaded / total * 80; 133 | setProgress(percent); 134 | }); 135 | }); 136 | } 137 | 138 | /** 139 | * @param {String} state can be 'playback', 'upload', 'shared' 140 | */ 141 | function setState(state) { 142 | var STATES = ['playback', 'upload', 'shared']; 143 | STATES.forEach(function(s) { 144 | $('#bottom .' + s).style.display = (s == state ? 'block' : 'none'); 145 | }); 146 | } 147 | 148 | /** 149 | * @param {Int} percent goes from 0 to 100. 150 | */ 151 | function setProgress(percent) { 152 | document.querySelector('.upload .progress').style.width = percent + '%'; 153 | } 154 | 155 | /** 156 | * @param {String} url the URL that was shared 157 | * @param {String} message (optional) message to the user 158 | * @param {Object} editUrl (optional) URL to edit the uploaded asset 159 | */ 160 | function setSharedInfo(url, message, editUrl) { 161 | // Set link 162 | var link = $('#bottom .shared .link'); 163 | link.innerText = url; 164 | // If message specified, set it 165 | if (message) { 166 | $('#bottom .shared .message').innerText = message; 167 | } 168 | // If editUrl specified, make sure user can click it 169 | if (editUrl) { 170 | } 171 | // Lastly, select the URL 172 | setTimeout(function() { selectElementContents(link); }, 200); 173 | } 174 | 175 | function selectElementContents(element) { 176 | var range = document.createRange(); 177 | element.focus(); 178 | range.setStart(element.firstChild, 0); 179 | range.setEnd(element.lastChild, element.innerText.length); 180 | var sel = window.getSelection(); 181 | sel.removeAllRanges(); 182 | sel.addRange(range); 183 | } 184 | 185 | -------------------------------------------------------------------------------- /screencast.md: -------------------------------------------------------------------------------- 1 | Future: ScreenCast extension for screen sharing. 2 | 3 | # High level description 4 | 5 | Broadcasting: 6 | 1. Capture screenshots at some frame rate 7 | 2. Compute differences between subsequent frames (pixel-by-pixel, hashing) 8 | 3. Decide which frames are key frames (significantly different) 9 | 4. Send key frames to appengine server 10 | 11 | (V2: attempt to send diff frames) 12 | 13 | Viewing: 14 | 1. Go to the appengine server viewing page 15 | 2. Viewing page polls with XHR (or the channels API) to get new images. 16 | 17 | # Implementation 18 | 19 | Client is mostly done. Just need server. 20 | 21 | ## AppEngine server for image hosting and serving 22 | 23 | V1 API: 24 | 25 | * Create screencast with name 26 | 27 | createScreencast(name) 28 | 29 | * Upload image at time relative to video start: 30 | 31 | uploadImage(screencastId, time) 32 | 33 | * Get image at time for a given screencast: 34 | 35 | getImage(screencastId, time) 36 | 37 | Note: images can be as sparse (every 30 FPS for recordings) or as 38 | not-sparse (variable ~= 1 FPS for presentations) as you want. This 39 | allows having very dynamic videos, or just presentation-like broadcasts. 40 | 41 | ## Player for viewing 'video' 42 | -------------------------------------------------------------------------------- /screenshot.js: -------------------------------------------------------------------------------- 1 | var FPS = 10; 2 | var QUALITY = 50; 3 | 4 | var isRecording = false; 5 | var timer = null; 6 | var images = []; 7 | 8 | function startRecording() { 9 | // Update icon to show that it's recording 10 | chrome.browserAction.setIcon({path: 'images/icon-rec.png'}); 11 | chrome.browserAction.setTitle({title: 'Stop recording.'}); 12 | images = []; 13 | // Set up a timer to regularly get screengrabs 14 | timer = setInterval(function() { 15 | chrome.tabs.captureVisibleTab(null, {quality: QUALITY}, function(img) { 16 | images.push(img); 17 | }); 18 | }, 1000 / FPS); 19 | } 20 | 21 | function stopRecording() { 22 | // Update icon to show regular icon 23 | chrome.browserAction.setIcon({path: 'images/icon.png'}); 24 | chrome.browserAction.setTitle({title: 'Start recording.'}); 25 | // Stop the timer 26 | clearInterval(timer); 27 | // Playback the recorded video 28 | showVideoPlaybackPage(); 29 | } 30 | 31 | function showVideoPlaybackPage() { 32 | var playbackUrl = chrome.extension.getURL('playback.html'); 33 | chrome.tabs.create({url: playbackUrl}); 34 | } 35 | 36 | // Listen for a click on the camera icon. On that click, take a screenshot. 37 | chrome.browserAction.onClicked.addListener(function(tab) { 38 | if (isRecording) { 39 | stopRecording(); 40 | } else { 41 | startRecording(); 42 | } 43 | isRecording = !isRecording; 44 | }); 45 | --------------------------------------------------------------------------------