├── ski ├── bg.png ├── objects.png └── player.png ├── surf ├── bg.png ├── player.png └── objects.png ├── icons ├── icon-16.png ├── icon-32.png ├── icon-114.png ├── icon-128.png └── icon-256.png ├── register-sw.js ├── js ├── surf-error-reporting.js ├── base-error-reporting.js ├── strings.m.js └── load_time_data.m.js ├── offline.js ├── appmanifest.json ├── .github └── workflows │ └── static.yml ├── README.md ├── index.html ├── favicon.svg ├── css └── interface.css └── sw.js /ski/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yell0wsuit/ms-edge-letssurf/HEAD/ski/bg.png -------------------------------------------------------------------------------- /surf/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yell0wsuit/ms-edge-letssurf/HEAD/surf/bg.png -------------------------------------------------------------------------------- /ski/objects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yell0wsuit/ms-edge-letssurf/HEAD/ski/objects.png -------------------------------------------------------------------------------- /ski/player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yell0wsuit/ms-edge-letssurf/HEAD/ski/player.png -------------------------------------------------------------------------------- /surf/player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yell0wsuit/ms-edge-letssurf/HEAD/surf/player.png -------------------------------------------------------------------------------- /icons/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yell0wsuit/ms-edge-letssurf/HEAD/icons/icon-16.png -------------------------------------------------------------------------------- /icons/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yell0wsuit/ms-edge-letssurf/HEAD/icons/icon-32.png -------------------------------------------------------------------------------- /surf/objects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yell0wsuit/ms-edge-letssurf/HEAD/surf/objects.png -------------------------------------------------------------------------------- /icons/icon-114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yell0wsuit/ms-edge-letssurf/HEAD/icons/icon-114.png -------------------------------------------------------------------------------- /icons/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yell0wsuit/ms-edge-letssurf/HEAD/icons/icon-128.png -------------------------------------------------------------------------------- /icons/icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yell0wsuit/ms-edge-letssurf/HEAD/icons/icon-256.png -------------------------------------------------------------------------------- /register-sw.js: -------------------------------------------------------------------------------- 1 | if ('serviceWorker' in navigator) { 2 | navigator.serviceWorker.register('sw.js'); 3 | } -------------------------------------------------------------------------------- /js/surf-error-reporting.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) Microsoft Corporation. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | hookErrorReporting('webui-surf-game'); -------------------------------------------------------------------------------- /offline.js: -------------------------------------------------------------------------------- 1 | {"version":1.7,"fileList":["appmanifest.json","favicon.svg","index.html","register-sw.js","sw.js","css/interface.css","js/base-error-reporting.js","js/lib_common.chunk.js","js/lib_react.chunk.js","js/load_time_data.m.js","js/strings.m.js","js/surf.bundle.js","js/surf-error-reporting.js","surf/bg.png","surf/objects.png","surf/player.png","ski/bg.png","ski/objects.png","ski/player.png","icons/icon-114.png","icons/icon-128.png","icons/icon-16.png","icons/icon-256.png","icons/icon-32.png"]} -------------------------------------------------------------------------------- /js/base-error-reporting.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) Microsoft Corporation. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | function hookErrorReporting(component) { 5 | window.onerror = (message, source, lineno, columnNumber, error) => { 6 | const errorInfo = { 7 | column: columnNumber, 8 | component, 9 | line: lineno, 10 | message: error.message, 11 | name: error.name, 12 | source_url: source, 13 | stack: error.stack 14 | }; 15 | chrome.errorReporting.reportError(errorInfo); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /appmanifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Let's Surf", 3 | "short_name": "Let's Surf", 4 | "description": "An offline game from Microsoft Edge", 5 | "version": "1.7", 6 | "start_url": "index.html", 7 | "display": "fullscreen", 8 | "orientation": "any", 9 | "background_color": "#ffffff", 10 | "icons": [ 11 | { 12 | "src": "icons/icon-16.png", 13 | "sizes": "16x16", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "icons/icon-32.png", 18 | "sizes": "32x32", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "icons/icon-114.png", 23 | "sizes": "114x114", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "icons/icon-128.png", 28 | "sizes": "128x128", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "icons/icon-256.png", 33 | "sizes": "256x256", 34 | "type": "image/png" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v5 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v3 38 | with: 39 | # Upload entire repository 40 | path: '.' 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Microsoft Edge's *Let's Surf* + *Let's Ski* 2 | The *Let's Surf* + *Let's Ski* game from Microsoft Edge. 3 | 4 | **NEW**: Check out [Edge Surf 2](https://github.com/yell0wsuit/ms-edge-surf-2), a newly updated surf game in 2025 that features more extensive character customization, new game mode, remastered graphics, and more! 5 | 6 | *Also check out [Chrome Dino](https://github.com/yell0wsuit/chrome-dino-enhanced) that saves your highscore.* 7 | 8 |

9 | 10 |

11 | 12 | ## Play 13 | Play online at https://yell0wsuit.page/assets/games/edge-surf/index.html 14 | 15 | For offline play, go to the website and install this game as a web app (easiest method). Check the install button in the address bar of the browser. 16 | On iOS, you need to use Safari and add the page to the homescreen. 17 | On Chrome Android, a notification may appear at the bottom of the screen. 18 | 19 | ## Features 20 | - New code from Microsoft Edge v104.0.1293.47. 21 | - Added new theme, "Let's Ski" from v97. Also added a drop-down menu to change between 2 themes. 22 | - Added a new character, Linux Tux. 23 | - 3 game modes. 24 | - Keyboard, mouse, touch and controller support. 25 | - Use ``localstorage`` for storing high score instead of ``chrome.send``, which is not present for client-side. 26 | - PWA support. You can install this game as an app to play offline. 27 | - Mobile support. The game is responsive to most screen sizes. 28 | - Show touch instructions for touch screens as default. 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Let's Surf 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 25 | 26 | 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /js/strings.m.js: -------------------------------------------------------------------------------- 1 | import {loadTimeData} from './load_time_data.m.js'; 2 | loadTimeData.data = {"bestScore":"Best $1 $2 $3","bestScoreFlyout":"Best score: $1","bestScoreMenuDisplay":"$1 $2","cancel":"Cancel","close":"Close","code":"Cheat code activated!","codeCheat":"Cheat code activated! $1","codeScoring":"Scoring turned off for this round.","congrats":"You beat $1% of global players!","endlessBestScore":-1,"endlessDescriptor":"distance","endlessModeTitleInline":"Endless:","endlessTitle":"Endless","endlessUnit":"m","fontfamily":"system-ui, sans-serif","fontsize":"75%","gameCreditsButton":"Game credits","gameCreditsDialogTitle":"Game credits","gameMode":"Game mode changed to $1","gameModeSelectLabel":"Game mode","gameModeSelectLabelDisabled":"Game mode (start new game to change)","gameSettings":"Game settings","highVisibilityActive":false,"highVisiblityModeToggleLabel":"High visibility","howToPlayButton":"How to play","howToPlayControllerBoost":"Press RT to use your speed boost","howToPlayControllerMovement":"Use the thumbstick or D-pad to steer","howToPlayControllerRefresh":"Press LB+RB to restart the game","howToPlayDialogTitle":"How to play","howToPlayEndless":"Travel as far as you can and rescue a friend for help with enemies","howToPlayKeyboardBoost":"Press `F` to use your speed boost","howToPlayKeyboardMovement":"Use the arrow or `WASD` keys to steer","howToPlayKeyboardRefresh":"Refresh the page to restart the game","howToPlayMouseBoost":"Right-click to use your speed boost","howToPlayMouseMovement":"Move the mouse to steer","howToPlayMouseRefresh":"Refresh the page to restart the game","howToPlayTimeTrial":"Reach the end as fast as you can and collect coins to subtract time","howToPlayTouchBoost":"Swipe down twice to use your speed boost","howToPlayTouchMovement":"Swipe or tap to steer","howToPlayTouchRefresh":"Refresh the page to restart the game","howToPlayZigZag":"Pass through as many gates as you can in a row","isDebugMode":false,"isLinuxTuxEnabled":true,"isNewShare":false,"is_windows_xbox_sku":"false","keyboardAction":"spacebar","language":"en","lastSelectedMode":"endless","lastSelectedPlayer":1,"lastSelectedTheme":"surf","menuInfo":"to start playing","mouseAction":"double click","newBestScore":"New best $1 $2 $3","newGame":"Start new game","off":"Off","on":"On","overInfo":"to play again","overTitle":"Nice try!","pauseInfo":"to resume playing","pauseTitle":"Game paused","reducedSpeedActive":false,"reducedSpeedModeToggleLabel":"Reduced speed","resetAllStats":"Reset all stats","resetAllStatsDialogText":"Are you sure you want to reset all your game stats, including your high scores?","resetAllStatsDialogTitle":"Reset all stats","share":"Share with a friend","shareButtonString":"Share","shareCopy":"Copy","shareLinkCopied":"Copied!","shareLinkFlyout":"https://go.microsoft.com/fwlink/?linkid=2162539","shareLinkGameOver":"https://go.microsoft.com/fwlink/?linkid=2168605","shareNewLinkMobile":"https://microsoftedgewelcome.microsoft.com/emmx/edge-surf","shareString":"I discovered a super fun game, let's surf together!","skiTheme":"Let's ski","specialThanks":"Special thanks:","startShare":"Start Share","surfTheme":"Let's surf","textdirection":"ltr","theme":"Theme changed to $1","themeSelectLabel":"Theme","timetrialBestScore":-1,"timetrialDescriptor":"time","timetrialModeTitleInline":"Time trial:","timetrialTitle":"Time trial","timetrialUnit":"s","touchAction":"double tap","zigzagBestScore":-1,"zigzagDescriptor":"streak","zigzagModeTitleInline":"Zig zag:","zigzagTitle":"Zig zag","zigzagUnit":"gates"}; -------------------------------------------------------------------------------- /favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /js/load_time_data.m.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2012 The Chromium Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | /** 6 | * @fileoverview This file defines a singleton which provides access to all data 7 | * that is available as soon as the page's resources are loaded (before DOM 8 | * content has finished loading). This data includes both localized strings and 9 | * any data that is important to have ready from a very early stage (e.g. things 10 | * that must be displayed right away). 11 | * 12 | * Note that loadTimeData is not guaranteed to be consistent between page 13 | * refreshes (https://crbug.com/740629) and should not contain values that might 14 | * change if the page is re-opened later. 15 | */ 16 | 17 | /** @type {!LoadTimeData} */ 18 | // eslint-disable-next-line no-var 19 | export var loadTimeData; 20 | 21 | class LoadTimeData { 22 | constructor() { 23 | /** @type {?Object} */ 24 | this.data_ = null; 25 | } 26 | 27 | /** 28 | * Sets the backing object. 29 | * 30 | * Note that there is no getter for |data_| to discourage abuse of the form: 31 | * 32 | * var value = loadTimeData.data()['key']; 33 | * 34 | * @param {Object} value The de-serialized page data. 35 | */ 36 | set data(value) { 37 | expect(!this.data_, 'Re-setting data.'); 38 | this.data_ = value; 39 | } 40 | 41 | /** 42 | * @param {string} id An ID of a value that might exist. 43 | * @return {boolean} True if |id| is a key in the dictionary. 44 | */ 45 | valueExists(id) { 46 | return this.data_ ? id in this.data_ : false; 47 | } 48 | 49 | /** 50 | * Fetches a value, expecting that it exists. 51 | * @param {string} id The key that identifies the desired value. 52 | * @return {*} The corresponding value. 53 | */ 54 | getValue(id) { 55 | expect(this.data_, 'No data. Did you remember to include strings.js?'); 56 | const value = this.data_[id]; 57 | expect(typeof value !== 'undefined', 'Could not find value for ' + id); 58 | return value; 59 | } 60 | 61 | /** 62 | * As above, but also makes sure that the value is a string. 63 | * @param {string} id The key that identifies the desired string. 64 | * @return {string} The corresponding string value. 65 | */ 66 | getString(id) { 67 | const value = this.getValue(id); 68 | expectIsType(id, value, 'string'); 69 | return /** @type {string} */ (value); 70 | } 71 | 72 | /** 73 | * Returns a formatted localized string where $1 to $9 are replaced by the 74 | * second to the tenth argument. 75 | * @param {string} id The ID of the string we want. 76 | * @param {...(string|number)} var_args The extra values to include in the 77 | * formatted output. 78 | * @return {string} The formatted string. 79 | */ 80 | getStringF(id, var_args) { 81 | const value = this.getString(id); 82 | if (!value) { 83 | return ''; 84 | } 85 | 86 | const args = Array.prototype.slice.call(arguments); 87 | args[0] = value; 88 | return this.substituteString.apply(this, args); 89 | } 90 | 91 | /** 92 | * Returns a formatted localized string where $1 to $9 are replaced by the 93 | * second to the tenth argument. Any standalone $ signs must be escaped as 94 | * $$. 95 | * @param {string} label The label to substitute through. 96 | * This is not an resource ID. 97 | * @param {...(string|number)} var_args The extra values to include in the 98 | * formatted output. 99 | * @return {string} The formatted string. 100 | */ 101 | substituteString(label, var_args) { 102 | const varArgs = arguments; 103 | return label.replace(/\$(.|$|\n)/g, function(m) { 104 | expect(m.match(/\$[$1-9]/), 'Unescaped $ found in localized string.'); 105 | return m === '$$' ? '$' : varArgs[m[1]]; 106 | }); 107 | } 108 | 109 | /** 110 | * Returns a formatted string where $1 to $9 are replaced by the second to 111 | * tenth argument, split apart into a list of pieces describing how the 112 | * substitution was performed. Any standalone $ signs must be escaped as $$. 113 | * @param {string} label A localized string to substitute through. 114 | * This is not an resource ID. 115 | * @param {...(string|number)} var_args The extra values to include in the 116 | * formatted output. 117 | * @return {!Array} The formatted 118 | * string pieces. 119 | */ 120 | getSubstitutedStringPieces(label, var_args) { 121 | const varArgs = arguments; 122 | // Split the string by separately matching all occurrences of $1-9 and of 123 | // non $1-9 pieces. 124 | const pieces = (label.match(/(\$[1-9])|(([^$]|\$([^1-9]|$))+)/g) || 125 | []).map(function(p) { 126 | // Pieces that are not $1-9 should be returned after replacing $$ 127 | // with $. 128 | if (!p.match(/^\$[1-9]$/)) { 129 | expect( 130 | (p.match(/\$/g) || []).length % 2 === 0, 131 | 'Unescaped $ found in localized string.'); 132 | return {value: p.replace(/\$\$/g, '$'), arg: null}; 133 | } 134 | 135 | // Otherwise, return the substitution value. 136 | return {value: varArgs[p[1]], arg: p}; 137 | }); 138 | 139 | return pieces; 140 | } 141 | 142 | /** 143 | * As above, but also makes sure that the value is a boolean. 144 | * @param {string} id The key that identifies the desired boolean. 145 | * @return {boolean} The corresponding boolean value. 146 | */ 147 | getBoolean(id) { 148 | const value = this.getValue(id); 149 | expectIsType(id, value, 'boolean'); 150 | return /** @type {boolean} */ (value); 151 | } 152 | 153 | /** 154 | * As above, but also makes sure that the value is an integer. 155 | * @param {string} id The key that identifies the desired number. 156 | * @return {number} The corresponding number value. 157 | */ 158 | getInteger(id) { 159 | const value = this.getValue(id); 160 | expectIsType(id, value, 'number'); 161 | expect(value === Math.floor(value), 'Number isn\'t integer: ' + value); 162 | return /** @type {number} */ (value); 163 | } 164 | 165 | /** 166 | * Override values in loadTimeData with the values found in |replacements|. 167 | * @param {Object} replacements The dictionary object of keys to replace. 168 | */ 169 | overrideValues(replacements) { 170 | expect( 171 | typeof replacements === 'object', 172 | 'Replacements must be a dictionary object.'); 173 | for (const key in replacements) { 174 | this.data_[key] = replacements[key]; 175 | } 176 | } 177 | 178 | /** 179 | * Reset loadTimeData's data. Should only be used in tests. 180 | * @param {?Object} newData The data to restore to, when null restores to 181 | * unset state. 182 | */ 183 | resetForTesting(newData = null) { 184 | this.data_ = newData; 185 | } 186 | 187 | /** 188 | * @return {boolean} Whether loadTimeData.data has been set. 189 | */ 190 | isInitialized() { 191 | return this.data_ !== null; 192 | } 193 | } 194 | 195 | /** 196 | * Checks condition, throws error message if expectation fails. 197 | * @param {*} condition The condition to check for truthiness. 198 | * @param {string} message The message to display if the check fails. 199 | */ 200 | function expect(condition, message) { 201 | if (!condition) { 202 | throw new Error( 203 | 'Unexpected condition on ' + document.location.href + ': ' + message); 204 | } 205 | } 206 | 207 | /** 208 | * Checks that the given value has the given type. 209 | * @param {string} id The id of the value (only used for error message). 210 | * @param {*} value The value to check the type on. 211 | * @param {string} type The type we expect |value| to be. 212 | */ 213 | function expectIsType(id, value, type) { 214 | expect( 215 | typeof value === type, '[' + value + '] (' + id + ') is not a ' + type); 216 | } 217 | 218 | expect(!loadTimeData, 'should only include this file once'); 219 | loadTimeData = new LoadTimeData(); 220 | 221 | // Expose |loadTimeData| directly on |window|, since within a JS module the 222 | // scope is local and not all files have been updated to import the exported 223 | // |loadTimeData| explicitly. 224 | window.loadTimeData = loadTimeData; 225 | 226 | -------------------------------------------------------------------------------- /css/interface.css: -------------------------------------------------------------------------------- 1 | /** Copyright (C) Microsoft Corporation. All rights reserved. 2 | * Use of this source code is governed by a BSD-style license that can be 3 | * found in the LICENSE file. 4 | */ 5 | 6 | /* minimal css reset */ 7 | html, body, div, h1, h2, h3, h4, h5, h6, a, p, img, ul, li, button { padding: 0; margin: 0; border: 0; outline: 0; } 8 | 9 | html, body { 10 | width: 100vw; /* fullscreen */ 11 | height: 100vh; /* fullscreen */ 12 | overflow: hidden; 13 | user-select: none; 14 | font-family: system-ui, sans-serif; 15 | font-size: 16px; 16 | color: #000000; 17 | touch-action: pinch-zoom; /* prevents navigating back with one finger */ 18 | } 19 | 20 | /* game layers fill entire page */ 21 | #game-canvas, #game-bg, #game-gradient, #game-ui, #game-tint { 22 | overflow: hidden; 23 | position: absolute; 24 | user-select: none; 25 | width: 100vw; 26 | height: 100vh; 27 | top: 0; 28 | left: 0; 29 | } 30 | 31 | /* from edge */ 32 | #hamburger-container { 33 | overflow: visible; 34 | position: absolute; 35 | top: 10px; 36 | right: 8px; 37 | } 38 | 39 | html[dir="rtl"] #hamburger-container { 40 | right: unset; 41 | left: 10px; 42 | } 43 | 44 | html[dir="rtl"].icon-arrow-right, 45 | html[dir="rtl"].icon-arrow-left { 46 | /* undo RTL */ 47 | transform: scale(-1, 1); 48 | } 49 | 50 | #modal-root { 51 | position: fixed; 52 | } 53 | 54 | 55 | 56 | /* page structure */ 57 | 58 | #game-gradient { 59 | z-index: 1; 60 | pointer-events: none; 61 | } 62 | 63 | @media (forced-colors:active) { 64 | /* CanvasText, LinkText, VisitedText, ActiveText, GrayText, Field, FieldText, HighlightText, Highlight (txt bg), ButtonText, ButtonFace (btn bg) */ 65 | #game-bg { background: Canvas !important; } 66 | } 67 | 68 | #game-bg { 69 | background-repeat: repeat; 70 | image-rendering: pixelated; /* important */ 71 | image-rendering: crisp-edges; 72 | image-rendering: -moz-crisp-edges; 73 | image-rendering: -webkit-crisp-edges; 74 | z-index: 2; 75 | pointer-events: none; 76 | } 77 | 78 | #game-canvas { 79 | image-rendering: pixelated; /* important */ 80 | image-rendering: crisp-edges; 81 | image-rendering: -moz-crisp-edges; 82 | image-rendering: -webkit-crisp-edges; 83 | z-index: 3; 84 | } 85 | 86 | /* overlay */ 87 | 88 | #game-tint { 89 | pointer-events: none; 90 | z-index: 4; 91 | background: linear-gradient(180deg, rgba(255,255,255,0.6) 0%, rgba(255,255,255,0.1) 100%); 92 | transition: opacity 0.35s, visibility 0s 0.35s; 93 | transition-timing-function: cubic-bezier(0.30, 0.20, 0.20, 1.00); 94 | visibility: hidden; 95 | opacity: 0; 96 | backdrop-filter: blur(0px); 97 | -webkit-backdrop-filter: blur(0px); 98 | } 99 | 100 | #game-tint.visible { 101 | pointer-events: inherit; 102 | transition: opacity 0.35s; /* instant visibility */ 103 | visibility: visible; 104 | opacity: 1; 105 | backdrop-filter: blur(4px); 106 | -webkit-backdrop-filter: blur(4px); 107 | } 108 | 109 | /* main game ui */ 110 | 111 | #game-ui { 112 | width: calc(100vw - 32px); /* fullscreen minus 32 padding */ 113 | height: calc(100vh - 32px); /* fullscreen minus 32 padding */ 114 | padding: 16px; 115 | z-index: 5; 116 | } 117 | 118 | #ui-title, 119 | #ui-subtitle { 120 | width: calc(100vw - 32px); /* fullscreen minus 32 padding */ 121 | font-weight: bold; 122 | text-align: center; 123 | text-transform: uppercase; 124 | } 125 | 126 | #ui-title { 127 | margin-top: calc(10vh - 40px); 128 | line-height: 48px; 129 | font-size: 48px; 130 | } 131 | 132 | #ui-subtitle { 133 | margin-top: 10px; 134 | line-height: 24px; 135 | font-size: 24px; 136 | } 137 | 138 | /* shrinks title for super small screens */ 139 | @media screen and (max-height: 480px) { 140 | #ui-title { line-height: 36px; font-size: 36px; } 141 | #ui-subtitle { line-height: 20px; font-size: 20px; } 142 | } 143 | 144 | /* hides mode title on tiny screens */ 145 | @media screen and (max-height: 360px) { 146 | #ui-subtitle { visibility: hidden; } 147 | } 148 | 149 | /* hides game title on tiny screens */ 150 | @media screen and (max-height: 280px) { 151 | #ui-title { visibility: hidden; } 152 | } 153 | 154 | #ui-selector { 155 | position: absolute; 156 | top: calc(40vh - 64px); 157 | left: calc(50vw - 240px); 158 | width: 480px; /* fullscreen minus 32 padding */ 159 | height: 128px; 160 | display: flex; 161 | justify-content: space-between; 162 | align-items: center; 163 | } 164 | 165 | #ui-selector span { 166 | border-radius: 4px; 167 | background-color: rgba(255,255,255,0); 168 | transition: background-color 0.1s; 169 | } 170 | 171 | #ui-selector span:hover { 172 | background: rgba(255,255,255,0.4); 173 | cursor: pointer; 174 | } 175 | 176 | /* shrink menu arrows on narrow screens */ 177 | @media screen and (max-width: 520px) { 178 | #ui-selector { left: calc(50vw - 220px); width: 440px; } 179 | } 180 | 181 | /* hide menu arrows on narrow screens */ 182 | @media screen and (max-width: 420px) { 183 | #ui-selector span { visibility: hidden; } 184 | } 185 | 186 | #ui-container { 187 | display: inline-block; 188 | } 189 | 190 | #view-container { 191 | position: absolute; 192 | top: calc(100vh * 0.55 - 32px); /* minus half height */ 193 | width: calc(100vw - 32px); /* fullscreen minus 32 padding */ 194 | text-align: center; 195 | } 196 | 197 | #ui-instruct { 198 | position: relative; 199 | /* top: calc(100vh * 0.6 - 32px); /* minus half height */ 200 | /* width: calc(100vw - 32px); /* fullscreen minus 32 padding */ 201 | text-align: center; 202 | } 203 | 204 | #instruct-content { 205 | padding: 16px 12px; 206 | } 207 | 208 | #instruct-content.tinted { 209 | background-color: rgba(255,255,255,0.6); 210 | box-shadow: 0px 16px 24px rgba(0,0,0,0.16); 211 | border-radius: 4px; 212 | } 213 | 214 | @supports not (-moz-appearance:none) { 215 | #instruct-content.tinted { 216 | backdrop-filter: blur(32px); 217 | -webkit-backdrop-filter: blur(32px); 218 | } 219 | } /* Backdrop filter is broken on Firefox */ 220 | 221 | #instruct-icon { /* xbox + ps */ 222 | display: inline-block; 223 | vertical-align: middle; 224 | width: 32px; 225 | height: 32px; 226 | } 227 | 228 | #instruct-action { /* spacebar, double tap, double click */ 229 | display: inline-block; 230 | margin: 0 8px; 231 | vertical-align: middle; 232 | } 233 | 234 | #instruct-action.outline { /* spacebar, double tap, double click */ 235 | padding: 4px 12px 5px; 236 | border: 2px solid #000000; 237 | border-radius: 4px; 238 | font-size: 20px; 239 | font-weight: bold; 240 | text-transform: uppercase; 241 | } 242 | 243 | #instruct-text { 244 | display: inline-block; 245 | margin: 0 8px; 246 | vertical-align: middle; 247 | font-size: 24px; 248 | font-weight: 600; 249 | } 250 | 251 | #ui-notify { 252 | position: absolute; 253 | top: calc(100vh * 0.75 - 24px); 254 | width: calc(100vw - 32px); /* fullscreen minus 32 padding */ 255 | text-align: center; 256 | transition: opacity 0.5s, visibility 0s 0.5s; 257 | transition-timing-function: cubic-bezier(0.30, 0.20, 0.20, 1.00); 258 | visibility: hidden; 259 | opacity: 0; 260 | } 261 | 262 | #ui-notify.visible { 263 | transition: opacity 0.35s; /* instant visibility */ 264 | visibility: visible; 265 | opacity: 1; 266 | } 267 | 268 | #notify-content { 269 | display: inline-block; 270 | padding: 12px 20px; 271 | font-weight: 600; 272 | border-radius: 4px; 273 | color: #FFFFFF; 274 | background-color: rgba(0,0,0,0.7); 275 | backdrop-filter: blur(32px); 276 | -webkit-backdrop-filter: blur(32px); 277 | } 278 | 279 | #notify-shareLink { 280 | display: inline-flex; 281 | vertical-align: -33%; 282 | align-items: center; 283 | font-weight: normal; 284 | background-color: rgba(255,255,255,0.2); 285 | padding: 4px 8px 2px; 286 | margin-inline-start: 12px; 287 | border-radius: 4px; 288 | cursor: pointer; 289 | color: currentColor; 290 | } 291 | 292 | #notify-shareLink:hover { 293 | background-color: rgba(255,255,255,0.3); 294 | } 295 | 296 | #notify-shareText { 297 | } 298 | 299 | /* game stats and ui */ 300 | 301 | #ui-dash { 302 | position: relative; 303 | width: 100%; 304 | margin: 0 auto; 305 | } 306 | 307 | #dash-stats { 308 | display: flex; 309 | width: 280px; 310 | margin: 0 auto; 311 | } 312 | 313 | #stats-score, 314 | .stats-icons { 315 | display: flex; 316 | justify-content: center; 317 | height: 22px; 318 | flex-basis: 136px; 319 | } 320 | 321 | .stats-icons { 322 | flex-basis: 72px; 323 | image-rendering: pixelated; /* important */ 324 | image-rendering: crisp-edges; 325 | image-rendering: -moz-crisp-edges; 326 | image-rendering: -webkit-crisp-edges; 327 | } 328 | 329 | .stats-icons div { 330 | width: 22px; 331 | height: 22px; 332 | margin-inline-end: 2px; 333 | background-size: 1920px 512px; 334 | } 335 | 336 | .life-full { background-position: -1px -1px; } 337 | .life-empty { background-position: -1px -25px; } 338 | .boost-full { background-position: -25px -1px; } 339 | .boost-empty { background-position: -25px -25px; } 340 | .shield { background-position: -49px -1px; } 341 | .infinite { background-position: -49px -25px; } 342 | 343 | #score-icon { 344 | display: inline-block; 345 | vertical-align: middle; 346 | height: 22px; 347 | margin-inline-end: 4px; 348 | } 349 | 350 | #score-text { 351 | vertical-align: middle; 352 | font-size: 16px; 353 | font-weight: bold; 354 | line-height: 22px; 355 | } 356 | 357 | #ui-share { 358 | position: relative; 359 | text-align: center; 360 | margin-top: 10px; 361 | visibility: hidden; 362 | } 363 | 364 | #ui-share.visible { 365 | visibility: visible; 366 | } 367 | 368 | #share-content { 369 | padding: 12px 12px; 370 | } 371 | 372 | #share-content.tinted { 373 | background-color: rgba(255,255,255,0.6); 374 | backdrop-filter: blur(32px); 375 | -webkit-backdrop-filter: blur(32px); 376 | box-shadow: 0px 16px 24px rgba(0,0,0,0.16); 377 | border-radius: 4px; 378 | } 379 | 380 | #share-best-score { 381 | font-size: 12px; 382 | line-height: 14px; 383 | text-align: center; 384 | color: #8E8E8E; 385 | } 386 | 387 | #share-new-score { 388 | text-align: center; 389 | font-weight: 500; 390 | font-size: 30px; 391 | line-height: 32px; 392 | align-items: center; 393 | color: #000000; 394 | } 395 | 396 | #share-text { 397 | margin-top: 4px; 398 | text-align: center; 399 | font-style: normal; 400 | font-weight: 500; 401 | font-size: 14px; 402 | line-height: 16px; 403 | color: #000000; 404 | } 405 | 406 | #share-action { 407 | margin-top: 10px; 408 | width: 113px; 409 | height: 32px; 410 | background: #000000; 411 | box-sizing: border-box; 412 | border-radius: 4px; 413 | } 414 | 415 | #share-action-container { 416 | display: inline-block; 417 | margin-top: 2px; 418 | } 419 | 420 | #share-action-container > svg { 421 | vertical-align: top; 422 | } 423 | 424 | #share-action-container > svg > path { 425 | fill: #FFFFFF; 426 | } 427 | 428 | #share-action-text { 429 | font-style: normal; 430 | font-size: 18px; 431 | color: #FFFFFF; 432 | margin-left: 7.5px; 433 | font-weight: 550; 434 | } 435 | 436 | .icon-star { 437 | vertical-align: top; 438 | } 439 | 440 | .icon-fill { 441 | fill: #000000; 442 | } 443 | 444 | #ui-notify .icon-fill { 445 | fill: #FFFFFF; 446 | } 447 | 448 | @media (forced-colors:active) { 449 | /* CanvasText, LinkText, VisitedText, ActiveText, GrayText, Field, FieldText, HighlightText, Highlight (txt bg), ButtonText, ButtonFace (btn bg) */ 450 | .icon-fill { fill: CanvasText !important; } 451 | } -------------------------------------------------------------------------------- /sw.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const OFFLINE_DATA_FILE = "offline.js"; 4 | const CACHE_NAME_PREFIX = "edge-surf"; 5 | const BROADCASTCHANNEL_NAME = "offline"; 6 | const CONSOLE_PREFIX = "[SW] "; 7 | const LAZYLOAD_KEYNAME = ""; 8 | 9 | // Create a BroadcastChannel if supported. 10 | const broadcastChannel = (typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(BROADCASTCHANNEL_NAME)); 11 | 12 | ////////////////////////////////////// 13 | // Utility methods 14 | function PostBroadcastMessage(o) 15 | { 16 | if (!broadcastChannel) 17 | return; // not supported 18 | 19 | // Impose artificial (and arbitrary!) delay of 3 seconds to make sure client is listening by the time the message is sent. 20 | // Note we could remove the delay on some messages, but then we create a race condition where sometimes messages can arrive 21 | // in the wrong order (e.g. "update ready" arrives before "started downloading update"). So to keep the consistent ordering, 22 | // delay all messages by the same amount. 23 | setTimeout(() => broadcastChannel.postMessage(o), 3000); 24 | }; 25 | 26 | function Broadcast(type) 27 | { 28 | PostBroadcastMessage({ 29 | "type": type 30 | }); 31 | }; 32 | 33 | function BroadcastDownloadingUpdate(version) 34 | { 35 | PostBroadcastMessage({ 36 | "type": "downloading-update", 37 | "version": version 38 | }); 39 | } 40 | 41 | function BroadcastUpdateReady(version) 42 | { 43 | PostBroadcastMessage({ 44 | "type": "update-ready", 45 | "version": version 46 | }); 47 | } 48 | 49 | function IsUrlInLazyLoadList(url, lazyLoadList) 50 | { 51 | if (!lazyLoadList) 52 | return false; // presumably lazy load list failed to load 53 | 54 | try { 55 | for (const lazyLoadRegex of lazyLoadList) 56 | { 57 | if (new RegExp(lazyLoadRegex).test(url)) 58 | return true; 59 | } 60 | } 61 | catch (err) 62 | { 63 | console.error(CONSOLE_PREFIX + "Error matching in lazy-load list: ", err); 64 | } 65 | 66 | return false; 67 | }; 68 | 69 | function WriteLazyLoadListToStorage(lazyLoadList) 70 | { 71 | if (typeof localforage === "undefined") 72 | return Promise.resolve(); // bypass if localforage not imported 73 | else 74 | return localforage.setItem(LAZYLOAD_KEYNAME, lazyLoadList) 75 | }; 76 | 77 | function ReadLazyLoadListFromStorage() 78 | { 79 | if (typeof localforage === "undefined") 80 | return Promise.resolve([]); // bypass if localforage not imported 81 | else 82 | return localforage.getItem(LAZYLOAD_KEYNAME); 83 | }; 84 | 85 | function GetCacheBaseName() 86 | { 87 | // Include the scope to avoid name collisions with any other SWs on the same origin. 88 | // e.g. "c3offline-https://example.com/foo/" (won't collide with anything under bar/) 89 | return CACHE_NAME_PREFIX + "-" + self.registration.scope; 90 | }; 91 | 92 | function GetCacheVersionName(version) 93 | { 94 | // Append the version number to the cache name. 95 | // e.g. "c3offline-https://example.com/foo/-v2" 96 | return GetCacheBaseName() + "-v" + version; 97 | }; 98 | 99 | // Return caches.keys() filtered down to just caches we're interested in (with the right base name). 100 | // This filters out caches from unrelated scopes. 101 | async function GetAvailableCacheNames() 102 | { 103 | const cacheNames = await caches.keys(); 104 | const cacheBaseName = GetCacheBaseName(); 105 | return cacheNames.filter(n => n.startsWith(cacheBaseName)); 106 | }; 107 | 108 | // Identify if an update is pending, which is the case when we have 2 or more available caches. 109 | // One must be an update that is waiting, since the next navigate that does an upgrade will 110 | // delete all the old caches leaving just one currently-in-use cache. 111 | async function IsUpdatePending() 112 | { 113 | const availableCacheNames = await GetAvailableCacheNames(); 114 | return (availableCacheNames.length >= 2); 115 | }; 116 | 117 | // Automatically deduce the main page URL (e.g. index.html or main.aspx) from the available browser windows. 118 | // This prevents having to hard-code an index page in the file list, implicitly caching it like AppCache did. 119 | async function GetMainPageUrl() 120 | { 121 | const allClients = await clients.matchAll({ 122 | includeUncontrolled: true, 123 | type: "window" 124 | }); 125 | 126 | for (const c of allClients) 127 | { 128 | // Parse off the scope from the full client URL, e.g. https://example.com/index.html -> index.html 129 | let url = c.url; 130 | if (url.startsWith(self.registration.scope)) 131 | url = url.substring(self.registration.scope.length); 132 | 133 | if (url && url !== "/") // ./ is also implicitly cached so don't bother returning that 134 | { 135 | // If the URL is solely a search string, prefix it with / to ensure it caches correctly. 136 | // e.g. https://example.com/?foo=bar needs to cache as /?foo=bar, not just ?foo=bar. 137 | if (url.startsWith("?")) 138 | url = "/" + url; 139 | 140 | return url; 141 | } 142 | } 143 | 144 | return ""; // no main page URL could be identified 145 | }; 146 | 147 | // Fetch optionally bypassing HTTP cache using fetch cache options 148 | function fetchWithBypass(request, bypassCache) 149 | { 150 | if (typeof request === "string") 151 | request = new Request(request); 152 | 153 | if (bypassCache) 154 | { 155 | return fetch(request.url, { 156 | method: 'GET', 157 | headers: request.headers, 158 | mode: request.mode == 'navigate' ? 'cors' : request.mode, 159 | credentials: request.credentials, 160 | redirect: request.redirect 161 | }); 162 | } 163 | else 164 | { 165 | // bypass disabled: perform normal fetch which is allowed to return from HTTP cache 166 | return fetch(request); 167 | } 168 | }; 169 | 170 | // Effectively a cache.addAll() that only creates the cache on all requests being successful (as a weak attempt at making it atomic) 171 | // and can optionally cache-bypass with fetchWithBypass in every request 172 | async function CreateCacheFromFileList(cacheName, fileList, bypassCache) 173 | { 174 | // Kick off all requests and wait for them all to complete 175 | const responses = await Promise.all(fileList.map(url => fetchWithBypass(url, bypassCache))); 176 | 177 | // Check if any request failed. If so don't move on to opening the cache. 178 | // This makes sure we only open a cache if all requests succeeded. 179 | let allOk = true; 180 | 181 | for (const response of responses) 182 | { 183 | if (!response.ok) 184 | { 185 | allOk = false; 186 | console.error(CONSOLE_PREFIX + "Error fetching '" + response.url + "' (" + response.status + " " + response.statusText + ")"); 187 | } 188 | } 189 | 190 | if (!allOk) 191 | throw new Error("not all resources were fetched successfully"); 192 | 193 | // Can now assume all responses are OK. Open a cache and write all responses there. 194 | // TODO: ideally we can do this transactionally to ensure a complete cache is written as one atomic operation. 195 | // This needs either new transactional features in the spec, or at the very least a way to rename a cache 196 | // (so we can write to a temporary name that won't be returned by GetAvailableCacheNames() and then rename it when ready). 197 | const cache = await caches.open(cacheName); 198 | 199 | try { 200 | return await Promise.all(responses.map( 201 | (response, i) => cache.put(fileList[i], response) 202 | )); 203 | } 204 | catch (err) 205 | { 206 | // Not sure why cache.put() would fail (maybe if storage quota exceeded?) but in case it does, 207 | // clean up the cache to try to avoid leaving behind an incomplete cache. 208 | console.error(CONSOLE_PREFIX + "Error writing cache entries: ", err); 209 | caches.delete(cacheName); 210 | throw err; 211 | } 212 | }; 213 | 214 | async function UpdateCheck(isFirst) 215 | { 216 | try { 217 | // Always bypass cache when requesting offline.js to make sure we find out about new versions. 218 | const response = await fetchWithBypass(OFFLINE_DATA_FILE, true); 219 | 220 | if (!response.ok) 221 | throw new Error(OFFLINE_DATA_FILE + " responded with " + response.status + " " + response.statusText); 222 | 223 | const data = await response.json(); 224 | 225 | const version = data.version; 226 | const fileList = data.fileList; 227 | const lazyLoadList = data.lazyLoad; 228 | const currentCacheName = GetCacheVersionName(version); 229 | 230 | const cacheExists = await caches.has(currentCacheName); 231 | 232 | // Don't recache if there is already a cache that exists for this version. Assume it is complete. 233 | if (cacheExists) 234 | { 235 | // Log whether we are up-to-date or pending an update. 236 | const isUpdatePending = await IsUpdatePending(); 237 | if (isUpdatePending) 238 | { 239 | console.log(CONSOLE_PREFIX + "Update pending"); 240 | Broadcast("update-pending"); 241 | } 242 | else 243 | { 244 | console.log(CONSOLE_PREFIX + "Up to date"); 245 | Broadcast("up-to-date"); 246 | } 247 | return; 248 | } 249 | 250 | // Implicitly add the main page URL to the file list, e.g. "index.html", so we don't have to assume a specific name. 251 | const mainPageUrl = await GetMainPageUrl(); 252 | 253 | // Prepend the main page URL to the file list if we found one and it is not already in the list. 254 | // Also make sure we request the base / which should serve the main page. 255 | fileList.unshift("./"); 256 | 257 | if (mainPageUrl && fileList.indexOf(mainPageUrl) === -1) 258 | fileList.unshift(mainPageUrl); 259 | 260 | console.log(CONSOLE_PREFIX + "Caching " + fileList.length + " files for offline use"); 261 | 262 | if (isFirst) 263 | Broadcast("downloading"); 264 | else 265 | BroadcastDownloadingUpdate(version); 266 | 267 | // Note we don't bypass the cache on the first update check. This is because SW installation and the following 268 | // update check caching will race with the normal page load requests. For any normal loading fetches that have already 269 | // completed or are in-flight, it is pointless and wasteful to cache-bust the request for offline caching, since that 270 | // forces a second network request to be issued when a response from the browser HTTP cache would be fine. 271 | if (lazyLoadList) 272 | await WriteLazyLoadListToStorage(lazyLoadList); // dump lazy load list to local storage# 273 | 274 | await CreateCacheFromFileList(currentCacheName, fileList, !isFirst); 275 | const isUpdatePending = await IsUpdatePending(); 276 | 277 | if (isUpdatePending) 278 | { 279 | console.log(CONSOLE_PREFIX + "All resources saved, update ready"); 280 | BroadcastUpdateReady(version); 281 | } 282 | else 283 | { 284 | console.log(CONSOLE_PREFIX + "All resources saved, offline support ready"); 285 | Broadcast("offline-ready"); 286 | } 287 | } 288 | catch (err) 289 | { 290 | // Update check fetches fail when we're offline, but in case there's any other kind of problem with it, log a warning. 291 | console.warn(CONSOLE_PREFIX + "Update check failed: ", err); 292 | } 293 | }; 294 | 295 | self.addEventListener("install", event => 296 | { 297 | // On install kick off an update check to cache files on first use. 298 | // If it fails we can still complete the install event and leave the SW running, we'll just 299 | // retry on the next navigate. 300 | event.waitUntil( 301 | UpdateCheck(true) // first update 302 | .catch(() => null) 303 | ); 304 | }); 305 | 306 | async function GetCacheNameToUse(availableCacheNames, doUpdateCheck) 307 | { 308 | // Prefer the oldest cache available. This avoids mixed-version responses by ensuring that if a new cache 309 | // is created and filled due to an update check while the page is running, we keep returning resources 310 | // from the original (oldest) cache only. 311 | if (availableCacheNames.length === 1 || !doUpdateCheck) 312 | return availableCacheNames[0]; 313 | 314 | // We are making a navigate request with more than one cache available. Check if we can expire any old ones. 315 | const allClients = await clients.matchAll(); 316 | 317 | // If there are other clients open, don't expire anything yet. We don't want to delete any caches they 318 | // might be using, which could cause mixed-version responses. 319 | if (allClients.length > 1) 320 | return availableCacheNames[0]; 321 | 322 | // Identify newest cache to use. Delete all the others. 323 | const latestCacheName = availableCacheNames[availableCacheNames.length - 1]; 324 | console.log(CONSOLE_PREFIX + "Updating to new version"); 325 | 326 | await Promise.all( 327 | availableCacheNames.slice(0, -1) 328 | .map(c => caches.delete(c)) 329 | ); 330 | 331 | return latestCacheName; 332 | }; 333 | 334 | async function HandleFetch(event, doUpdateCheck) 335 | { 336 | const availableCacheNames = await GetAvailableCacheNames(); 337 | 338 | // No caches available: go to network 339 | if (!availableCacheNames.length) 340 | return fetch(event.request); 341 | 342 | const useCacheName = await GetCacheNameToUse(availableCacheNames, doUpdateCheck); 343 | const cache = await caches.open(useCacheName); 344 | const cachedResponse = await cache.match(event.request); 345 | 346 | if (cachedResponse) 347 | return cachedResponse; // use cached response 348 | 349 | // We need to check if this request is to be lazy-cached. Send the request and load the lazy-load list 350 | // from storage simultaneously. 351 | const result = await Promise.all([fetch(event.request), ReadLazyLoadListFromStorage()]); 352 | const fetchResponse = result[0]; 353 | const lazyLoadList = result[1]; 354 | 355 | if (IsUrlInLazyLoadList(event.request.url, lazyLoadList)) 356 | { 357 | // Handle failure writing to the cache. This can happen if the storage quota is exceeded, which is particularly 358 | // likely in Safari 11.1, which appears to have very tight storage limits. Make sure even in the event of an error 359 | // we continue to return the response from the fetch. 360 | try { 361 | // Note clone response since we also respond with it 362 | await cache.put(event.request, fetchResponse.clone()); 363 | } 364 | catch (err) 365 | { 366 | console.warn(CONSOLE_PREFIX + "Error caching '" + event.request.url + "': ", err); 367 | } 368 | } 369 | 370 | return fetchResponse; 371 | }; 372 | 373 | self.addEventListener("fetch", event => 374 | { 375 | /** NOTE (iain) 376 | * This check is to prevent a bug with XMLHttpRequest where if its 377 | * proxied with "FetchEvent.prototype.respondWith" no upload progress 378 | * events are triggered. By returning we allow the default action to 379 | * occur instead. Currently all cross-origin requests fall back to default. 380 | */ 381 | if (new URL(event.request.url).origin !== location.origin) 382 | return; 383 | 384 | // Check for an update on navigate requests 385 | const doUpdateCheck = (event.request.mode === "navigate"); 386 | 387 | const responsePromise = HandleFetch(event, doUpdateCheck); 388 | 389 | if (doUpdateCheck) 390 | { 391 | // allow the main request to complete, then check for updates 392 | event.waitUntil( 393 | responsePromise 394 | .then(() => UpdateCheck(false)) // not first check 395 | ); 396 | } 397 | 398 | event.respondWith(responsePromise); 399 | }); --------------------------------------------------------------------------------