├── screenshot.png ├── src ├── assets │ ├── icon_128.png │ ├── icon_16.png │ └── icon_32.png ├── mount.html ├── manifest.json ├── mount.css ├── mount.js └── background.js └── README.md /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beaufortfrancois/cloud-storage-chrome-app/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/assets/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beaufortfrancois/cloud-storage-chrome-app/HEAD/src/assets/icon_128.png -------------------------------------------------------------------------------- /src/assets/icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beaufortfrancois/cloud-storage-chrome-app/HEAD/src/assets/icon_16.png -------------------------------------------------------------------------------- /src/assets/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beaufortfrancois/cloud-storage-chrome-app/HEAD/src/assets/icon_32.png -------------------------------------------------------------------------------- /src/mount.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 |
11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloud Storage Chrome OS App 2 | 3 | A simple Chrome OS App that showcases the [`chrome.fileSystemProvider`](https://developer.chrome.com/apps/fileSystemProvider) API by giving you access to [Cloud Storage Buckets](https://cloud.google.com/storage/docs/overview) directly in the Files.app. 4 | 5 | Please, note that it creates a **read-only** mount point at the Files app. To perform the read-write operations, please follow the [Google Cloud Storage documentation](https://cloud.google.com/storage/docs/). 6 | 7 | Get it on the Chrome Web Store at https://chrome.google.com/webstore/detail/ibfbhbegfkamboeglpnianlggahglbfi 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Cloud Storage", 4 | "description": "Access Cloud Storage Buckets in the Files App", 5 | "version": "0.2", 6 | "minimum_chrome_version": "42", 7 | 8 | "icons": { 9 | "16": "assets/icon_16.png", 10 | "32": "assets/icon_32.png", 11 | "128": "assets/icon_128.png" 12 | }, 13 | "permissions": [ 14 | "fileSystemProvider", 15 | "identity", 16 | "storage" 17 | ], 18 | "file_system_provider_capabilities": { 19 | "configurable": false, 20 | "multiple_mounts": true, 21 | "source": "network" 22 | }, 23 | "oauth2": { 24 | "client_id": "1019873396808-onql6al5ijtivb6ngmgboasifbg5r2hs.apps.googleusercontent.com", 25 | "scopes": [ 26 | "https://www.googleapis.com/auth/devstorage.read_only" 27 | ] 28 | }, 29 | "app": { 30 | "background": { 31 | "scripts": ["background.js"] 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/mount.css: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | width: 0px !important; 3 | } 4 | 5 | body { 6 | margin: 0px; 7 | background-color: #4182fa; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | height: 200px; 12 | flex-direction: column; 13 | } 14 | 15 | #form { 16 | flex-direction: row; 17 | display: flex; 18 | } 19 | 20 | #lastBuckets { 21 | width: 300px; 22 | margin-top: 12px; 23 | max-height: 64px; 24 | overflow: overlay; 25 | } 26 | 27 | input, button { 28 | margin: 0 auto; 29 | display: block; 30 | border: 0; 31 | outline: none; 32 | } 33 | 34 | input { 35 | border-radius: 2px; 36 | width: 240px; 37 | line-height: 38px; 38 | border-bottom: 1px solid #555; 39 | padding-left: 12px; 40 | } 41 | 42 | span { 43 | display: block; 44 | margin-bottom: 8px; 45 | color: rgba(255, 255, 255, .7); 46 | } 47 | 48 | button { 49 | border-radius: 0 2px 2px 0; 50 | font-weight: bold; 51 | border-bottom: 1px solid #555; 52 | padding: 1px 12px; 53 | background-color: #444; 54 | color: white; 55 | transition: background-color .1s; 56 | text-shadow: 1px 1px 0 #333; 57 | } 58 | 59 | button:active { 60 | background-color: #888; 61 | } 62 | -------------------------------------------------------------------------------- /src/mount.js: -------------------------------------------------------------------------------- 1 | var input = document.querySelector('input'); 2 | var button = document.querySelector('button'); 3 | 4 | function mount(bucket, callback) { 5 | var options = { fileSystemId: bucket, displayName: 'gs://' + bucket }; 6 | chrome.fileSystemProvider.mount(options, function() { 7 | if (chrome.runtime.lastError) { 8 | console.error(chrome.runtime.lastError); 9 | } 10 | callback(); 11 | }); 12 | } 13 | 14 | function submit() { 15 | var bucket = input.value.trim(); 16 | if (bucket.indexOf('gs://') === 0) { 17 | bucket = bucket.substr('gs://'.length); 18 | } 19 | if (!bucket) { 20 | return; 21 | } 22 | mount(bucket, function() { 23 | getLastBuckets(function(lastBuckets) { 24 | if (lastBuckets.length === 0 || 25 | bucket !== lastBuckets[0]) { 26 | lastBuckets.unshift(bucket); 27 | } 28 | setLastBuckets(lastBuckets, function() { 29 | window.close(); 30 | }); 31 | }); 32 | }); 33 | } 34 | 35 | button.addEventListener('click', submit); 36 | input.addEventListener('keyup', function(event) { 37 | if (event.keyCode === 13) { 38 | submit(); 39 | } 40 | }); 41 | 42 | function submitLastBucket(event) { 43 | var index = 0; 44 | var bucket = 'gs://' + event.target.textContent; 45 | var type = function() { 46 | input.value = input.value + bucket[index]; 47 | index++; 48 | if (index === bucket.length) { 49 | submit(); 50 | } else { 51 | requestAnimationFrame(type); 52 | } 53 | } 54 | type(); 55 | } 56 | 57 | function getLastBuckets(callback) { 58 | chrome.storage.local.get('lastBuckets', function(data) { 59 | var lastBuckets = data.lastBuckets || []; 60 | callback(lastBuckets); 61 | }); 62 | } 63 | 64 | function setLastBuckets(lastBuckets, callback) { 65 | chrome.storage.local.set({'lastBuckets': lastBuckets}, callback); 66 | } 67 | 68 | getLastBuckets(function(lastBuckets) { 69 | lastBuckets.forEach(function(bucket) { 70 | var row = document.createElement('span'); 71 | row.textContent = bucket; 72 | row.addEventListener('click', submitLastBucket); 73 | document.querySelector('#lastBuckets').appendChild(row); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | // Helper function to get an authentication token. 2 | function getAuthToken(successCallback, errorCallback) { 3 | chrome.identity.getAuthToken({ 'interactive': true }, function(token) { 4 | if (chrome.runtime.lastError) { 5 | errorCallback(); 6 | } else { 7 | successCallback(token); 8 | } 9 | }); 10 | } 11 | 12 | // Helper function to send an authorized request and receive a JSON response. 13 | function request(url, successCallback, errorCallback) { 14 | getAuthToken(function(token) { 15 | var xhr = new XMLHttpRequest(); 16 | xhr.open('GET', url); 17 | xhr.onloadend = function() { 18 | if (xhr.status === 200) { 19 | successCallback(xhr.response); 20 | } else if (xhr.status === 401) { 21 | // Removed cached token and try again. 22 | chrome.identity.removeCachedAuthToken({ token: token }, function() { 23 | request(url, successCallback, errorCallback); 24 | }); 25 | } else { 26 | errorCallback(); 27 | } 28 | } 29 | xhr.setRequestHeader('Authorization', 'Bearer ' + token); 30 | xhr.responseType = 'json'; 31 | xhr.send(); 32 | }, errorCallback); 33 | } 34 | 35 | // Helper function to sanitize metadata. 36 | function sanitizeMetadata(object, options) { 37 | function sanitize(metadata) { 38 | metadata.modificationTime = new Date(metadata.modificationTime); 39 | if (options) { 40 | if (!options.name) { delete metadata.name; } 41 | if (!options.thumbnail) { delete(metadata.thumbnail); } 42 | if (!options.size) { delete(metadata.size); } 43 | if (!options.mimeType) { delete(metadata.mimeType); } 44 | if (!options.modificationTime) { delete(metadata.modificationTime); } 45 | if (!options.isDirectory) { delete(metadata.isDirectory); } 46 | } 47 | return metadata; 48 | } 49 | if (object instanceof Array) { 50 | return object.map(sanitize) 51 | } else { 52 | return sanitize(object); 53 | } 54 | 55 | } 56 | 57 | // Get cloud storage object media link URL. 58 | function getObjectMediaLink(bucket, object, successCallback, errorCallback) { 59 | var url = 'https://www.googleapis.com/storage/v1/b/' + bucket + 60 | '/o/' + encodeURIComponent(object) + '?fields=mediaLink'; 61 | request(url, successCallback, errorCallback); 62 | } 63 | 64 | // Get cloud storage object list that starts with a prefix. 65 | function getObjectsList(bucket, prefix, successCallback, errorCallback) { 66 | var url = 'https://www.googleapis.com/storage/v1/b/' + bucket + '/o/' + 67 | '?delimiter=%2F' + 68 | '&fields=' + encodeURIComponent('items(name,size,updated,contentType),prefixes') + 69 | '&prefix=' + (prefix ? encodeURIComponent(prefix) : ''); 70 | request(url, successCallback, errorCallback); 71 | } 72 | 73 | function onGetMetadataRequested(options, onSuccess, onError) { 74 | console.log('onGetMetadataRequested', options.entryPath); 75 | 76 | if (options.entryPath === '/') { 77 | // Return static root entry. 78 | var root = {isDirectory: true, name: '', size: 0, modificationTime: new Date()}; 79 | onSuccess(sanitizeMetadata(root, options)); 80 | return; 81 | } 82 | 83 | var bucket = options.fileSystemId; 84 | var prefix = options.entryPath.substr(1); // Removes starting slash. 85 | getObjectsList(bucket, prefix, function(response) { 86 | var entry = null; 87 | if (response.items) { 88 | for (var item of response.items) { 89 | if (item.name === prefix) { 90 | var entryName = item.name; 91 | if (entryName.lastIndexOf('/') >= 0) { 92 | entryName = entryName.substring(entryName.lastIndexOf('/')+1); 93 | } 94 | // Return a file entry. 95 | entry = { 96 | 'isDirectory': false, 97 | 'name': entryName, 98 | 'size': parseInt(item.size, 10), 99 | 'modificationTime': new Date(item.updated), 100 | 'mimeType': item.contentType 101 | }; 102 | break; 103 | } 104 | } 105 | } else if (response.prefixes) { 106 | // Return a directory entry. 107 | entry = { 108 | 'isDirectory': true, 109 | 'name': prefix, 110 | 'size': 0, 111 | 'modificationTime': new Date() 112 | }; 113 | } 114 | if (entry) { 115 | onSuccess(sanitizeMetadata(entry, options)); 116 | } else { 117 | onError('NOT_FOUND'); 118 | } 119 | }, function() { 120 | onError('FAILED'); 121 | }); 122 | } 123 | 124 | function onReadDirectoryRequested(options, onSuccess, onError) { 125 | console.log('onReadDirectoryRequested', options.directoryPath); 126 | 127 | var bucket = options.fileSystemId; 128 | var prefix = ''; 129 | if (options.directoryPath !== '/') { 130 | prefix = options.directoryPath.substr(1) + '/'; 131 | } 132 | getObjectsList(bucket, prefix, function(response) { 133 | var entries = []; 134 | if (response.items) { 135 | // Add all files in this directory. 136 | for (var item of response.items) { 137 | if (item.name === prefix) { 138 | // Skip folder... 139 | continue; 140 | } 141 | entries.push({ 142 | 'isDirectory': false, 143 | 'name': item.name.substr(prefix.length), 144 | 'size': parseInt(item.size, 10), 145 | 'modificationTime': new Date(item.updated), 146 | 'mimeType': item.contentType 147 | }); 148 | } 149 | } 150 | if (response.prefixes) { 151 | // Add all directories in this directory. 152 | for (var item of response.prefixes) { 153 | entries.push({ 154 | 'isDirectory': true, 155 | 'name': item.substr(prefix.length).slice(0, -1), 156 | 'size': 0, 157 | 'modificationTime': new Date() 158 | }); 159 | } 160 | } 161 | onSuccess(sanitizeMetadata(entries, options), false /* last call */); 162 | }, function() { 163 | onError('FAILED'); 164 | }); 165 | } 166 | 167 | // A map with currently opened files. As key it has requestId of 168 | // openFileRequested and as a value the file path. 169 | var openedFiles = {}; 170 | 171 | function onOpenFileRequested(options, onSuccess, onError) { 172 | console.log('onOpenFileRequested', options); 173 | if (options.mode != 'READ' || options.create) { 174 | onError('INVALID_OPERATION'); 175 | } else { 176 | openedFiles[options.requestId] = options.filePath; 177 | onSuccess(); 178 | } 179 | } 180 | 181 | function onReadFileRequested(options, onSuccess, onError) { 182 | console.log('onReadFileRequested', options); 183 | 184 | var bucket = options.fileSystemId; 185 | var filePath = openedFiles[options.openRequestId].substr(1); 186 | getObjectMediaLink(bucket, filePath, function(response) { 187 | getAuthToken(function(token) { 188 | var xhr = new XMLHttpRequest(); 189 | xhr.open('GET', response.mediaLink); 190 | xhr.responseType = 'arraybuffer'; 191 | xhr.setRequestHeader('Authorization', 'Bearer ' + token); 192 | xhr.setRequestHeader('Range', 'bytes=' + options.offset + '-' + 193 | (options.length + options.offset - 1)); 194 | xhr.onloadend = function() { 195 | if (xhr.status === 206) { 196 | onSuccess(xhr.response, false /* last call */); 197 | } else if (xhr.status === 416) { 198 | // There's nothing more... 199 | onSuccess(new ArrayBuffer(), false /* last call */); 200 | } else { 201 | onError('NOT_FOUND'); 202 | } 203 | }; 204 | xhr.send(); 205 | }, function() { 206 | onError('ACCESS_DENIED'); 207 | }); 208 | }, function() { 209 | onError('NOT_FOUND'); 210 | }); 211 | } 212 | 213 | function onCloseFileRequested(options, onSuccess, onError) { 214 | console.log('onCloseFileRequested', options); 215 | if (!openedFiles[options.openRequestId]) { 216 | onError('INVALID_OPERATION'); 217 | return; 218 | } 219 | 220 | delete openedFiles[options.openRequestId]; 221 | onSuccess(); 222 | } 223 | 224 | function onUnmountRequested(options, onSuccess, onError) { 225 | console.log('onUnmountRequested', options); 226 | onSuccess(); 227 | chrome.fileSystemProvider.unmount({ fileSystemId: options.fileSystemId }); 228 | } 229 | 230 | function onMountRequested(onSuccess, onError) { 231 | console.log('onMountRequested'); 232 | onSuccess(); 233 | showMountWindow(); 234 | } 235 | 236 | function showMountWindow() { 237 | chrome.app.window.create('mount.html', { 238 | id: 'mount-window', 239 | resizable: false, 240 | frame: { color: "#4182fa" }, 241 | innerBounds: { width: 420, height: 200, } 242 | }); 243 | } 244 | 245 | chrome.fileSystemProvider.onGetMetadataRequested.addListener(onGetMetadataRequested); 246 | chrome.fileSystemProvider.onReadDirectoryRequested.addListener(onReadDirectoryRequested); 247 | chrome.fileSystemProvider.onOpenFileRequested.addListener(onOpenFileRequested); 248 | chrome.fileSystemProvider.onReadFileRequested.addListener(onReadFileRequested); 249 | chrome.fileSystemProvider.onCloseFileRequested.addListener(onCloseFileRequested); 250 | chrome.fileSystemProvider.onUnmountRequested.addListener(onUnmountRequested); 251 | chrome.fileSystemProvider.onMountRequested && chrome.fileSystemProvider.onMountRequested.addListener(onMountRequested); 252 | 253 | chrome.app.runtime.onLaunched.addListener(showMountWindow); 254 | --------------------------------------------------------------------------------