├── .dockerignore ├── .empty ├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── codeql.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── Makefile ├── README.md ├── assets ├── images │ ├── great_and_powerful.xcf │ ├── screenshot.jpg │ ├── slideshow.webp │ └── slideshow.xcf └── notes │ ├── command_notes │ └── splines ├── build ├── entitlements.mac.plist ├── icon.png ├── images │ ├── app-icon.icns │ ├── app-icon.ico │ └── icon256-dev.png ├── linux.Dockerfile └── scripts │ └── notarize.js ├── https └── .gitignore ├── images ├── mac-trayicon.png ├── mac-trayicon@2x.png ├── win-trayicon.png └── win-trayicon@2x.png ├── package.json ├── pages ├── analysis-manifest.json ├── analysis-settings.html ├── analysis.html ├── athletes.html ├── browser-source-settings.html ├── browser-source.html ├── chat-manifest.json ├── chat-settings.html ├── chat.html ├── confirm-dialog.html ├── deps │ ├── .gitignore │ └── Makefile ├── eula.html ├── events-manifest.json ├── events-settings.html ├── events.html ├── file-replay.html ├── fonts │ ├── MaterialSymbolsRounded.woff2 │ ├── PermanentMarker.woff2 │ └── Saira.woff2 ├── game-control-manifest.json ├── game-control-settings.html ├── game-control.html ├── gauge.html ├── gauges │ ├── cadence-manifest.json │ ├── cadence.html │ ├── draft-manifest.json │ ├── draft.html │ ├── hr-manifest.json │ ├── hr.html │ ├── pace-manifest.json │ ├── pace.html │ ├── power-manifest.json │ ├── power.html │ ├── settings.html │ ├── wbal-manifest.json │ └── wbal.html ├── geo-manifest.json ├── geo-settings.html ├── geo.html ├── groups-manifest.json ├── groups-settings.html ├── groups.html ├── images │ ├── become_a_patron_button@2x.png │ ├── blankavatar.png │ ├── examples │ │ ├── elevation-profile-chart.webp │ │ ├── power-zones-horiz-chart.png │ │ ├── power-zones-pie-chart.png │ │ ├── power-zones-vert-chart.png │ │ └── sauce-line-chart-cap-50pct.png │ ├── fa │ │ ├── bolt-duotone.svg │ │ ├── caret-left-regular.svg │ │ ├── caret-right-regular.svg │ │ ├── cog-duotone.svg │ │ ├── flag-checkered-duotone.svg │ │ ├── heartbeat-duotone.svg │ │ ├── plus-square-duotone.svg │ │ ├── search-minus-duotone.svg │ │ ├── search-plus-duotone.svg │ │ ├── solar-system-duotone.svg │ │ ├── stopwatch-regular.svg │ │ ├── tachometer-duotone.svg │ │ ├── times-circle-duotone.svg │ │ ├── undo-regular.svg │ │ ├── user-circle-solid.svg │ │ └── wind-duotone.svg │ ├── favicon.png │ ├── great_and_powerful.webp │ ├── great_and_powerful_bubble.webp │ ├── icon128.png │ ├── icon16.png │ ├── icon19.png │ ├── icon256.png │ ├── icon32.png │ ├── icon38.png │ ├── icon48.png │ ├── icon64.png │ ├── logo_horiz_128x48.png │ ├── logo_horiz_320x120.png │ ├── logo_horiz_64x24.png │ ├── logo_vert_120x320.png │ ├── logo_vert_32x85.png │ ├── logo_vert_48x128.png │ ├── logo_vert_96x256.png │ ├── map-fade-mask.png │ ├── mod-store-badge.webp │ ├── ranking │ │ ├── cat1-darkbg.png │ │ ├── cat1.png │ │ ├── cat2-darkbg.png │ │ ├── cat2.png │ │ ├── cat3-darkbg.png │ │ ├── cat3.png │ │ ├── cat4-darkbg.png │ │ ├── cat4.png │ │ ├── cat5-darkbg.png │ │ ├── cat5.png │ │ ├── pro-darkbg.png │ │ ├── pro.png │ │ ├── world-tour-darkbg.png │ │ └── world-tour.png │ ├── smart_trainer.svg │ └── zp_logo.png ├── index.html ├── logs.html ├── nearby-manifest.json ├── nearby-settings.html ├── nearby.html ├── non-patron.html ├── overview-manifest.json ├── overview-settings.html ├── overview.html ├── patron-a51.html ├── patron-checking.html ├── patron-success.html ├── patron-waiting.html ├── patron.html ├── profile-avatar.html ├── profile.html ├── release-notes.html ├── scss │ ├── _charts.scss │ ├── _color.scss │ ├── analysis.scss │ ├── animations.scss │ ├── athletes.scss │ ├── browser-source.scss │ ├── chat.scss │ ├── common.scss │ ├── elevation.scss │ ├── events.scss │ ├── expandable-table.scss │ ├── file-replay.scss │ ├── game-control.scss │ ├── gauge.scss │ ├── geo.scss │ ├── groups.scss │ ├── logs.scss │ ├── map.scss │ ├── nearby.scss │ ├── overview-settings.scss │ ├── overview.scss │ ├── profile.scss │ ├── profile_tpl.scss │ ├── segments.scss │ ├── stats-for-nerds.scss │ ├── themes.scss │ ├── watching.scss │ └── welcome.scss ├── segments-settings.html ├── segments.html ├── sounds │ ├── great_and_powerful.ogg │ └── soundtweet.ogg ├── src │ ├── analysis.mjs │ ├── athletes.mjs │ ├── browser-source.mjs │ ├── charts.mjs │ ├── chat.mjs │ ├── color.mjs │ ├── common.mjs │ ├── custom-elements.mjs │ ├── echarts-sauce-theme.mjs │ ├── elevation.mjs │ ├── events.mjs │ ├── fields.mjs │ ├── file-replay.mjs │ ├── game-control.mjs │ ├── gauge.mjs │ ├── geo.mjs │ ├── groups.mjs │ ├── logs.mjs │ ├── map.mjs │ ├── nearby.mjs │ ├── overview.mjs │ ├── preloads.js │ ├── profile.mjs │ ├── segments.mjs │ ├── sentry.js │ ├── stats-for-nerds.mjs │ ├── watching.mjs │ └── welcome.mjs ├── stats-for-nerds.html ├── system-message.html ├── templates │ ├── analysis │ │ ├── activity-summary.html.tpl │ │ ├── laps.html.tpl │ │ ├── main.html.tpl │ │ ├── peak-efforts.html.tpl │ │ ├── segment-results.html.tpl │ │ ├── segments.html.tpl │ │ └── selection-stats.html.tpl │ ├── athlete-cards.html.tpl │ ├── events │ │ ├── details.html.tpl │ │ ├── list.html.tpl │ │ ├── subgroup.html.tpl │ │ └── summary.html.tpl │ ├── profile.html.tpl │ ├── segment-results.html.tpl │ └── watching-screen-layout.html.tpl ├── update.html ├── watching-manifest.json ├── watching-settings.html ├── watching.html ├── welcome.html ├── zwift-login.html └── zwift-monitor-login.html ├── shared ├── curves.mjs ├── deps │ ├── .gitignore │ └── Makefile ├── report.mjs └── sauce │ ├── base.mjs │ ├── browser.mjs │ ├── data.mjs │ ├── geo.mjs │ ├── index.mjs │ ├── locale.mjs │ ├── pace.mjs │ ├── perf.mjs │ ├── power.mjs │ └── template.mjs ├── src ├── app.mjs ├── argparse.mjs ├── db.mjs ├── env.mjs ├── fs-safe.js ├── headless.mjs ├── loader.js ├── logging.js ├── main.mjs ├── menu.mjs ├── mime.mjs ├── mods-core.mjs ├── mods.mjs ├── patreon.mjs ├── preload │ ├── common.js │ ├── patron-link.js │ ├── storage-proxy.js │ ├── webview.js │ └── zwift-login.js ├── rpc.mjs ├── secrets.mjs ├── stats.mjs ├── storage.mjs ├── unzoom.mjs ├── webserver.mjs ├── windows.mjs ├── zwift.mjs └── zwift.proto ├── test ├── .eslintrc.json ├── curves.test.mjs ├── lru.test.mjs └── wbal.test.mjs └── tools └── bin ├── buildenv ├── jsonminify ├── lintwatch └── watch /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | assets 4 | -------------------------------------------------------------------------------- /.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/.empty -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | pages/src/sentry.js 2 | shared/deps/segments.mjs 3 | shared/deps/routes.mjs 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["eslint-plugin-html"], 3 | "env": { 4 | "browser": true, 5 | "commonjs": true, 6 | "es2021": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended" 10 | ], 11 | "parserOptions": { 12 | "ecmaVersion": 2022 13 | }, 14 | "rules": { 15 | "quotes": "off", 16 | "no-console": "off", 17 | "no-debugger": "off", 18 | "linebreak-style": ["error", "unix"], 19 | "semi": ["warn", "always"], 20 | "no-unused-vars": ["warn", {"args": "none"}], 21 | "no-constant-condition": ["error", {"checkLoops": false }], 22 | "no-constant-binary-expression": "warn", 23 | "no-constructor-return": "error", 24 | "no-new-native-nonconstructor": "error", 25 | "no-unreachable-loop": "error", 26 | "no-use-before-define": ["warn", "nofunc"], 27 | "complexity": ["warn", 60], 28 | "max-len": ["warn", { 29 | "code": 110, 30 | "ignoreComments": false, 31 | "ignoreTrailingComments": false, 32 | "ignoreStrings": false, 33 | "ignoreTemplateLiterals": false, 34 | "ignoreRegExpLiterals": false 35 | }], 36 | "max-nested-callbacks": ["warn", 3], 37 | "max-depth": ["warn", 8], 38 | "curly": "warn", 39 | "eqeqeq": ["error", "always", {"null": "ignore"}], 40 | "no-implicit-globals": "error", 41 | "no-throw-literal": "error", 42 | "no-var": "error", 43 | "no-unreachable": "warn", 44 | "no-warning-comments": "off", 45 | "prefer-const": ["warn", { 46 | "destructuring": "all", 47 | "ignoreReadBeforeAssign": true 48 | }], 49 | "require-await": "warn", 50 | "func-call-spacing": "warn", 51 | "indent": ["warn", 4, { 52 | "CallExpression": {"arguments": "first"}, 53 | "FunctionDeclaration": {"parameters": "first"}, 54 | "FunctionExpression": {"parameters": "first"}, 55 | "ObjectExpression": "first", 56 | "ArrayExpression": 1, 57 | "SwitchCase": 1 58 | }], 59 | "no-trailing-spaces": "warn" 60 | }, 61 | "overrides": [{ 62 | "files": ["*.mjs"], 63 | "parserOptions": { 64 | "sourceType": "module", 65 | "allowImportExportEverywhere": true 66 | } 67 | }] 68 | } 69 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | patreon: sauce4strava 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '21 10 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build.json 2 | 3 | # Logs 4 | logs 5 | *.log 6 | *.swp 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | out 110 | 111 | .DS_Store 112 | 113 | pages/css 114 | .build 115 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | prefer-dedupe=true 3 | loglevel="verbose" 4 | color=true 5 | logs-max=0 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: build 2 | 3 | PACKAGES := node_modules/.build 4 | BUILD := build.json 5 | 6 | ifeq ($(OS),Windows_NT) 7 | WINBLOWS := true 8 | #SHELL := powershell.exe 9 | #.SHELLFLAGS := -C 10 | else 11 | T := $(shell uname -s) 12 | ifeq ($(T),Linux) 13 | LINUX := true 14 | endif 15 | ifeq ($(UNAME_S),Darwin) 16 | MAC := true 17 | endif 18 | endif 19 | 20 | MODS := $(CURDIR)/node_modules 21 | NPATH := $(MODS)/.bin 22 | TOOLPATH := $(CURDIR)/tools/bin 23 | ifndef WINBLOWS 24 | PAGES_SRC := $(shell find pages -type f) 25 | endif 26 | 27 | 28 | $(PACKAGES): package.json 29 | npm install 30 | echo "" > $@ 31 | 32 | 33 | $(BUILD): $(PAGES_SRC) $(PACKAGES) sass deps Makefile .git/index test 34 | node tools/bin/buildenv $@ 35 | 36 | build: $(BUILD) 37 | 38 | 39 | run: $(BUILD) 40 | npm start 41 | 42 | run-debug: $(BUILD) 43 | npm run start-debug 44 | 45 | run-debug-brk: $(BUILD) 46 | npm run start-debug-brk 47 | 48 | 49 | unpacked: $(BUILD) 50 | ifndef WINBLOWS 51 | SKIP_NOTARIZE=1 npm run unpacked 52 | else 53 | npm run unpacked 54 | endif 55 | 56 | packed: $(BUILD) 57 | ifndef WINBLOWS 58 | SKIP_NOTARIZE=1 npm run build 59 | else 60 | npm run build 61 | endif 62 | 63 | publish: $(BUILD) 64 | ifndef WINBLOWS 65 | GH_TOKEN="$${GH_TOKEN_SAUCE4ZWIFT_RELEASE}" npm run publish 66 | else 67 | npm run publish 68 | endif 69 | 70 | publish-docker-linux-native: 71 | docker build --build-arg arch=amd64 -t linux-s4z-build -f ./build/linux.Dockerfile . 72 | docker run -it -v $$HOME/.git-credentials:/root/.git-credentials \ 73 | -e GH_TOKEN_SAUCE4ZWIFT_RELEASE -v $(CURDIR)/dist/docker-dist:/sauce4zwift/dist linux-s4z-build make publish 74 | 75 | _publis-docker-linux-arm_DO_NOT_USE: 76 | # Artifacts collide with non arm builds. I think this is possible to avoid but haven't dived in 77 | # Also this takes like an hour or more to finish on highend 2023 AMD CPU, yikes. 78 | docker build --build-arg arch=arm64 -t linux-s4z-build-arm -f ./build/linux.Dockerfile . 79 | docker run -it -v $$HOME/.git-credentials:/root/.git-credentials \ 80 | -e GH_TOKEN_SAUCE4ZWIFT_RELEASE linux-s4z-build-arm make publish 81 | 82 | 83 | deps: 84 | $(MAKE) -j 32 -C pages/deps 85 | $(MAKE) -j 32 -C shared/deps 86 | 87 | 88 | sass: 89 | $(NPATH)/sass pages/scss:pages/css 90 | 91 | sass-watch: 92 | $(NPATH)/sass pages/scss:pages/css --watch 93 | 94 | 95 | lint: 96 | $(NPATH)/eslint src shared pages/src 97 | 98 | lint-watch: 99 | ifndef WINBLOWS 100 | ifdef LINUX 101 | tools/bin/lintwatch 102 | else 103 | while true ; do \ 104 | $(MAKE) lint; \ 105 | sleep 5; \ 106 | done 107 | endif 108 | else 109 | @echo Unsupported on winblows 110 | endif 111 | 112 | 113 | realclean: clean 114 | rm -rf node_modules 115 | 116 | clean: 117 | rm -f $(BUILD) 118 | rm -rf pages/css 119 | $(MAKE) -C shared/deps clean 120 | $(MAKE) -C pages/deps clean 121 | 122 | 123 | test: 124 | npm run test 125 | 126 | 127 | .PHONY: build packed unpacked publish lint sass deps clean realclean test 128 | -------------------------------------------------------------------------------- /assets/images/great_and_powerful.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/assets/images/great_and_powerful.xcf -------------------------------------------------------------------------------- /assets/images/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/assets/images/screenshot.jpg -------------------------------------------------------------------------------- /assets/images/slideshow.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/assets/images/slideshow.webp -------------------------------------------------------------------------------- /assets/images/slideshow.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/assets/images/slideshow.xcf -------------------------------------------------------------------------------- /assets/notes/command_notes: -------------------------------------------------------------------------------- 1 | command | sub_command | action 2 | 1 1 CHANGE CAMERA 3 | 2 JOIN PLAYER 4 | subject: athleteId to join/teleport to 5 | 3 TELEPORT HOME 6 | 4 4 ELBOW (pull through signal) 7 | 5 5 WAVE 8 | 6 6 SAY (RideOn) 9 | 7 7 RING BELL 10 | 8 8 SAY (hammer time) 11 | 9 9 SAY (i'm toast) 12 | 10 10 SAY (nice) 13 | 11 11 SAY (bringit) 14 | 14 14 END RIDE 15 | 17 17 TAKE PICTURE 16 | 21 21 USE POWER UP 17 | 23 23 REVERSE 18 | 24 - SWITCH VIEW 19 | subject(f3): athleteid 20 | 16 - REQUEST PROFILE 21 | (f5): athleteId 22 | 25 - SOCIAL ACTION (CHAT) 23 | f11: { 24 | 1: 1 25 | 2: 1(direct/private?) or 0 (nearby) 26 | 3: 1 27 | 4: "z" // firstname 28 | 5: "offline" // lastname 29 | 6: "Aaaaaaaaa" // message 30 | 7: "https://avatar.jpg" // avatar 31 | 8: 0 32 | } 33 | 29 - GAME PACKET (f21 = message(f1 = 8, f9 = msg/string(f1 = 1))) 34 | 35 | 22 [1-?] REPLICATE COMMANDS subcommand is treated as the command 36 | 22 1003 ORANGE BAR INCREASE ?? XXX not sure what it is 37 | 22 1004 ORANGE BAR DECREASE ?? XXX not sure what it is 38 | 22 1005 BEEP SOUND XXX Maybe other effect? 39 | 22 1006 BOOP SOUND XXX Maybe other effect? 40 | 22 1020 Some sort of pairing thing XXX 41 | 22 1021 Some sort of pairing thing XXX 42 | 22 1050 START STEERING CALIBRATION 43 | 22 1060 TOGGLE GRAPHS 44 | 22 1080 HUD ON 45 | 22 1081 HUD OFF 46 | 47 | 48 | From decompiled dex classes: 49 | PHONE_TO_GAME_UNKNOWN_COMMAND(0), 50 | CHANGE_CAMERA_ANGLE(1), 51 | JOIN_ANOTHER_PLAYER(2), 52 | TELEPORT_TO_START(3), 53 | ELBOW_FLICK(4), 54 | WAVE(5), 55 | RIDE_ON(6), 56 | BELL(7), 57 | HAMMER_TIME(8), 58 | TOAST(9), 59 | NICE(10), 60 | BRING_IT(11), 61 | DONE_RIDING(14), 62 | CANCEL_DONE_RIDING(15), 63 | DISCARD_ACTIVITY(12), 64 | SAVE_ACTIVITY(13), 65 | REQUEST_FOR_PROFILE(16), 66 | TAKE_SCREENSHOT(17), 67 | OBSOLETE_GROUP_TEXT_MESSAGE(18), 68 | OBSOLETE_SINGLE_PLAYER_TEXT_MESSAGE(19), 69 | MOBILE_API_VERSION(20), 70 | ACTIVATE_POWER_UP(21), 71 | CUSTOM_ACTION(22), 72 | U_TURN(23), 73 | FAN_VIEW(24), 74 | SOCIAL_PLAYER_ACTION(25), 75 | MOBILE_ALERT_RESPONSE(26), 76 | BLEPERIPHERAL_RESPONSE(27), 77 | PAIRING_AS(28), 78 | PHONE_TO_GAME_PACKET(29), 79 | BLEPERIPHERAL_DISCOVERY(30); 80 | 81 | -------------------------------------------------------------------------------- /assets/notes/splines: -------------------------------------------------------------------------------- 1 | catmull-rom spline and beizer are the two road splines used. 2 | -------------------------------------------------------------------------------- /build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.disable-library-validation 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/build/icon.png -------------------------------------------------------------------------------- /build/images/app-icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/build/images/app-icon.icns -------------------------------------------------------------------------------- /build/images/app-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/build/images/app-icon.ico -------------------------------------------------------------------------------- /build/images/icon256-dev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/build/images/icon256-dev.png -------------------------------------------------------------------------------- /build/linux.Dockerfile: -------------------------------------------------------------------------------- 1 | ARG arch 2 | FROM --platform=linux/${arch} fedora:36 3 | RUN dnf install -y make nodejs git python gcc g++ libsecret-devel findutils 4 | RUN git config --global credential.helper store 5 | COPY . sauce4zwift 6 | WORKDIR sauce4zwift 7 | -------------------------------------------------------------------------------- /build/scripts/notarize.js: -------------------------------------------------------------------------------- 1 | const {notarize} = require('@electron/notarize'); 2 | 3 | exports.default = async function notarizing(context) { 4 | const {electronPlatformName, appOutDir} = context; 5 | const skip = process.env.SKIP_NOTARIZE; 6 | if (electronPlatformName !== 'darwin' || skip) { 7 | return; 8 | } 9 | const appName = context.packager.appInfo.productFilename; 10 | const appleId = process.env.APPLE_ID; 11 | const appleIdPassword = process.env.APPLE_ID_PASSWORD; 12 | const teamId = process.env.APPLE_TEAM_ID; 13 | if (!appleId || !appleIdPassword) { 14 | throw new Error("APPLE_ID env not set"); 15 | } 16 | return await notarize({ 17 | appBundleId: 'io.saucellc.sauce4zwift', 18 | appPath: `${appOutDir}/${appName}.app`, 19 | appleId, 20 | appleIdPassword, 21 | teamId, 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /https/.gitignore: -------------------------------------------------------------------------------- 1 | *.pem 2 | -------------------------------------------------------------------------------- /images/mac-trayicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/images/mac-trayicon.png -------------------------------------------------------------------------------- /images/mac-trayicon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/images/mac-trayicon@2x.png -------------------------------------------------------------------------------- /images/win-trayicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/images/win-trayicon.png -------------------------------------------------------------------------------- /images/win-trayicon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/images/win-trayicon@2x.png -------------------------------------------------------------------------------- /pages/analysis-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Analysis", 3 | "name": "Sauce for Zwift™ - Analysis", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Analysis", 7 | "start_url": "/pages/analysis.html", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/analysis-settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Analysis - Settings - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 22 |
23 |
24 | 25 |
Analysis - Settings
26 |
27 |
28 |
close
29 |
30 |
31 |
32 |
33 |
34 | 38 | 42 |
43 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /pages/analysis.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | Analysis - Sauce for Zwift™ 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 23 | 24 | 25 |
26 |
27 | 28 |
Analysis
29 |
30 |
31 | Exportdownload 32 | settings 34 |
minimize
35 |
close
36 |
37 |
38 |
39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /pages/athletes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Athletes - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
Athletes
28 |
29 |
30 | 37 | 39 |
minimize
40 |
close
41 |
42 |
43 |
44 |
45 |
46 |
Following compressexpand
47 |
48 |
49 |
50 |
Marked compressexpand
51 |
52 |
53 |
54 |
Followers compressexpand
55 |
56 |
57 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /pages/browser-source-settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Browser Source - Settings - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 | 22 |
Browser Source - Settings
23 |
24 |
25 |
close
26 |
27 |
28 |
29 |
30 |
31 | 35 | 39 | 44 | 48 | 55 |
56 |
57 | 58 | 59 | -------------------------------------------------------------------------------- /pages/browser-source.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Browser Source - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 | 23 |
24 |
Browser Source
25 | 32 |
33 |
34 | 35 |
star
36 |
37 |
38 | settings 40 |
minimize
41 |
close
42 |
43 |
44 |
45 |
46 | 47 |
48 |
49 |
50 |
51 |
52 | 53 | 54 | -------------------------------------------------------------------------------- /pages/chat-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Chat", 3 | "name": "Sauce for Zwift™ - Chat", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Chat Window", 7 | "start_url": "/pages/chat.html", 8 | "display": "standalone", 9 | "orientation": "portrait", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/chat.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Chat - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
Chat
28 |
29 |
30 | settings 32 |
minimize
33 |
close
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /pages/deps/.gitignore: -------------------------------------------------------------------------------- 1 | flags 2 | src 3 | css 4 | -------------------------------------------------------------------------------- /pages/deps/Makefile: -------------------------------------------------------------------------------- 1 | 2 | NODE := ../../node_modules 3 | 4 | default: flags echarts saucecharts 5 | 6 | 7 | flags: 8 | mkdir -p flags 9 | cp $(NODE)/world_countries_lists/data/flags/64x64/*.png flags/ 10 | 11 | echarts: 12 | mkdir -p src 13 | cp $(NODE)/echarts/dist/echarts.esm.min.js src/echarts.mjs 14 | 15 | saucecharts: 16 | mkdir -p css/saucecharts src/saucecharts 17 | cp -r $(NODE)/saucecharts/src/* src/saucecharts/ 18 | cp -r $(NODE)/saucecharts/css/* css/saucecharts/ 19 | 20 | dev: default 21 | rm -f src/saucecharts/* 22 | for x in ~/project/saucecharts/src/*; do \ 23 | ln $$x src/saucecharts/ ; \ 24 | done 25 | rm -f css/saucecharts/* 26 | for x in ~/project/saucecharts/css/*; do \ 27 | ln $$x css/saucecharts/ ; \ 28 | done 29 | 30 | 31 | clean: 32 | rm -rf flags src css 33 | 34 | 35 | .PHONY: flags echarts saucecharts 36 | -------------------------------------------------------------------------------- /pages/eula.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Legal Junk - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 65 | 66 | 78 | 79 | 80 |

Obligatory legal stuff...

81 |

Click "I Agree" if you consent to all this mumbo jumbo.

82 | 83 |
84 | 85 | 86 |
87 | 88 | 89 | -------------------------------------------------------------------------------- /pages/events-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Events", 3 | "name": "Sauce for Zwift™ - Events", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Events", 7 | "start_url": "/pages/events.html", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/events-settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Events - Settings - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 22 |
23 |
24 | 25 |
Events - Settings
26 |
27 |
28 |
close
29 |
30 |
31 |
32 |
33 |
34 | 38 |
39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /pages/events.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Events - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
Events
28 |
29 |
30 | 39 |
40 |
41 |
42 |
43 | settings 45 |
minimize
46 |
close
47 |
48 |
49 |
50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /pages/file-replay.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | File Replay - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 |
24 |
25 | 26 |
File Replay
27 |
28 |
29 | settings 31 |
minimize
32 |
close
33 |
34 |
35 |
36 |
37 |
Load activity (i.e. .fit file):
38 | 39 |
40 |
skip_previous
41 |
fast_rewind
42 |
stop_circle
43 |
play_circle
44 |
fast_forward
45 |
skip_next
46 |
47 |
Time code:
48 |
49 |
navigate_before
50 |
51 |
navigate_next
52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | -------------------------------------------------------------------------------- /pages/fonts/MaterialSymbolsRounded.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/fonts/MaterialSymbolsRounded.woff2 -------------------------------------------------------------------------------- /pages/fonts/PermanentMarker.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/fonts/PermanentMarker.woff2 -------------------------------------------------------------------------------- /pages/fonts/Saira.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/fonts/Saira.woff2 -------------------------------------------------------------------------------- /pages/game-control-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Game Control", 3 | "name": "Sauce for Zwift™ - Game Control", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Game Control using companion protocol", 7 | "start_url": "/pages/game-control.html", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/game-control-settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Game Control - Settings - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 22 |
23 |
24 | 25 |
Game Control - Settings
26 |
27 |
28 |
close
29 |
30 |
31 |
32 |
33 |
34 | 38 |
39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /pages/gauge.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /pages/gauges/cadence-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Cadence Gauge", 3 | "name": "Sauce for Zwift™ - Cadence Gauge", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Cadence Gauge Window", 7 | "start_url": "/pages/gauges/cadence.html", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/gauges/cadence.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Cadence Gauge - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
Cadence Gauge
28 |
29 |
30 | settings 32 |
minimize
33 |
close
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /pages/gauges/draft-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Draft Gauge", 3 | "name": "Sauce for Zwift™ - Draft Gauge", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Draft Gauge Window", 7 | "start_url": "/pages/gauges/draft.html", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/gauges/draft.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Draft Gauge - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
Draft Gauge
28 |
29 |
30 | settings 32 |
minimize
33 |
close
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /pages/gauges/hr-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Heart Rate Gauge", 3 | "name": "Sauce for Zwift™ - Heart Rate Gauge", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Heart Rate Gauge Window", 7 | "start_url": "/pages/gauges/hr.html", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/gauges/hr.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Heart Rate Gauge - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
Heart Rate Gauge
28 |
29 |
30 | settings 32 |
minimize
33 |
close
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /pages/gauges/pace-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Speed Gauge", 3 | "name": "Sauce for Zwift™ - Speed Gauge", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Speed Gauge Window", 7 | "start_url": "/pages/gauges/pace.html", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/gauges/pace.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Speed Gauge - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
Speed Gauge
28 |
29 |
30 | settings 32 |
minimize
33 |
close
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /pages/gauges/power-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Power Gauge", 3 | "name": "Sauce for Zwift™ - Power Gauge", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Power Gauge Window", 7 | "start_url": "/pages/gauges/power.html", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/gauges/power.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Power Gauge - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
Power Gauge
28 |
29 |
30 | settings 32 |
minimize
33 |
close
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /pages/gauges/wbal-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "W'bal Gauge", 3 | "name": "Sauce for Zwift™ - W'bal Gauge", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - W'bal Gauge Window", 7 | "start_url": "/pages/gauges/wbal.html", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/gauges/wbal.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | W'bal Gauge - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
W'bal Gauge
28 |
29 |
30 | settings 32 |
minimize
33 |
close
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /pages/geo-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Map", 3 | "name": "Sauce for Zwift™ - Map", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Map Window", 7 | "start_url": "/pages/geo.html", 8 | "display": "standalone", 9 | "orientation": "portrait", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/geo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Map - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
Map
28 |
29 |
30 |
31 |
32 | settings 34 |
minimize
35 |
close
36 |
37 |
38 |
39 |
40 |
42 |
43 |
44 |
explore
46 |
my_location
48 |
49 |
50 |
51 |
52 |
53 |
54 | 55 | 56 | -------------------------------------------------------------------------------- /pages/groups-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Groups", 3 | "name": "Sauce for Zwift™ - Groups", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Groups Window", 7 | "start_url": "/pages/groups.html", 8 | "display": "standalone", 9 | "orientation": "portrait", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/groups.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Groups - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
Groups
28 |
29 |
30 | settings 32 |
minimize
33 |
close
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /pages/images/become_a_patron_button@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/become_a_patron_button@2x.png -------------------------------------------------------------------------------- /pages/images/blankavatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/blankavatar.png -------------------------------------------------------------------------------- /pages/images/examples/elevation-profile-chart.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/examples/elevation-profile-chart.webp -------------------------------------------------------------------------------- /pages/images/examples/power-zones-horiz-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/examples/power-zones-horiz-chart.png -------------------------------------------------------------------------------- /pages/images/examples/power-zones-pie-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/examples/power-zones-pie-chart.png -------------------------------------------------------------------------------- /pages/images/examples/power-zones-vert-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/examples/power-zones-vert-chart.png -------------------------------------------------------------------------------- /pages/images/examples/sauce-line-chart-cap-50pct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/examples/sauce-line-chart-cap-50pct.png -------------------------------------------------------------------------------- /pages/images/fa/bolt-duotone.svg: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /pages/images/fa/caret-left-regular.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pages/images/fa/caret-right-regular.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pages/images/fa/cog-duotone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/images/fa/flag-checkered-duotone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/images/fa/heartbeat-duotone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/images/fa/plus-square-duotone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/images/fa/search-minus-duotone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/images/fa/search-plus-duotone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/images/fa/solar-system-duotone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/images/fa/stopwatch-regular.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pages/images/fa/tachometer-duotone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/images/fa/times-circle-duotone.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pages/images/fa/undo-regular.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pages/images/fa/user-circle-solid.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pages/images/fa/wind-duotone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/favicon.png -------------------------------------------------------------------------------- /pages/images/great_and_powerful.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/great_and_powerful.webp -------------------------------------------------------------------------------- /pages/images/great_and_powerful_bubble.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/great_and_powerful_bubble.webp -------------------------------------------------------------------------------- /pages/images/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon128.png -------------------------------------------------------------------------------- /pages/images/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon16.png -------------------------------------------------------------------------------- /pages/images/icon19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon19.png -------------------------------------------------------------------------------- /pages/images/icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon256.png -------------------------------------------------------------------------------- /pages/images/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon32.png -------------------------------------------------------------------------------- /pages/images/icon38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon38.png -------------------------------------------------------------------------------- /pages/images/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon48.png -------------------------------------------------------------------------------- /pages/images/icon64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon64.png -------------------------------------------------------------------------------- /pages/images/logo_horiz_128x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_horiz_128x48.png -------------------------------------------------------------------------------- /pages/images/logo_horiz_320x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_horiz_320x120.png -------------------------------------------------------------------------------- /pages/images/logo_horiz_64x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_horiz_64x24.png -------------------------------------------------------------------------------- /pages/images/logo_vert_120x320.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_vert_120x320.png -------------------------------------------------------------------------------- /pages/images/logo_vert_32x85.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_vert_32x85.png -------------------------------------------------------------------------------- /pages/images/logo_vert_48x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_vert_48x128.png -------------------------------------------------------------------------------- /pages/images/logo_vert_96x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_vert_96x256.png -------------------------------------------------------------------------------- /pages/images/map-fade-mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/map-fade-mask.png -------------------------------------------------------------------------------- /pages/images/mod-store-badge.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/mod-store-badge.webp -------------------------------------------------------------------------------- /pages/images/ranking/cat1-darkbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat1-darkbg.png -------------------------------------------------------------------------------- /pages/images/ranking/cat1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat1.png -------------------------------------------------------------------------------- /pages/images/ranking/cat2-darkbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat2-darkbg.png -------------------------------------------------------------------------------- /pages/images/ranking/cat2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat2.png -------------------------------------------------------------------------------- /pages/images/ranking/cat3-darkbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat3-darkbg.png -------------------------------------------------------------------------------- /pages/images/ranking/cat3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat3.png -------------------------------------------------------------------------------- /pages/images/ranking/cat4-darkbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat4-darkbg.png -------------------------------------------------------------------------------- /pages/images/ranking/cat4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat4.png -------------------------------------------------------------------------------- /pages/images/ranking/cat5-darkbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat5-darkbg.png -------------------------------------------------------------------------------- /pages/images/ranking/cat5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat5.png -------------------------------------------------------------------------------- /pages/images/ranking/pro-darkbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/pro-darkbg.png -------------------------------------------------------------------------------- /pages/images/ranking/pro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/pro.png -------------------------------------------------------------------------------- /pages/images/ranking/world-tour-darkbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/world-tour-darkbg.png -------------------------------------------------------------------------------- /pages/images/ranking/world-tour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/world-tour.png -------------------------------------------------------------------------------- /pages/images/smart_trainer.svg: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | 19 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /pages/images/zp_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/zp_logo.png -------------------------------------------------------------------------------- /pages/logs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Debug Logs - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 |
24 |
25 | 26 |
Debug Logs
27 |
28 |
29 |
30 | 36 |
37 |
38 |
39 |
40 |
Open file(s)
41 |
Clear
42 |
43 |
44 |
45 |
minimize
46 |
close
47 |
48 |
49 |
50 |
51 |
52 |
53 | 54 | 55 | -------------------------------------------------------------------------------- /pages/nearby-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Nearby", 3 | "name": "Sauce for Zwift™ - Nearby", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Nearby Raw Data", 7 | "start_url": "/pages/nearby.html", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/nearby.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Nearby - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
Nearby Athletes
28 |
30 |
31 |
32 |
33 | settings 35 |
minimize
36 |
close
37 |
38 |
39 |
40 |
41 | 42 | 43 | 44 |
45 |
46 |
47 |
48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /pages/overview-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Overview", 3 | "name": "Sauce for Zwift™ - Overview", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Overview Window", 7 | "start_url": "/pages/overview.html", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/overview.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 26 |
Sauce for Zwift™
27 |
28 |
29 |
30 |
31 | monitoring 33 | person_search 36 | event 38 |
Auto
Hidden
39 |
visibility_off
40 |
visibility
41 | settings 43 |
power_settings_new
44 |
45 |
46 | 47 | 48 | -------------------------------------------------------------------------------- /pages/patron-a51.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Enter Code - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 57 | 58 | 59 | 60 |
61 | 62 |
63 |
64 |

Enter your special code below

65 | 66 |
67 | 68 | 69 |
70 |
71 | 72 | 73 | -------------------------------------------------------------------------------- /pages/patron-checking.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Checking membership - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 53 | 54 | 55 | 56 |
57 | 58 |

🤔 Checking membership...

59 |
60 |
61 | foo 62 |

63 |

Asking mom and dad if I can play...

64 | 65 |


66 | 67 | For assistance send an email to support@sauce.llc. 68 |
69 | 70 | 71 | -------------------------------------------------------------------------------- /pages/patron-success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Access Granted - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 56 | 57 | 58 | 59 |
60 | 61 |
62 |

🎉 Huzzah!

63 |

It actually worked! 🥳

64 |
65 |
66 |
67 |
68 |

Never a doubt in my mind!

69 | 70 |
71 | 72 |
Proceed...
73 | 74 |


75 |

76 | 77 |
78 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /pages/patron-waiting.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Waiting for validation - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 52 | 53 | 54 | 55 |
56 | 57 |

Waiting for validation...

58 |
59 |
60 |

61 |

Leave this window open while you log in to Patreon with your browser.

62 | 63 |

64 | 65 |
Or if something went wrong: Try again
66 | 67 |

68 | 69 | HINT: A new browser window should have opened to guide you through the next steps. 70 | For assistance send an email to support@sauce.llc. 71 |
72 | 73 | 74 | -------------------------------------------------------------------------------- /pages/profile-avatar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Profile Avatar - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 26 | 27 | 56 | 57 | 58 | 59 |
60 |
61 | 62 |
Profile Avatar
63 |
64 |
65 |
minimize
66 |
close
67 |
68 |
69 |
70 |
71 | 72 | 73 | -------------------------------------------------------------------------------- /pages/profile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Athlete Profile - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /pages/scss/_charts.scss: -------------------------------------------------------------------------------- 1 | @use 'color'; 2 | 3 | @mixin legend($selector) { 4 | #{$selector} { 5 | display: flex; 6 | flex-wrap: wrap; 7 | align-items: center; 8 | justify-content: space-evenly; 9 | max-width: 100%; 10 | user-select: none; 11 | 12 | .s-legend-item { 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | padding: 0.12em 0.33em; 17 | cursor: pointer; 18 | 19 | &.hidden { 20 | opacity: 0.4; 21 | 22 | .color { 23 | background-color: color.get(fg, 0.4) !important; 24 | } 25 | } 26 | 27 | &:hover { 28 | text-decoration: underline; 29 | } 30 | 31 | .color { 32 | border-radius: 50%; 33 | width: 0.7em; 34 | height: 0.7em; 35 | overflow: hidden; 36 | border: 0.075em solid color.shade(fg, 10%, 0.8); 37 | margin-right: 0.3em; 38 | opacity: 0.82; 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /pages/scss/_color.scss: -------------------------------------------------------------------------------- 1 | @function get($key, $alpha: 100%) { 2 | @return hsl( 3 | var(--theme-#{$key}-hue) 4 | var(--theme-#{$key}-sat) 5 | var(--theme-#{$key}-light) 6 | / $alpha); 7 | } 8 | 9 | @function alpha($key, $alpha) { 10 | @return hsl( 11 | var(--theme-#{$key}-hue) 12 | var(--theme-#{$key}-sat) 13 | var(--theme-#{$key}-light) 14 | / $alpha); 15 | } 16 | 17 | @function darken($key, $amount, $alpha: 100%) { 18 | @return hsl( 19 | var(--theme-#{$key}-hue) 20 | var(--theme-#{$key}-sat) 21 | calc(var(--theme-#{$key}-light) - #{$amount}) 22 | / $alpha); 23 | } 24 | 25 | @function lighten($key, $amount, $alpha: 100%) { 26 | @return hsl( 27 | var(--theme-#{$key}-hue) 28 | var(--theme-#{$key}-sat) 29 | calc(var(--theme-#{$key}-light) + #{$amount}) 30 | / $alpha); 31 | } 32 | 33 | @function shade($key, $amount, $alpha: 100%, $saturate: null) { 34 | @if $saturate != null { 35 | @return hsl( 36 | var(--theme-#{$key}-hue) 37 | calc(var(--theme-#{$key}-sat) + #{$saturate}) 38 | calc(var(--theme-#{$key}-light) + (#{$amount} * var(--theme-#{$key}-shade-dir))) 39 | / $alpha); 40 | } @else { 41 | @return hsl( 42 | var(--theme-#{$key}-hue) 43 | var(--theme-#{$key}-sat) 44 | calc(var(--theme-#{$key}-light) + (#{$amount} * var(--theme-#{$key}-shade-dir))) 45 | / $alpha); 46 | } 47 | } 48 | 49 | @function hue($key, $amount, $alpha: 100%, $shade: 0%) { 50 | @return hsl( 51 | calc(var(--theme-#{$key}-hue) + #{$amount}) 52 | var(--theme-#{$key}-sat) 53 | calc(var(--theme-#{$key}-light) + (#{$shade} * var(--theme-#{$key}-shade-dir))) 54 | / $alpha); 55 | } 56 | -------------------------------------------------------------------------------- /pages/scss/animations.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --tilt: 0deg; 3 | } 4 | 5 | @keyframes pournshake { 6 | 0%, 7 | 60%, 8 | 100% { transform: translate(0, 0) rotate(var(--tilt)) scale(1); } 9 | 30% { transform: translate(0, 0) rotate(calc(var(--tilt) + 180deg)) scale(1.2); } 10 | 32% { transform: translate(0.2em, 0.2em) rotate(calc(var(--tilt) + 185deg)) scale(1.2); } 11 | 34% { transform: translate(-0.2em, 0) rotate(calc(var(--tilt) + 175deg)) scale(0.8); } 12 | 36% { transform: translate(0.2em, 0.1em) rotate(calc(var(--tilt) + 180deg)) scale(1.2); } 13 | 38% { transform: translate(-0.2em, -0.2em) rotate(calc(var(--tilt) + 185deg)) scale(0.8); } 14 | 40% { transform: translate(0.2em, 0.2em) rotate(calc(var(--tilt) + 185deg)) scale(1.2); } 15 | 42% { transform: translate(-0.2em, 0) rotate(calc(var(--tilt) + 175deg)) scale(0.8); } 16 | 44% { transform: translate(0.2em, 0.1em) rotate(calc(var(--tilt) + 180deg)) scale(1.2); } 17 | 46% { transform: translate(-0.2em, -0.2em) rotate(calc(var(--tilt) + 185deg)) scale(0.8); } 18 | } 19 | 20 | @keyframes highlight { 21 | from { 22 | filter: brightness(1); 23 | } 24 | 25 | 33% { 26 | filter: brightness(0.80); 27 | } 28 | 29 | 66% { 30 | filter: brightness(1.20); 31 | } 32 | 33 | to { 34 | filter: brightness(1); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pages/scss/browser-source.scss: -------------------------------------------------------------------------------- 1 | @use 'color'; 2 | 3 | html { 4 | font-size: 16px; 5 | 6 | &.solid-background { 7 | background: var(--background-color); 8 | 9 | body { 10 | background: transparent; 11 | } 12 | } 13 | 14 | --opacity: 1; 15 | } 16 | 17 | #content { 18 | align-items: center; 19 | justify-content: center; 20 | opacity: var(--opacity); 21 | 22 | > webview { 23 | width: 100%; 24 | height: 100%; 25 | } 26 | 27 | > .load-fail-reason { 28 | width: 100%; 29 | height: 100%; 30 | background: color.get(bg); 31 | color: color.get(fg); 32 | display: flex; 33 | align-items: center; 34 | justify-content: center; 35 | } 36 | 37 | &:not(.load-failed) > .load-fail-reason { 38 | display: none; 39 | } 40 | 41 | &.load-failed > webview { 42 | display: none; 43 | } 44 | } 45 | 46 | #titlebar { 47 | position: relative !important; 48 | --extra-height: 1.4em; 49 | 50 | header { 51 | // XXX probably won't work on windows 52 | app-region: drag; 53 | 54 | > * { 55 | app-region: no-drag; 56 | } 57 | } 58 | 59 | header .double-row { 60 | padding-top: 0.15em; 61 | padding-bottom: 0.15em; 62 | display: flex; 63 | overflow: hidden; 64 | flex-direction: column; 65 | justify-content: center; 66 | flex-shrink: 0.00000001; // last resort only to keep right side buttons 67 | 68 | .title { 69 | display: initial; // fixes text-overflow 70 | padding-top: 0.15em; 71 | padding-left: 0.3em; 72 | // Let buttons exclusively control width 73 | width: 0; 74 | min-width: 100%; 75 | height: auto; 76 | text-overflow: ellipsis; 77 | overflow: hidden; 78 | } 79 | 80 | .buttons { } 81 | } 82 | 83 | .url-holder { 84 | display: flex; 85 | overflow: hidden; 86 | flex: 1 0.5 100%; 87 | min-width: 0; 88 | background-color: #111; 89 | color: #fff; 90 | border-radius: 0.28em; 91 | margin-left: 0.4em; 92 | margin-right: 0.4em; 93 | padding-left: 0.3em; 94 | 95 | &:focus-within { 96 | outline: 1px solid color.get(primary); 97 | } 98 | 99 | input { 100 | margin: 0; 101 | background: transparent; 102 | border: none; 103 | color: inherit; 104 | flex: 1 1 100%; 105 | overflow: hidden; 106 | outline: none !important; 107 | } 108 | 109 | .pin { 110 | &.pinned { 111 | font-variation-settings: 'FILL' 1; 112 | color: #1c9be3; 113 | -webkit-text-stroke: 1px #fff; 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /pages/scss/elevation.scss: -------------------------------------------------------------------------------- 1 | .sauce-elevation-profile-container { 2 | font-variant-numeric: tabular-nums; 3 | } 4 | -------------------------------------------------------------------------------- /pages/scss/expandable-table.scss: -------------------------------------------------------------------------------- 1 | @use 'color'; 2 | 3 | table.expandable { 4 | border-collapse: collapse; 5 | 6 | > tbody > tr { 7 | transition: background 150ms, border 150ms; 8 | 9 | &:nth-child(4n - 1) { 10 | background-color: color.get(intrinsic-inverted, 0.03); 11 | } 12 | 13 | &:nth-child(2n - 1) { 14 | &:not(.expanded) + tr { 15 | display: none; 16 | } 17 | } 18 | 19 | &.summary { 20 | cursor: pointer; 21 | 22 | &:not(.expanded):hover { 23 | background-color: color.get(intrinsic-inverted, 0.15); 24 | } 25 | } 26 | 27 | &.expanded, 28 | &.expanded + tr.details { 29 | background-color: color.shade(intrinsic-inverted, 20%, 0.1); 30 | } 31 | 32 | &.expanded { 33 | border-top: 0.2em solid color.shade(intrinsic-inverted, 0%, 50%); 34 | 35 | > td { 36 | font-weight: bold; 37 | } 38 | } 39 | 40 | &.expanded + .details { 41 | border-bottom: 0.2em solid color.shade(intrinsic-inverted, 0%, 50%); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pages/scss/file-replay.scss: -------------------------------------------------------------------------------- 1 | input[type="file"] { 2 | color: fieldtext; 3 | } 4 | 5 | #content { 6 | overflow: auto; 7 | font-size: 0.6em; 8 | padding: 1em; 9 | 10 | .category { 11 | font-weight: 800; 12 | font-size: 1.1em; 13 | font-variant: all-small-caps; 14 | line-height: 1; 15 | text-shadow: 0 0 1px #000; 16 | opacity: 0.9; 17 | } 18 | 19 | .button-group { 20 | margin: 0.5em 0; 21 | } 22 | 23 | .button { 24 | margin: 0; 25 | padding: 0.1rem; 26 | 27 | &:not(:last-child) { 28 | margin-right: 0.4em; 29 | } 30 | 31 | ms { 32 | font-size: 2em; 33 | } 34 | } 35 | 36 | .timecode { 37 | justify-content: space-evenly; 38 | align-items: center; 39 | 40 | .button { 41 | flex: 0 0 auto; 42 | margin: 0; 43 | } 44 | } 45 | 46 | .timecode-value { 47 | font-size: 1.2em; 48 | font-weight: 800; 49 | letter-spacing: 5px; 50 | background: white; 51 | color: black; 52 | padding: 0.2em 1em; 53 | margin-top: 0.2em; 54 | border-radius: 10em; 55 | box-shadow: 1px 1px 4px 0 #0003; 56 | font-variant-numeric: tabular-nums; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pages/scss/game-control.scss: -------------------------------------------------------------------------------- 1 | html:not(.connected) { 2 | #content .button { 3 | pointer-events: none; 4 | opacity: 0.5; 5 | } 6 | } 7 | 8 | html.settings-mode #content header { 9 | app-region: initial !important; 10 | } 11 | 12 | #titlebar .title { 13 | flex-direction: column !important; 14 | align-items: flex-start !important; 15 | justify-content: center !important; 16 | 17 | .top { } 18 | 19 | .bottom { 20 | font-variant: all-small-caps; 21 | font-weight: 600; 22 | line-height: 0.65; 23 | opacity: 0.7; 24 | } 25 | } 26 | 27 | #content { 28 | overflow: auto; 29 | font-size: 0.6em; 30 | 31 | .category { 32 | font-weight: 800; 33 | font-size: 1.1em; 34 | font-variant: all-small-caps; 35 | margin-left: 0.6rem; 36 | line-height: 1; 37 | text-shadow: 0 0 1px #000; 38 | opacity: 0.9; 39 | } 40 | 41 | .category + .button-group { 42 | margin-top: 0; 43 | } 44 | 45 | .button-group { 46 | margin: 0.5em; 47 | 48 | b { 49 | writing-mode: vertical-lr; 50 | text-align: center; 51 | } 52 | } 53 | 54 | .button { 55 | text-transform: uppercase; 56 | font-weight: 700; 57 | min-height: 3em; 58 | padding-left: 0.4em; 59 | padding-right: 0.4em; 60 | text-align: center; // XXX tempted to make this std, makes wrapped text centered 61 | 62 | ms:not([xl]) { 63 | padding-left: 0.3em; 64 | margin-bottom: 0; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /pages/scss/gauge.scss: -------------------------------------------------------------------------------- 1 | html { 2 | &.solid-background { 3 | background: var(--background-color); 4 | 5 | body { 6 | background: transparent; 7 | } 8 | } 9 | } 10 | 11 | #content { 12 | .gauge { 13 | flex-grow: 1; 14 | margin-bottom: max(-20vw, -20vh); 15 | font-variant-numeric: tabular-nums; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pages/scss/profile.scss: -------------------------------------------------------------------------------- 1 | @import url(profile_tpl.css); 2 | 3 | html { 4 | font-size: 3.9mm; 5 | } 6 | 7 | body > main { 8 | display: flex; 9 | flex-direction: column; 10 | flex: 1 1; 11 | max-height: 100%; 12 | 13 | > .profile { 14 | max-height: 100%; 15 | 16 | section { 17 | overflow: auto; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pages/scss/segments.scss: -------------------------------------------------------------------------------- 1 | @use 'color'; 2 | 3 | html { 4 | &.solid-background { 5 | background: var(--background-color); 6 | 7 | body { 8 | background: transparent !important; 9 | } 10 | } 11 | } 12 | 13 | 14 | #content { 15 | overflow: hidden; 16 | } 17 | 18 | .tabbed { 19 | overflow: hidden; 20 | flex-grow: 1; 21 | 22 | > .tab { 23 | padding: 0; 24 | } 25 | } 26 | 27 | .results { 28 | font-size: 0.7em; 29 | display: grid; 30 | grid-auto-columns: min-content 1fr auto auto auto; 31 | grid-auto-rows: auto auto; 32 | 33 | .result { 34 | display: contents; 35 | 36 | &:nth-child(even) > * { 37 | background-color: color.shade(fg, 100%, 0.1); 38 | } 39 | &:nth-child(odd) > * { 40 | background-color: color.shade(fg, 100%, 0.2); 41 | } 42 | 43 | > * { 44 | padding: 0.2em; 45 | display: flex; 46 | justify-content: flex-start; 47 | align-items: baseline; 48 | } 49 | 50 | .place { 51 | grid-column: 1; 52 | grid-row: span 2; 53 | display: flex; 54 | align-items: center; 55 | justify-content: center; 56 | padding-left: 0.5em; 57 | padding-right: 0.5em; 58 | font-weight: 700; 59 | 60 | .trophy { 61 | font-size: 1.8em; 62 | font-weight: 600; 63 | filter: drop-shadow(1px 1px 4px #0004); 64 | 65 | &.gold { 66 | color: gold; 67 | } 68 | &.silver { 69 | color: silver; 70 | } 71 | &.bronze { 72 | color: #b35534; 73 | } 74 | } 75 | } 76 | 77 | .name { 78 | grid-column: 2 / 5; 79 | grid-row: span 1; 80 | padding-bottom: 0; 81 | white-space: nowrap; 82 | overflow: hidden; 83 | } 84 | 85 | .time { 86 | padding-top: 0; 87 | grid-column: 2 / 5; 88 | grid-row: span 1; 89 | font-weight: 600; 90 | 91 | .milliseconds { 92 | font-size: 0.8em; 93 | } 94 | } 95 | 96 | .hr { 97 | grid-column: 4; 98 | grid-row: span 1; 99 | } 100 | 101 | .power { 102 | grid-column: 5; 103 | grid-row: span 1; 104 | } 105 | 106 | .when { 107 | font-size: 0.9em; 108 | font-weight: 300; 109 | grid-column: 5; 110 | grid-row: span 1; 111 | padding-right: 1em; 112 | } 113 | } 114 | } 115 | 116 | -------------------------------------------------------------------------------- /pages/segments-settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Segments - Settings - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 | 22 |
Segments - Settings
23 |
24 |
25 |
close
26 |
27 |
28 |
29 |
30 |
31 | 35 | 39 | 46 |
47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /pages/segments.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Segments - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 | 23 |
24 |
Segments
25 |
26 |
27 |
28 | settings 30 |
minimize
31 |
close
32 |
33 |
34 |
35 |
36 |
37 |
38 |
Live
39 |
Just me
40 |
41 |
Live...
42 |
Just me...
43 |
44 | 47 |
48 |
49 |
50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /pages/sounds/great_and_powerful.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/sounds/great_and_powerful.ogg -------------------------------------------------------------------------------- /pages/sounds/soundtweet.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/sounds/soundtweet.ogg -------------------------------------------------------------------------------- /pages/src/custom-elements.mjs: -------------------------------------------------------------------------------- 1 | 2 | export const themes = [ 3 | {id: "sauce", name: "Sauce Default"}, 4 | {id: "bluepink", name: "Ice Blue Pink"}, 5 | {id: "green", name: "Green Lantern"}, 6 | {id: "burgundy", name: "Ron Burgundy"}, 7 | {id: "aqua", name: "Aqua Salad"}, 8 | {id: "watermelon", name: "Watermelon"}, 9 | {id: "light", name: "Light"}, 10 | {id: "dark", name: "Dark"}, 11 | {id: "transparent-light", name: "Light", group: "Transparent"}, 12 | {id: "transparent-dark", name: "Dark", group: "Transparent"}, 13 | {id: "semi-transparent-dark", name: "Semi", group: "Transparent"}, 14 | {id: "gpt-vibrant", name: "Vibrant", group: "AI Generated"}, 15 | {id: "gpt-sunset", name: "Sunset", group: "AI Generated"}, 16 | ]; 17 | 18 | class ThemeSelect extends HTMLSelectElement { 19 | constructor() { 20 | super(); 21 | this.setAttribute('name', this.hasAttribute('override') ? 'themeOverride' : '/theme'); 22 | this.render(); 23 | } 24 | 25 | render() { 26 | const _themes = this.hasAttribute('override') ? 27 | [{id: '', name: 'Use app setting'}, ...themes] : 28 | themes.map(x => x.id === 'sauce' ? {...x, id: ''} : x); 29 | const groups = new Map(); 30 | for (const x of _themes) { 31 | if (!groups.has(x.group)) { 32 | groups.set(x.group, []); 33 | } 34 | groups.get(x.group).push(x); 35 | } 36 | for (const [group, entries] of groups.entries()) { 37 | let parent; 38 | if (group) { 39 | parent = document.createElement('optgroup'); 40 | parent.label = group; 41 | this.append(parent); 42 | } else { 43 | parent = this; 44 | } 45 | for (const x of entries) { 46 | const option = document.createElement('option'); 47 | option.value = x.id; 48 | option.label = x.name; 49 | parent.append(option); 50 | } 51 | } 52 | } 53 | 54 | update() { 55 | while (this.childNodes.length) { 56 | this.removeChild(this.childNodes[0]); 57 | } 58 | this.render(); 59 | } 60 | } 61 | customElements.define('sauce-theme', ThemeSelect, {extends: 'select'}); 62 | -------------------------------------------------------------------------------- /pages/src/file-replay.mjs: -------------------------------------------------------------------------------- 1 | import * as common from './common.mjs'; 2 | import * as locale from '../../shared/sauce/locale.mjs'; 3 | 4 | common.enableSentry(); 5 | 6 | let playing; 7 | let timecodeOffset; 8 | let timecode; 9 | 10 | 11 | const _timeCodeEl = document.querySelector('.timecode-value'); 12 | function drawTimeCode() { 13 | requestAnimationFrame(drawTimeCode); 14 | const realTimeAdjust = playing ? (Date.now() - timecodeOffset) / 1000 : 0; 15 | const ts = timecode + realTimeAdjust || 0; 16 | common.softInnerHTML(_timeCodeEl, locale.human.timer(ts, {ms: true, long: true})); 17 | } 18 | 19 | 20 | async function updateStatus() { 21 | const status = await common.rpc.fileReplayStatus(); 22 | if (status.state === 'playing') { 23 | playing = true; 24 | } 25 | if (status.state !== 'inactive') { 26 | timecodeOffset = Date.now(); 27 | timecode = status.ts; 28 | } 29 | document.querySelectorAll('.button-group .button.disabled') 30 | .forEach(x => x.classList.toggle('disabled', status.state === 'inactive')); 31 | } 32 | 33 | 34 | export async function main() { 35 | common.initInteractionListeners(); 36 | document.querySelector('input[name="activity"]').addEventListener('input', async ev => { 37 | const file = ev.currentTarget.files[0]; 38 | if (!file) { 39 | return; 40 | } 41 | const ab = await file.arrayBuffer(); 42 | const payload = btoa(Array.from(new Uint8Array(ab)).map(x => String.fromCharCode(x)).join('')); 43 | try { 44 | await common.rpc.fileReplayLoad({ 45 | type: 'base64', 46 | payload, 47 | }); 48 | } catch(e) { 49 | alert(e.message); 50 | } 51 | document.querySelectorAll('.button-group .button.disabled') 52 | .forEach(x => x.classList.remove('disabled')); 53 | }); 54 | document.querySelector('#content').addEventListener('click', async ev => { 55 | const btn = ev.target.closest('.button'); 56 | if (!btn) { 57 | return; 58 | } 59 | const args = btn.dataset.args ? JSON.parse(btn.dataset.args) : []; 60 | await common.rpc[btn.dataset.call](...args); 61 | await updateStatus(); 62 | }); 63 | common.subscribe('file-replay-timesync', ev => { 64 | timecodeOffset = Date.now(); 65 | timecode = ev.ts; 66 | playing = ev.playing; 67 | }); 68 | await updateStatus(); 69 | drawTimeCode(); 70 | } 71 | 72 | 73 | export async function settingsMain() { 74 | common.initInteractionListeners(); 75 | await common.initSettingsForm('form')(); 76 | } 77 | -------------------------------------------------------------------------------- /pages/src/game-control.mjs: -------------------------------------------------------------------------------- 1 | import * as common from './common.mjs'; 2 | 3 | common.enableSentry(); 4 | 5 | 6 | function updateConnStatus(s) { 7 | if (!s) { 8 | s = {connected: false, state: 'disabled'}; 9 | } 10 | document.documentElement.classList.toggle('connected', s.connected); 11 | const statusEl = document.querySelector('.status'); 12 | statusEl.textContent = s.state; 13 | } 14 | 15 | 16 | export async function main() { 17 | common.initInteractionListeners(); 18 | common.subscribe('status', updateConnStatus, {source: 'gameConnection', persistent: true}); 19 | document.querySelector('#content').addEventListener('click', ev => { 20 | const btn = ev.target.closest('.button'); 21 | if (!btn) { 22 | return; 23 | } 24 | const args = btn.dataset.args ? JSON.parse(btn.dataset.args) : []; 25 | common.rpc[btn.dataset.call](...args); 26 | }); 27 | document.addEventListener('sauce-ws-status', async ({detail}) => { 28 | if (detail === 'connected') { 29 | updateConnStatus(await common.rpc.getGameConnectionStatus()); 30 | } else { 31 | updateConnStatus({connected: false, state: 'not running'}); 32 | updateConnStatus(await common.rpc.getGameConnectionStatus()); 33 | } 34 | }); 35 | updateConnStatus(await common.rpc.getGameConnectionStatus()); 36 | } 37 | 38 | 39 | export async function settingsMain() { 40 | common.initInteractionListeners(); 41 | await common.initSettingsForm('form')(); 42 | } 43 | -------------------------------------------------------------------------------- /pages/src/preloads.js: -------------------------------------------------------------------------------- 1 | const fonts = [ 2 | '/pages/fonts/MaterialSymbolsRounded.woff2?v=2', 3 | '/pages/fonts/Saira.woff2?v=1', 4 | ]; 5 | 6 | document.head.append(...fonts.map(x => { 7 | const link = document.createElement('link'); 8 | link.rel = 'preload'; 9 | link.href = x; 10 | link.as = 'font'; 11 | link.type = 'font/woff2'; 12 | // preloads + cors + file:// are a trainwreck in the w3c spec. 13 | if (location.protocol !== 'file:') { 14 | link.crossOrigin = 'anonymous'; 15 | } 16 | return link; 17 | })); 18 | -------------------------------------------------------------------------------- /pages/src/welcome.mjs: -------------------------------------------------------------------------------- 1 | import * as common from './common.mjs'; 2 | 3 | common.enableSentry(); 4 | 5 | export async function main() { 6 | common.initInteractionListeners(); 7 | const version = await common.rpc.getVersion(); 8 | setTimeout(() => { 9 | document.documentElement.classList.add('animate'); 10 | setTimeout(() => document.documentElement.classList.add('fadeout'), 14000); 11 | document.querySelector('footer').textContent = `v${version}`; 12 | }, 200); 13 | } 14 | -------------------------------------------------------------------------------- /pages/system-message.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | System Message - Sauce for Zwift™ 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 |
22 |

Hello

23 |

Hello

24 |

Hello

25 |

Hello

26 |

Hello

27 |

Hello

28 |

Hello

29 |

Hello

30 |

Hello

31 |

Hello

32 |

Hello

33 |

Hello

34 |

Hello

35 |

Hello

36 |

Hello

37 |

Hello

38 |

Hello

39 |

Hello

40 |

Hello

41 |

Hello

42 |

Hello

43 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /pages/templates/analysis/activity-summary.html.tpl: -------------------------------------------------------------------------------- 1 |
2 | <% if (athleteData?.stats) { %> 3 | Start:{-humanTime(Date.now() - (athleteData.stats.elapsedTime * 1000), {html: true})-} 4 | Time:{-humanTimer(athleteData.stats.activeTime, {full: true, html: true})-} 5 | Distance:{-humanDistance(athleteData.state.distance, {suffix: true, html: true})-} 6 | <% } %> 7 |
8 | -------------------------------------------------------------------------------- /pages/templates/analysis/laps.html.tpl: -------------------------------------------------------------------------------- 1 | <% const hasLaps = !!(laps && laps.length); %> 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | <% if (hasLaps) { %> 16 | <% for (const [i, x] of laps.entries()) { %> 17 | 18 | 19 | 20 | 21 | <% if (settings.preferWkg && athleteData.athlete?.weight) { %> 22 | 24 | <% } else { %> 25 | 27 | <% } %> 28 | 29 | 30 | 31 | 32 | <% } %> 33 | <% } else { %> 34 | 35 | 36 | 37 | <% } %> 38 | 39 |
LapTimeDistancePowerPaceHRCoffee
{{i+1}}{-humanTimer(x.stats.activeTime, {long: true, ms: true, html: true})-}{-humanDistance(streams.distance[x.endIndex + 1] - streams.distance[x.startIndex], {suffix: true, html: true})-}{-humanWkg(x.stats.power.avg / athleteData.athlete?.weight, {suffix: true, html: true})-}{-humanPower(x.stats.power.avg, {suffix: true, html: true})-}{-humanPace(x.stats.speed.avg, {suffix: true, html: true, sport: x.sport})-}{-humanNumber(x.stats.hr.avg, {suffix: 'bpm', html: true})-}{-humanTimer(x.stats.coffeeTime, {long: true, html: true})-}
No Lap Data
40 | -------------------------------------------------------------------------------- /pages/templates/analysis/peak-efforts.html.tpl: -------------------------------------------------------------------------------- 1 |
2 | 10 | 11 | <% if (obj.peaks) { %> 12 | <% for (const [k, x] of Object.entries(peaks)) { %> 13 | 14 | 15 | 21 | 22 | <% } %> 23 | <% } %> 24 |
{-humanDuration(k, {html: true})-} 16 | {-formatter(x.avg)-} 17 | <% if (x.rank?.badge) { %> 18 | 19 | <% } %> 20 |
25 |
26 | -------------------------------------------------------------------------------- /pages/templates/analysis/segment-results.html.tpl: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <% for (const [i, x] of results.entries()) { %> 14 | 15 | 22 | 29 | 30 | 35 | <% if (x.powerType !== 'POWER_METER') { %> 36 | 37 | <% } %> 38 | 39 | 40 | 41 | <% } %> 42 |
PlaceNameTimePowerHRDate
16 | <% if (i < 3) { %> 17 | trophy 18 | <% } else { %> 19 | {-humanPlace(i + 1, {suffix: true, html: true})-} 20 | <% } %> 21 | 23 | {{x.firstName}} {{x.lastName}} 25 | <% if (x.gender === 'female') { %> 26 | female 27 | <% } %> 28 | {-humanTimer(x.elapsed, {long: true, ms: true, html: true})-} 31 | <% if (x.powerType !== 'POWER_METER') { %> 32 | ~ 33 | <% } %> 34 | {-humanPower(x.avgPower, {suffix: true, html: true})-}{-humanNumber(x.avgHR || undefined, {suffix: 'bpm', html: true})-}{{humanRelTime(x.ts, {short: true, maxParts: 1})}}
43 |
44 | -------------------------------------------------------------------------------- /pages/templates/analysis/segments.html.tpl: -------------------------------------------------------------------------------- 1 | <% const hasSegments = !!(segments && segments.length); %> 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | <% if (hasSegments) { %> 15 | <% for (const [i, x] of segments.entries()) { %> 16 | 17 | 18 | 19 | 20 | <% if (settings.preferWkg && athleteData.athlete?.weight) { %> 21 | 23 | <% } else { %> 24 | 26 | <% } %> 27 | 28 | 29 | 30 | 31 | <% } %> 32 | <% } else { %> 33 | 34 | 35 | 36 | <% } %> 37 | 38 | 39 | -------------------------------------------------------------------------------- /pages/templates/athlete-cards.html.tpl: -------------------------------------------------------------------------------- 1 | <% for (const x of obj) { %> 2 | <% const athlete = x.athlete || {}; %> 3 | 5 | <% if (x.athlete) { %> 6 |
7 | <% if (athlete.avatar) { %> 8 | 9 | <% } else {%> 10 | 11 | <% } %> 12 |
13 |
{{athlete.sanitizedFullname}}
14 | <% } else if (x.profile) { %> 15 | <% const p = x.profile; %> 16 |
17 | <% if (p.imageSrcLarge || p.imageSrc) { %> 18 | 19 | <% } else {%> 20 | 21 | <% } %> 22 |
23 |
{{p.firstName}} {{p.lastName}}
24 | <% } else { %> 25 | Justin messed up :( 26 | <% } %> 27 |
28 | <% } %> 29 | -------------------------------------------------------------------------------- /pages/templates/events/list.html.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | <% for (const event of events) { %> 17 | {-embed(templates.eventsSummary, {event, eventBadge})-} 18 | 19 | <% } %> 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /pages/templates/events/summary.html.tpl: -------------------------------------------------------------------------------- 1 | <% const started = event.ts < Date.now(); %> 2 | <% const joinable = event.ts + ((event.lateJoinInMinutes || 0) * 60 * 1000) - Date.now(); %> 3 | 8 | 0) { %> 10 | title="Can late join until {{humanTime(event.ts + ((event.lateJoinInMinutes || 0) * 60 * 1000))}}" 11 | <% } %>> 12 | {-humanDateTime(event.eventStart, {html: true, concise: true, style: 'short', today_style: 'short'})-} 13 | <% if (event.lateJoinInMinutes) { %> 14 | acute 15 | <% } %> 16 | 17 | 18 | {{event.prettyTypeShort}} 19 | <% if (event.sport === 'running') { %> 20 | directions_run 21 | <% } %> 22 | 23 | {{event.name}} 24 | <% if (event.sameRouteName && event.route?.name) { %> 25 | route {{event.route?.name}} 26 | <% } else { %> 27 | - 28 | <% } %> 29 | <% if (event.durations.length) { %> 30 | <% if (event.durations.length > 1) { %> 31 | {-humanDuration(event.durations[0], {html: true, short: true})-} - 32 | {-humanDuration(event.durations.at(-1), {html: true, short: true})-} 33 | <% } else { %> 34 | {-humanDuration(event.durations[0], {html: true})-} 35 | <% } %> 36 | <% } else if (event.distances.length) { %> 37 | <% if (event.distances.length > 1) { %> 38 | {-humanDistance(event.distances[0])-} - 39 | {-humanDistance(event.distances.at(-1), {suffix: true, html: true})-} 40 | <% } else { %> 41 | {-humanDistance(event.distances[0], {suffix: true, html: true})-} 42 | <% } %> 43 | <% } else { %> 44 | {{console.warn("Event duration/distance bug:", event)}} 45 | - 46 | <% } %> 47 | {-humanElevation(event.routeClimbing, {suffix: true, html: true})-} 48 | 49 | <% if (event.eventSubgroups) { %> 50 | {-event.eventSubgroups.map(x => eventBadge(x.subgroupLabel)).join('')-} 51 | <% if (event.cullingType === 'CULLING_EVENT_ONLY') { %> 52 | group_work 53 | <% } else if (event.cullingType === 'CULLING_SUBGROUP_ONLY') { %> 54 | workspaces 55 | <% } %> 56 | <% } %> 57 | 58 | 59 | {{event.totalEntrantCount}}<% if (event.followeeEntrantCount) { %>, 60 | follow_the_signs {{event.followeeEntrantCount}} 61 | <% } %> 62 | 63 | 64 | -------------------------------------------------------------------------------- /pages/templates/segment-results.html.tpl: -------------------------------------------------------------------------------- 1 |
2 | <% for (const [i, x] of results.entries()) { %> 3 |
4 |
5 | <% if (i < 3) { %> 6 | trophy 7 | <% } else { %> 8 | {-humanPlace(i + 1, {suffix: true, html: true})-} 9 | <% } %> 10 |
11 |
12 | {{x.firstName}} {{x.lastName}} 14 | <% if (x.gender === 'female') { %> 15 | female 16 | <% } %> 17 |
18 |
19 | <% if (x.powerType !== 'POWER_METER') { %> 20 | ~ 21 | <% } %> 22 | {-humanPower(x.avgPower, {suffix: true, html: true})-} 23 | <% if (x.powerType !== 'POWER_METER') { %> 24 | 25 | <% } %> 26 |
27 |
{-humanTimer(x.elapsed, {long: true, ms: true, html: true})-}
28 | 29 |
{{humanRelTime(x.ts, {short: true, maxParts: 1})}}
30 |
31 | <% } %> 32 |
33 | -------------------------------------------------------------------------------- /pages/watching-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Grid", 3 | "name": "Sauce for Zwift™ - Grid", 4 | "background_color": "black", 5 | "theme_color": "black", 6 | "description": "Sauce for Zwift - Grid Window", 7 | "start_url": "/pages/watching.html", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [{ 11 | "src": "/pages/images/icon256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, { 15 | "src": "/pages/images/icon128.png", 16 | "sizes": "128x128", 17 | "type": "image/png" 18 | }, { 19 | "src": "/pages/images/icon64.png", 20 | "sizes": "64x64", 21 | "type": "image/png" 22 | }, { 23 | "src": "/pages/images/icon32.png", 24 | "sizes": "32x32", 25 | "type": "image/png" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /pages/watching.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Grid - Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 |
26 | 27 |
Grid
28 |
29 |
30 | settings 32 |
minimize
33 |
close
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | 54 | 55 | -------------------------------------------------------------------------------- /pages/welcome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Welcome to Sauce for Zwift™ 10 | 11 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 22 | 25 |
26 |
27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | Sauce 37 | for 38 | Zwift 39 | TM 40 | 41 | 42 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /shared/deps/.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | -------------------------------------------------------------------------------- /shared/deps/Makefile: -------------------------------------------------------------------------------- 1 | 2 | rwildcard=$(foreach d,$(wildcard $(1:=/*)),$(call rwildcard,$d,$2) $(filter $(subst *,%,$2),$d)) 3 | 4 | ZU_SRCS = $(call rwildcard,../../node_modules/zwift-utils/dist,*.json) 5 | ZU_OBJS = $(patsubst ../../node_modules/zwift-utils/dist/%,data/%,$(ZU_SRCS)) 6 | 7 | WORLD_SRC = ../../node_modules/world_countries_lists/data/countries/_combined/world.json 8 | WORLD_OBJ = data/countries.json 9 | 10 | default: $(ZU_OBJS) $(WORLD_OBJ) 11 | 12 | 13 | $(ZU_OBJS): $(ZU_SRCS) Makefile 14 | mkdir -p $(@D) 15 | node ../../tools/bin/jsonminify $(patsubst data/%,../../node_modules/zwift-utils/dist/%,$@) $@ 16 | 17 | 18 | $(WORLD_OBJ): $(WORLD_SRC) Makefile 19 | mkdir -p $(@D) 20 | cp $< $@ 21 | 22 | 23 | clean: 24 | rm -rf data 25 | -------------------------------------------------------------------------------- /shared/sauce/browser.mjs: -------------------------------------------------------------------------------- 1 | 2 | 3 | export function throttledAnimationFrame() { 4 | let nextFrame; 5 | return function(callback) { 6 | if (nextFrame) { 7 | cancelAnimationFrame(nextFrame); 8 | } 9 | nextFrame = requestAnimationFrame(() => { 10 | nextFrame = null; 11 | callback(); 12 | }); 13 | }; 14 | } 15 | 16 | 17 | export function downloadBlob(blob, name) { 18 | const url = URL.createObjectURL(blob); 19 | try { 20 | downloadURL(url, name || blob.name); 21 | } finally { 22 | URL.revokeObjectURL(url); 23 | } 24 | } 25 | 26 | 27 | export function downloadURL(url, name) { 28 | const link = document.createElement('a'); 29 | link.href = url; 30 | link.download = name; 31 | link.style.display = 'none'; 32 | document.body.appendChild(link); 33 | try { 34 | link.click(); 35 | } finally { 36 | link.remove(); 37 | } 38 | } 39 | 40 | 41 | const _fetchCache = new Map(); 42 | const _fetching = new Map(); 43 | export async function cachedFetch(url, options={}) { 44 | if (!_fetchCache.has(url)) { 45 | if (!_fetching.has(url)) { 46 | _fetching.set(url, fetch(url).then(async resp => { 47 | if (!resp.ok) { 48 | if (resp.status === 404) { 49 | console.warn("Not found:", url); 50 | _fetchCache.set(url, undefined); 51 | return; 52 | } 53 | throw new Error('Fetch HTTP failure: ' + resp.status); 54 | } 55 | let data; 56 | if (options.mode === 'json') { 57 | data = await resp.json(); 58 | } else if (options.mode === 'blob') { 59 | data = await resp.blob(); 60 | } else if (options.mode === 'arrayBuffer') { 61 | data = await resp.arrayBuffer(); 62 | } else { 63 | data = await resp.text(); 64 | } 65 | _fetchCache.set(url, data); 66 | }).finally(() => _fetching.delete(url))); 67 | } 68 | await _fetching.get(url); 69 | } 70 | return _fetchCache.get(url); 71 | } 72 | -------------------------------------------------------------------------------- /shared/sauce/index.mjs: -------------------------------------------------------------------------------- 1 | export * from './base.mjs'; 2 | export * as data from './data.mjs'; 3 | export * as power from './power.mjs'; 4 | export * as geo from './geo.mjs'; 5 | export * as perf from './perf.mjs'; 6 | export * as locale from './locale.mjs'; 7 | export * as browser from './browser.mjs'; 8 | export * as template from './template.mjs'; 9 | -------------------------------------------------------------------------------- /shared/sauce/pace.mjs: -------------------------------------------------------------------------------- 1 | 2 | import {RollingBase} from './data.mjs'; 3 | 4 | 5 | export class RollingPace extends RollingBase { 6 | distance(options) { 7 | options = options || {}; 8 | const offt = (options.offt || 0) + this._offt; 9 | const start = this._values[offt]; 10 | const end = this._values[this._length - 1]; 11 | if (start != null && end != null) { 12 | return end - start; 13 | } 14 | } 15 | 16 | avg() { 17 | const dist = this.distance(); 18 | const elapsed = this.elapsed(); 19 | if (!dist || !elapsed) { 20 | return; 21 | } 22 | return elapsed / dist; 23 | } 24 | 25 | full(options) { 26 | options = options || {}; 27 | const offt = options.offt; 28 | return this.distance({offt}) >= this.period; 29 | } 30 | } 31 | 32 | 33 | export function bestPace(distance, timeStream, distStream) { 34 | if (timeStream.length < 2 || distance[distance.length - 1] < distance) { 35 | return; 36 | } 37 | const roll = new RollingPace(distance); 38 | return roll.importReduce(timeStream, distStream, (cur, lead) => cur.avg() <= lead.avg()); 39 | } 40 | 41 | 42 | export function work(weight, dist, isWalking) { 43 | const cost = isWalking ? 2 : 4.35; // Hand tuned by intuition 44 | const j = cost / ((1 / weight) * (1 / dist)); 45 | const humanMechFactor = 0.24; // Human mechanical efficiency percentage 46 | const kj = j * humanMechFactor / 1000; 47 | return kj; 48 | } 49 | -------------------------------------------------------------------------------- /shared/sauce/perf.mjs: -------------------------------------------------------------------------------- 1 | 2 | export function calcTRIMP(duration, hrr, gender) { 3 | const y = hrr * (gender === 'female' ? 1.67 : 1.92); 4 | return (duration / 60) * hrr * 0.64 * Math.exp(y); 5 | } 6 | 7 | 8 | /* TRIMP based TSS, more accurate than hrTSS. 9 | * See: https://fellrnr.com/wiki/TRIMP 10 | */ 11 | export function tTSS(hrStream, timeStream, activeStream, ltHR, minHR, maxHR, gender) { 12 | let t = 0; 13 | let lastTime = timeStream[0]; 14 | for (let i = 1; i < timeStream.length; i++) { 15 | if (!activeStream[i]) { 16 | lastTime = timeStream[i]; 17 | continue; 18 | } 19 | const dur = timeStream[i] - lastTime; 20 | lastTime = timeStream[i]; 21 | const hrr = (hrStream[i] - minHR) / (maxHR - minHR); 22 | t += calcTRIMP(dur, hrr, gender); 23 | } 24 | const tHourAtLT = calcTRIMP(3600, (ltHR - minHR) / (maxHR - minHR), gender); 25 | return (t / tHourAtLT) * 100; 26 | } 27 | 28 | 29 | export function estimateRestingHR(ftp) { 30 | // Use handwavy assumption that high FTP = low resting HR. 31 | const baselineW = 300; 32 | const baselineR = 50; 33 | const range = 20; 34 | const delta = ftp - baselineW; 35 | const diff = range * (delta / baselineW); 36 | return baselineR - diff; 37 | } 38 | 39 | 40 | export function estimateMaxHR(zones) { 41 | // Estimate max from inner zone ranges. 42 | const avgRange = ((zones.z4 - zones.z3) + (zones.z3 - zones.z2)) / 2; 43 | return zones.z4 + avgRange; 44 | } 45 | 46 | 47 | // See: 48 | // https://www.trainerroad.com/forum/t/tss-spreadsheets-with-atl-ctl-form/7613/10 49 | // http://www.timetriallingforum.co.uk/index.php?/topic/74961-calculating-ctl-and-atl/#comment-1045764 50 | const chronicTrainingLoadConstant = 42; 51 | const acuteTrainingLoadConstant = 7; 52 | 53 | function _makeExpWeightedCalc(size) { 54 | const c = 1 - Math.exp(-1 / size); 55 | return function(data, seed=0) { 56 | let v = seed; 57 | for (const x of data) { 58 | v = (v * (1 - c)) + (x * c); 59 | } 60 | return v; 61 | }; 62 | } 63 | 64 | export const calcCTL = _makeExpWeightedCalc(chronicTrainingLoadConstant); 65 | export const calcATL = _makeExpWeightedCalc(acuteTrainingLoadConstant); 66 | 67 | export function expWeightedAvg(size, data, seed) { 68 | return _makeExpWeightedCalc(size)(data, seed); 69 | } 70 | 71 | 72 | export function makeExpWeightedAccumulator(fixedSize, seed=0) { 73 | let v = seed; 74 | const fixedC = fixedSize ? 1 - Math.exp(-1 / fixedSize) : null; 75 | return function(x, size) { 76 | const c = size ? (1 - Math.exp(-1 / size)) : fixedC; 77 | v = (v * (1 - c)) + (x * c); 78 | return v; 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /src/db.mjs: -------------------------------------------------------------------------------- 1 | import fs from './fs-safe.js'; 2 | import process from 'node:process'; 3 | import Database from 'better-sqlite3'; 4 | 5 | export const databases = new Map(); 6 | 7 | 8 | export class SqliteDatabase extends Database { 9 | constructor(name, {tables, indexes={}, ...options}={}) { 10 | console.info("Opening DB:", name); 11 | super(name, options); 12 | this.pragma('journal_mode = WAL'); // improve performance substantially 13 | for (const [table, schema] of Object.entries(tables)) { 14 | const schemaText = Object.entries(schema).map(([col, type]) => `${col} ${type}`).join(', '); 15 | this.prepare(`CREATE TABLE IF NOT EXISTS ${table} (${schemaText})`).run(); 16 | } 17 | for (const [index, x] of Object.entries(indexes)) { 18 | this.prepare(`CREATE ${x.unique ? 'UNIQUE' : ''} INDEX IF NOT EXISTS ${index} ON ` + 19 | `${x.table} (${x.columns.join(',')})`).run(); 20 | } 21 | databases.set(name, this); 22 | } 23 | } 24 | 25 | 26 | export function deleteDatabase(name) { 27 | const db = databases.get(name); 28 | if (db) { 29 | console.warn(`Closing DB [via delete]:`, name); 30 | db.close(); 31 | databases.delete(name); 32 | } 33 | console.warn(`Deleting DB:`, name); 34 | fs.rmSync(name, {force: true}); 35 | } 36 | 37 | 38 | function shutdown(origin) { 39 | const dbs = Array.from(databases.values()); 40 | databases.clear(); 41 | for (const db of dbs) { 42 | console.warn(`Closing DB [via ${origin}]:`, db.name); 43 | try { 44 | db.close(); 45 | } catch(e) { 46 | console.error(e); 47 | } 48 | } 49 | } 50 | 51 | // NOTE: we don't handle kill signal's because it's only useful 52 | // in dev and can cause interop problems. 53 | process.on('exit', shutdown); 54 | -------------------------------------------------------------------------------- /src/fs-safe.js: -------------------------------------------------------------------------------- 1 | const node = require('node:fs'); 2 | 3 | 4 | const _emptySharedArray = new Int32Array(new SharedArrayBuffer(4)); 5 | function sleepSync(ms) { 6 | Atomics.wait(_emptySharedArray, 0, 0, ms); 7 | } 8 | 9 | 10 | function rmSync(path, {maxRetries=10, recursive, ...options}={}) { 11 | recursive = recursive == null ? !!maxRetries : recursive; 12 | return node.rmSync(path, {...options, maxRetries, recursive}); 13 | } 14 | 15 | 16 | function renameSync(oldPath, newPath, {maxRetries=10}={}) { 17 | const delay = 100; 18 | let error; 19 | for (let i = 0; i < maxRetries; i++) { 20 | try { 21 | return node.renameSync(oldPath, newPath); 22 | } catch(e) { 23 | if (e.errno === -4048 && e.code === 'EPERM') { 24 | error = e; 25 | sleepSync(delay * (2 ** i)); 26 | continue; 27 | } 28 | throw e; 29 | } 30 | } 31 | throw error; 32 | } 33 | 34 | 35 | module.exports = { 36 | ...node, 37 | rmSync, 38 | renameSync, 39 | sleepSync, 40 | }; 41 | -------------------------------------------------------------------------------- /src/mime.mjs: -------------------------------------------------------------------------------- 1 | export const mimeTypes = { 2 | 'application/gzip': ['gz'], 3 | 'application/javascript': ['js', 'mjs'], 4 | 'application/json': ['json', 'map'], 5 | 'application/pdf': ['pdf'], 6 | 'application/postscript': ['ps', 'eps', 'ai'], 7 | 'application/zip': ['zip'], 8 | 'audio/mp3': ['mp3'], 9 | 'audio/mp4': ['m4a', 'mp4a'], 10 | 'audio/mpeg': ['mpga', 'mp2a', 'm2a'], 11 | 'audio/ogg': ['ogg', 'oga', 'spx', 'opus'], 12 | 'font/woff': ['woff'], 13 | 'font/woff2': ['woff2'], 14 | 'image/apng': ['apng'], 15 | 'image/avif': ['avif'], 16 | 'image/bmp': ['bmp'], 17 | 'image/gif': ['gif'], 18 | 'image/heic': ['heic'], 19 | 'image/heif': ['heif'], 20 | 'image/jp2': ['jp2', 'jpg2'], 21 | 'image/jpeg': ['jpg', 'jpeg', 'jpe'], 22 | 'image/png': ['png'], 23 | 'image/svg+xml': ['svg'], 24 | 'image/tiff': ['tif', 'tiff'], 25 | 'image/webp': ['webp'], 26 | 'image/wmf': ['wmf'], 27 | 'text/css': ['css'], 28 | 'text/csv': ['csv'], 29 | 'text/html': ['html', 'htm'], 30 | 'text/markdown': ['md', 'markdown'], 31 | 'text/plain': ['txt', 'text', 'conf', 'log', 'ini'], 32 | 'text/x-scss': ['scss'], 33 | 'text/xml': ['xml'], 34 | 'text/yaml': ['yaml', 'yml'], 35 | 'video/mp4': ['mp4', 'mp4v'], 36 | 'video/mpeg': ['mpeg', 'mpg', 'mpe', 'm2v', 'm1v'], 37 | 'video/ogg': ['ogv'], 38 | 'video/quicktime': ['qt', 'mov'], 39 | 'video/vnd.mpegurl': ['mxu', 'm4u'], 40 | 'video/webm': ['webm'], 41 | 'video/x-matroska': ['mkv'], 42 | }; 43 | 44 | export const mimeTypesByExt = new Map(); 45 | for (const [mime, exts] of Object.entries(mimeTypes)) { 46 | for (const x of exts) { 47 | mimeTypesByExt.set(x, mime); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/patreon.mjs: -------------------------------------------------------------------------------- 1 | import * as storage from './storage.mjs'; 2 | import Sentry from '@sentry/node'; 3 | import fetch from 'node-fetch'; 4 | 5 | export class NonMember extends Error {} 6 | 7 | 8 | async function _api(res, options) { 9 | const r = await fetch('https://api.saucellc.io' + res, options); 10 | const body = await r.text(); 11 | const data = body ? JSON.parse(body) : null; 12 | if (r.status === 404) { 13 | throw new NonMember(); 14 | } else if (!r.ok) { 15 | throw new Error(JSON.stringify({status: r.status, data}, null, 4)); 16 | } else { 17 | return data; 18 | } 19 | } 20 | 21 | 22 | export async function link(code, options={}) { 23 | storage.set('patreon-auth', null); 24 | let auth; 25 | try { 26 | auth = await _api('/patreon/auth', { 27 | method: 'POST', 28 | headers: { 29 | 'x-sauce-app': 'zwift', 30 | 'x-sauce-version': options.legacy ? '' : 2, 31 | }, 32 | body: JSON.stringify({code}), 33 | }); 34 | } catch(e) { 35 | if (!(e instanceof NonMember)) { 36 | Sentry.captureException(e); 37 | throw e; 38 | } 39 | return false; 40 | } 41 | storage.set('patreon-auth', auth); 42 | return true; 43 | } 44 | 45 | 46 | export function getUserId(options={}) { 47 | const auth = storage.get('patreon-auth'); 48 | if (auth) { 49 | return auth.id; 50 | } 51 | } 52 | 53 | 54 | export async function getMembership(options={}) { 55 | const auth = storage.get('patreon-auth'); 56 | if (!auth) { 57 | throw new TypeError('Patreon link not established'); 58 | } 59 | const q = options.detailed ? 'detailed=1' : ''; 60 | const r = await fetch(`https://api.saucellc.io/patreon/membership?${q}`, { 61 | headers: { 62 | 'x-sauce-app': 'zwift', 63 | 'x-sauce-version': options.legacy ? '' : 2, 64 | Authorization: `${auth.id} ${auth.secret}` 65 | } 66 | }); 67 | if (!r.ok) { 68 | if ([401, 403].includes(r.status)) { 69 | storage.set('patreon-auth', null); 70 | } else if (r.status !== 404) { 71 | throw new Error('Failed to get patreon membership: ' + r.status); 72 | } 73 | return null; 74 | } else { 75 | return await r.json(); 76 | } 77 | } 78 | 79 | export async function getLegacyMembership(token) { 80 | const r = await fetch('https://www.sauce.llc/patrons.json'); 81 | const patrons = await r.json(); 82 | if (patrons[token]) { 83 | return { 84 | patronLevel: patrons[token].level, 85 | }; 86 | } else { 87 | return null; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/preload/common.js: -------------------------------------------------------------------------------- 1 | const {ipcRenderer, contextBridge, webFrame} = require('electron'); 2 | 3 | 4 | ipcRenderer.on('subscribe-port', (ev, subId) => 5 | window.postMessage({channel: 'subscribe-port', subId}, '*', ev.ports)); 6 | 7 | ipcRenderer.on('sauce-highlight-window', () => { 8 | console.debug("Highlight window request"); 9 | const doc = document.documentElement; 10 | if (!doc || !document.body) { 11 | return; 12 | } 13 | if (document.body.classList.contains('transparent-bg')) { 14 | document.body.classList.remove('transparent-bg'); 15 | setTimeout(() => document.body.classList.add('transparent-bg'), 3000); 16 | } 17 | doc.classList.remove('highlight-window'); 18 | doc.offsetWidth; // force layout 19 | doc.classList.add('highlight-window'); 20 | }); 21 | 22 | 23 | const meta = ipcRenderer.sendSync('getWindowMetaSync'); 24 | contextBridge.exposeInMainWorld('electron', { 25 | context: meta.context, 26 | ipcInvoke: ipcRenderer.invoke.bind(ipcRenderer), 27 | }); 28 | contextBridge.exposeInMainWorld('isElectron', true); 29 | 30 | document.addEventListener('click', ev => { 31 | const link = ev.target.closest('a[external][href]'); 32 | if (link) { 33 | ev.preventDefault(); 34 | ipcRenderer.invoke('rpc', 'openExternalLink', link.href).catch(e => 35 | console.error('Error opening external page:', e)); 36 | } 37 | }); 38 | 39 | if (meta.internal) { 40 | const onReadyStateChange = ev => { 41 | if (document.readyState === 'interactive') { 42 | // Do some important DOM work before first paint to avoid flashing 43 | document.removeEventListener('readystatechange', onReadyStateChange); 44 | const doc = document.documentElement; 45 | doc.classList.add('electron-mode'); 46 | doc.classList.toggle('frame', !!meta.context.frame); 47 | doc.dataset.platform = meta.context.platform; 48 | const theme = localStorage.getItem('/theme'); 49 | if (theme) { 50 | doc.dataset.theme = JSON.parse(theme); 51 | } 52 | } 53 | }; 54 | // Fires for interactive before defer scripts. 55 | document.addEventListener('readystatechange', onReadyStateChange); 56 | 57 | if (meta.modContentScripts && meta.modContentScripts.length) { 58 | for (const x of meta.modContentScripts) { 59 | try { 60 | webFrame.executeJavaScript(x); 61 | } catch(e) { 62 | console.error("Mod content script error:", e); 63 | } 64 | } 65 | } 66 | 67 | if (meta.modContentStylesheets && meta.modContentStylesheets.length) { 68 | for (const x of meta.modContentStylesheets) { 69 | try { 70 | webFrame.insertCSS(x); 71 | } catch(e) { 72 | console.error("Mod content stylesheet error:", e); 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/preload/patron-link.js: -------------------------------------------------------------------------------- 1 | const {ipcRenderer, contextBridge} = require('electron'); 2 | 3 | const authUrl = 'https://www.patreon.com/oauth2/authorize'; 4 | const authArgs = { 5 | response_type: 'code', 6 | client_id: '5pxCmg6NBYOjHDVBL8XWJ4tzbwb_LxFO_pUDONDlZkPD0EOnz2NfRDUblE6J2k-C', 7 | scope: 'identity campaigns.members', 8 | }; 9 | 10 | const meta = ipcRenderer.sendSync('getWindowMetaSync'); 11 | contextBridge.exposeInMainWorld('isElectron', true); 12 | contextBridge.exposeInMainWorld('electron', { 13 | context: { 14 | ...meta.context, 15 | id: 'patron-link', 16 | spec: {}, 17 | }, 18 | ipcInvoke: (...args) => ipcRenderer.invoke(...args), 19 | closeWindow: () => window.close(), 20 | }); 21 | 22 | // Proxy the code from our public page to the renderer process for further processing. 23 | // The renderer will bounce them to the proper internal page immediately. 24 | document.addEventListener('patreon-auth-code', ev => 25 | void ipcRenderer.send('patreon-auth-code', ev.detail)); 26 | 27 | document.addEventListener('patreon-reset-session', ev => 28 | void ipcRenderer.send('patreon-reset-session')); 29 | 30 | document.addEventListener('click', ev => { 31 | const link = ev.target.closest('a[external][href]'); 32 | if (link) { 33 | ev.preventDefault(); 34 | ipcRenderer.invoke('rpc', 'openExternalLink', link.href).catch(e => 35 | console.error('Error opening external page:', e)); 36 | } 37 | }); 38 | 39 | document.addEventListener('DOMContentLoaded', () => { 40 | for (const x of document.querySelectorAll('.button.patron-link')) { 41 | const q = new URLSearchParams({ 42 | ...authArgs, 43 | redirect_uri: 'https://www.sauce.llc/sauce4zwift-patron-link-v2', 44 | }); 45 | x.href = `${authUrl}?${q}`; 46 | x.addEventListener('click', () => { 47 | // Slight delay to avoid flashing new content while an external window is opening 48 | setTimeout(() => location.assign('patron-waiting.html'), 1000); 49 | }); 50 | } 51 | for (const x of document.querySelectorAll('.button.patron-link-legacy')) { 52 | x.addEventListener('click', () => { 53 | const q = new URLSearchParams({ 54 | ...authArgs, 55 | redirect_uri: 'https://saucellc.io/sauce4zwift-patron-link', 56 | }); 57 | location.assign(`${authUrl}?${q}`); 58 | }, {capture: true}); 59 | } 60 | const special = document.querySelector('#specialtoken'); 61 | if (special) { 62 | special.addEventListener('submit', ev => { 63 | ev.preventDefault(); 64 | const token = ev.currentTarget.querySelector('input[name="specialtoken"]').value; 65 | ipcRenderer.send('patreon-special-token', token); 66 | }); 67 | } 68 | }); 69 | -------------------------------------------------------------------------------- /src/preload/storage-proxy.js: -------------------------------------------------------------------------------- 1 | const {ipcRenderer} = require('electron'); 2 | 3 | ipcRenderer.on('export', ev => 4 | ev.sender.send('response', JSON.parse(JSON.stringify({localStorage})))); 5 | 6 | ipcRenderer.on('import', (ev, storage) => { 7 | let success = true; 8 | try { 9 | localStorage.clear(); 10 | for (const [key, value] of Object.entries(storage.localStorage)) { 11 | localStorage.setItem(key, value); 12 | } 13 | } catch(e) { 14 | console.error('Import error:', e); 15 | success = false; 16 | } 17 | ev.sender.send('response', success); 18 | }); 19 | -------------------------------------------------------------------------------- /src/preload/webview.js: -------------------------------------------------------------------------------- 1 | const {ipcRenderer} = require('electron'); 2 | 3 | // TODO: Register these events based on requests from the host renderer. 4 | 5 | addEventListener('contextmenu', ev => { 6 | ipcRenderer.sendToHost('interaction', ev.type); 7 | }); 8 | 9 | addEventListener('mouseup', ev => { 10 | if (ev.button === 3 || ev.button === 4) { 11 | ipcRenderer.sendToHost('interaction', 'navigate', { 12 | direction: ev.button === 3 ? 'back' : 'forward', 13 | }); 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /src/preload/zwift-login.js: -------------------------------------------------------------------------------- 1 | const {ipcRenderer} = require('electron'); 2 | 3 | document.addEventListener('DOMContentLoaded', () => { 4 | const form = document.querySelector('form'); 5 | form.addEventListener('submit', ev => { 6 | ev.preventDefault(); 7 | ipcRenderer.send('zwift-creds', 8 | Object.fromEntries(Array.from(form.elements).map(x => [x.name, x.value]))); 9 | document.documentElement.classList.add('validating'); 10 | document.querySelector('form .error').innerHTML = ''; 11 | }); 12 | }); 13 | 14 | ipcRenderer.on('validation-error', (ev, status) => { 15 | document.documentElement.classList.remove('validating'); 16 | document.querySelector('form .error').textContent = status; 17 | }); 18 | -------------------------------------------------------------------------------- /src/rpc.mjs: -------------------------------------------------------------------------------- 1 | export const handlers = new Map(); 2 | 3 | 4 | export function errorReply(e, extra) { 5 | console.warn("RPC error:", e); 6 | return { 7 | ...extra, 8 | success: false, 9 | error: { 10 | name: e.name, 11 | message: e.message, 12 | stack: e.stack, 13 | } 14 | }; 15 | } 16 | 17 | 18 | export function successReply(value, extra) { 19 | return { 20 | ...extra, 21 | success: true, 22 | value 23 | }; 24 | } 25 | 26 | 27 | export async function invoke() { 28 | try { 29 | return await _invoke.apply(this, arguments); 30 | } catch(e) { 31 | return errorReply(e); 32 | } 33 | } 34 | 35 | 36 | async function _invoke(name, ...args) { 37 | const handler = handlers.get(name); 38 | if (!handler) { 39 | throw new Error('Invalid handler name: ' + name); 40 | } 41 | const warning = handler.warning; // deprecation, etc 42 | if (warning) { 43 | console.warn(warning); 44 | } 45 | try { 46 | return successReply(await handler.fn.call(handler.scope || this, ...args), {warning}); 47 | } catch(e) { 48 | return errorReply(e, {warning}); 49 | } 50 | } 51 | 52 | 53 | export function register(fn, options={}) { 54 | const name = options.name || fn.name; 55 | if (!name) { 56 | throw new TypeError("Function name could not be inferred, use options.name"); 57 | } 58 | let warning; 59 | if (options.deprecatedBy) { 60 | warning = `DEPRECATED RPC [${name}]: migrate to -> ${options.deprecatedBy.name}`; 61 | } else if (options.deprecated) { 62 | warning = `DEPRECATED RPC [${name}]`; 63 | } 64 | handlers.set(options.name || fn.name, {fn, warning, scope: options.scope}); 65 | } 66 | -------------------------------------------------------------------------------- /src/secrets.mjs: -------------------------------------------------------------------------------- 1 | import keytar from 'keytar'; 2 | 3 | const service = 'Zwift Credentials - Sauce for Zwift'; 4 | 5 | 6 | export async function get(key) { 7 | if (!key) { 8 | throw new TypeError('key required'); 9 | } 10 | const raw = await keytar.getPassword(service, key); 11 | return raw ? JSON.parse(raw) : undefined; 12 | } 13 | 14 | 15 | export async function set(key, data) { 16 | if (!key || !data) { 17 | throw new TypeError('key and data required'); 18 | } 19 | await keytar.setPassword(service, key, JSON.stringify(data)); 20 | } 21 | 22 | 23 | export async function remove(key) { 24 | if (!key) { 25 | throw new TypeError('key required'); 26 | } 27 | return await keytar.deletePassword(service, key); 28 | } 29 | -------------------------------------------------------------------------------- /src/storage.mjs: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import {SqliteDatabase, deleteDatabase} from './db.mjs'; 3 | 4 | 5 | let _initialized = false; 6 | let _dir; 7 | let _db; 8 | 9 | 10 | function getName() { 11 | if (!_initialized) { 12 | throw new Error("initialize(...) required before use"); 13 | } 14 | return path.join(_dir, 'storage.sqlite'); 15 | } 16 | 17 | 18 | function getDB() { 19 | if (_db) { 20 | return _db; 21 | } 22 | _db = new SqliteDatabase(getName(), { 23 | tables: { 24 | store: { 25 | id: 'TEXT PRIMARY KEY', 26 | data: 'TEXT', 27 | } 28 | } 29 | }); 30 | return _db; 31 | } 32 | 33 | 34 | export function initialize(dir) { 35 | if (_initialized) { 36 | throw new Error("Already initialized"); 37 | } 38 | _dir = dir; 39 | _initialized = true; 40 | } 41 | 42 | 43 | export function reset() { 44 | deleteDatabase(getName()); 45 | } 46 | 47 | 48 | export function get(key) { 49 | const db = getDB(); 50 | const r = db.prepare('SELECT data from store WHERE id = ?').get(key); 51 | return r ? JSON.parse(r.data) : undefined; 52 | } 53 | 54 | 55 | export function set(key, data) { 56 | const db = getDB(); 57 | db.prepare('INSERT OR REPLACE INTO store (id, data) VALUES(?, ?)').run(key, JSON.stringify(data)); 58 | } 59 | 60 | 61 | export function remove(key) { 62 | const db = getDB(); 63 | db.prepare('DELETE FROM store WHERE id = ?').run(key); 64 | } 65 | -------------------------------------------------------------------------------- /src/unzoom.mjs: -------------------------------------------------------------------------------- 1 | import * as mwc from 'macos-window-control'; 2 | import process from 'node:process'; 3 | 4 | const saucePid = Number(process.argv.at(-1)); 5 | 6 | if (!saucePid) { 7 | console.error("Missing PID argument"); 8 | process.exit(1); 9 | } 10 | 11 | 12 | function pidAlive(pid) { 13 | try { 14 | process.kill(pid, 0); 15 | return true; 16 | } catch(e) { 17 | return false; 18 | } 19 | } 20 | 21 | 22 | async function main() { 23 | while (true) { 24 | await new Promise(r => setTimeout(r, 400)); 25 | if (!pidAlive(saucePid)) { 26 | console.info("Sauce not running, unzooming..."); 27 | const displays = mwc.getDisplays(); 28 | for (const x of displays) { 29 | mwc.setZoom({scale: 1, displayId: x.id}); 30 | } 31 | process.exit(0); 32 | } 33 | } 34 | } 35 | 36 | main(); 37 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "test": "readonly", 4 | "expect": "readonly" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/lru.test.mjs: -------------------------------------------------------------------------------- 1 | import test from 'node:test'; 2 | import assert from 'node:assert'; 3 | import {LRUCache} from '../shared/sauce/index.mjs'; 4 | 5 | 6 | function assertCacheKeys(cache, keys) { 7 | assert.deepStrictEqual(new Set(cache.keys()), new Set(keys)); 8 | } 9 | 10 | 11 | test('lru replace', () => { 12 | const c = new LRUCache(10); 13 | c.set('A', 111); 14 | assert.strictEqual(c.get('A'), 111); 15 | c.set('A', 222); 16 | assert.strictEqual(c.get('A'), 222); 17 | }); 18 | 19 | test('lru fill exact', () => { 20 | const size = 4; 21 | const c = new LRUCache(size); 22 | for (let i = 0; i < size; i++) { 23 | c.set(i, i * 10); 24 | } 25 | for (let i = 0; i < size; i++) { 26 | assert.strictEqual(c.get(i), i * 10); 27 | } 28 | }); 29 | 30 | test('lru fill overflow 1', () => { 31 | const size = 4; 32 | const c = new LRUCache(size); 33 | for (let i = 0; i < size + 1; i++) { 34 | c.set(i, i * 10); 35 | } 36 | assert.strictEqual(c.get(0), undefined); 37 | for (let i = 1; i < size + 1; i++) { 38 | assert.strictEqual(c.get(i), i * 10); 39 | } 40 | }); 41 | 42 | test('lru fill overflow 1, reordered', () => { 43 | const size = 4; 44 | const c = new LRUCache(size); 45 | for (let i = 0; i < size; i++) { 46 | c.set(i, i * 10); 47 | } 48 | c.get(0); 49 | c.set(size, size * 10); 50 | assert.strictEqual(c.get(0), 0); 51 | assert.strictEqual(c.get(1), undefined); 52 | assert.strictEqual(c.get(2), 20); 53 | assert.strictEqual(c.get(3), 30); 54 | assert.strictEqual(c.get(4), 40); 55 | }); 56 | 57 | test('lru cache consistency', () => { 58 | const c = new LRUCache(3); 59 | c.set(1, true); 60 | c.set(2, true); 61 | c.set(3, true); 62 | c.set(4, true); // bump 1 63 | c.get(2); // 2 4 3 64 | assertCacheKeys(c, [2, 4, 3]); 65 | c.get(4); // 4 2 3 66 | assertCacheKeys(c, [4, 2, 3]); 67 | c.set(5, true); // 5 4 2 68 | assertCacheKeys(c, [5, 4, 2]); 69 | c.set(6, true); // 6 5 4 70 | assertCacheKeys(c, [6, 5, 4]); 71 | }); 72 | 73 | test('lru fuzz', () => { 74 | for (let _case = 0; _case < 50; _case++) { 75 | const ref = new Map(); 76 | const size = 1 + Math.random() * 100 | 0; 77 | const iterations = Math.random() * 1000 | 0; 78 | const cache = new LRUCache(size); 79 | const setPropensity = Math.abs(Math.sin(_case + 1)); 80 | for (let i = 0; i < iterations; i++) { 81 | const key = Math.random() * 1500 | 0; 82 | if (Math.random() < setPropensity) { 83 | cache.set(key, true); 84 | if (!ref.has(key) && ref.size === size) { 85 | const byAge = Array.from(ref.entries()).sort((a, b) => a[1] - b[1]); 86 | ref.delete(byAge[0][0]); 87 | } 88 | ref.set(key, i); 89 | } else { 90 | cache.get(key); 91 | if (ref.has(key)) { 92 | ref.set(key, i); 93 | } 94 | } 95 | assertCacheKeys(cache, ref.keys()); 96 | } 97 | } 98 | }); 99 | -------------------------------------------------------------------------------- /test/wbal.test.mjs: -------------------------------------------------------------------------------- 1 | import test from 'node:test'; 2 | import assert from 'node:assert'; 3 | import * as sauce from '../shared/sauce/index.mjs'; 4 | 5 | 6 | test('wbal sample rate accuracy', () => { 7 | const cp = 300; 8 | const wp = 20000; 9 | const incCalcNom = new sauce.power.makeIncWPrimeBalDifferential(cp, wp); 10 | const incCalcHalf = new sauce.power.makeIncWPrimeBalDifferential(cp, wp); 11 | const incCalcDbl = new sauce.power.makeIncWPrimeBalDifferential(cp, wp); 12 | const incCalcRnd = new sauce.power.makeIncWPrimeBalDifferential(cp, wp); 13 | let lastRnd = 0; 14 | const wbals = {}; 15 | for (let i = 0; i < 10000; i++) { 16 | const v = Math.sin(i / 1000) * (cp / 2) + cp; 17 | wbals.fine = incCalcHalf(v, 0.5); 18 | if (i % 2 === 0) { 19 | wbals.nom = incCalcNom(v, 1); 20 | } 21 | if (i % 4 === 0) { 22 | wbals.dbl = incCalcDbl(v, 2); 23 | } 24 | if (Math.random() < 0.8) { 25 | const t = i ? i - lastRnd : 1; 26 | lastRnd = i; 27 | wbals.rnd = incCalcRnd(v, t / 2); 28 | } 29 | if (i && (i % 10 === 0)) { 30 | assert(Math.abs(wbals.fine - wbals.nom) < 1000); 31 | assert(Math.abs(wbals.fine - wbals.dbl) < 1000); 32 | assert(Math.abs(wbals.fine - wbals.rnd) < 1000); 33 | } 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /tools/bin/buildenv: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require('node:fs'); 3 | const process = require('node:process'); 4 | const childProcess = require('node:child_process'); 5 | 6 | function shexec(command) { 7 | return new Promise((resolve, reject) => { 8 | childProcess.exec(command, (error, stdout, stderr) => { 9 | if (error) { 10 | reject(error); 11 | } else { 12 | resolve({stdout, stderr}); 13 | } 14 | }); 15 | }); 16 | } 17 | 18 | async function main() { 19 | if (process.argv.length < 3) { 20 | throw new Error("Missing OUTFILE argument"); 21 | } 22 | fs.writeFileSync(process.argv[2], JSON.stringify({ 23 | git_commit: (await shexec('git rev-parse HEAD')).stdout.trim(), 24 | sentry_dsn: process.env.SAUCE4ZWIFT_SENTRY_DSN, 25 | google_map_tile_key: process.env.SAUCE4ZWIFT_GOOGLE_MAP_TILE_KEY, 26 | })); 27 | } 28 | 29 | main().catch(e => { 30 | console.error(e); 31 | process.exit(1); 32 | }); 33 | -------------------------------------------------------------------------------- /tools/bin/jsonminify: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require('node:fs'); 3 | 4 | if (process.argv.length !== 4) { 5 | console.error(`Usage: ${process.argv[1]} INPUT_FILE OUTPUT_FILE`); 6 | process.exit(1); 7 | } else { 8 | const [origFile, minFile] = process.argv.slice(2); 9 | const orig = JSON.parse(fs.readFileSync(origFile)); 10 | fs.writeFileSync(minFile, JSON.stringify(orig)); 11 | } 12 | -------------------------------------------------------------------------------- /tools/bin/lintwatch: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | TOOLS=$(dirname $0) 3 | while true ; do 4 | FILE=$($TOOLS/watch src pages/src shared/*.mjs shared/sauce '*.js' '*.mjs') 5 | clear 6 | $TOOLS/../../node_modules/.bin/eslint --no-ignore "$FILE" && echo CLEAN 7 | done 8 | -------------------------------------------------------------------------------- /tools/bin/watch: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | EXT="${@:$#}" 3 | DIRS="${@:1:$#-1}" 4 | find $DIRS -type f -name "$EXT" 2>/dev/null | xargs fswatch --event Updated -1 --latency 0.001 5 | --------------------------------------------------------------------------------