Merge pull request #41 from oldnapalm/master

Fix issues with Mac launcher 1.0.23 (issue #27)
Update instructions to configure Mac client
Fix issues with Windows launcher 1.0.50
Add support for multiple profiles (tested with Windows and Mac, untested with Docker)
Remove free trial limit from profile (mentioned in issue #25)
Add Garmin Connect activity upload
Fix multi profile on Android (issue #44)
Update to Zwift 1.0.48969
This commit is contained in:
zoffline
2020-04-24 18:35:18 -04:00
committed by GitHub
5 changed files with 254 additions and 85 deletions
+45 -12
View File
@@ -65,9 +65,9 @@ dependencies listed below. The necessary Apache configuration is inside the
<details><summary>Windows 10 Instructions</summary>
* Install Zwift
* If your Zwift version is newer than 1.0.48307 you may have to uninstall, then reinstall after installing zoffline.
* If your Zwift version is 1.0.48307, you're all set.
* If Zwift is not installed install it after installing zoffline (1.0.48307 will be installed instead of the latest).
* If your Zwift version is newer than 1.0.48969 you may have to uninstall, then reinstall after installing zoffline.
* If your Zwift version is 1.0.48969, you're all set.
* If Zwift is not installed install it after installing zoffline (1.0.48969 will be installed instead of the latest).
* On your Windows machine running Zwift, copy the following files in this repo to a known location:
* ``ssl/cert-zwift-com.p12``
* ``ssl/cert-zwift-com.pem``
@@ -77,7 +77,7 @@ dependencies listed below. The necessary Apache configuration is inside the
* Open Notepad as an admin and open ``C:\Program Files (x86)\Zwift\data\cacert.pem``
* Append the contents of ``ssl/cert-zwift-com.pem`` to cacert.pem
* Open Notepad as an admin and open ``C:\Windows\System32\Drivers\etc\hosts``
* Append this line: ``<zoffline ip> us-or-rly101.zwift.com secure.zwift.com cdn.zwift.com``
* Append this line: ``<zoffline ip> us-or-rly101.zwift.com secure.zwift.com cdn.zwift.com launcher.zwift.com``
<br />(Where ``<zoffline ip>`` is the ip address of the machine running zoffline. If
it's running on the same machine as Zwift, use ``127.0.0.1`` as the ip.)
@@ -90,9 +90,9 @@ to generate your own certificates and do the same.
<details><summary>Mac OS X Instructions (Thanks @oldnapalm!)</summary>
* Install Zwift
* If your Zwift version is newer than 1.0.48307 you may have to uninstall, then reinstall after installing zoffline.
* If your Zwift version is 1.0.48307, you're all set.
* If Zwift is not installed install it after installing zoffline (1.0.48307 will be installed instead of the latest).
* If your Zwift version is newer than 1.0.48969 you may have to uninstall, then reinstall after installing zoffline.
* If your Zwift version is 1.0.48969, you're all set.
* If Zwift is not installed install it after installing zoffline (1.0.48969 will be installed instead of the latest).
* On your Mac machine running Zwift, copy the following files in this repo to a known location:
* ``ssl/cert-zwift-com.p12``
* ``ssl/cert-zwift-com.pem``
@@ -102,8 +102,25 @@ to generate your own certificates and do the same.
* If you're prompted for a password, just leave it blank. There is no password.
* Using a text editor open ``~/Library/Application Support/Zwift/data/cacert.pem``
* Append the contents of ``ssl/cert-zwift-com.pem`` to cacert.pem
* Using a text editor (with admin privileges) open ``/Applications/Zwift.app/Contents/Info.plist``
* Append these keys:
```
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>zwift.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
```
* Using a text editor (with admin privileges) open ``/etc/hosts``
* Append this line: ``<zoffline ip> us-or-rly101.zwift.com secure.zwift.com cdn.zwift.com``
* Append this line: ``<zoffline ip> us-or-rly101.zwift.com secure.zwift.com cdn.zwift.com launcher.zwift.com``
<br />(Where ``<zoffline ip>`` is the ip address of the machine running zoffline. If
it's running on the same machine as Zwift, use ``127.0.0.1`` as the ip.)
@@ -132,6 +149,7 @@ to generate your own certificates and do the same.
* (modify on PC)
* ``adb push hosts /etc/hosts``
* Start Zwift and sign in using any email/password
* To change profile, sign in using ``player_id`` (numeric, default is 1000) and any password (Thanks @kienkhuat!)
Why: We need to redirect Zwift to use zoffline and convince Zwift to
accept zoffline's self signed certificates for Zwift's domain names. Feel free
@@ -160,13 +178,26 @@ To obtain your current profile:
### Step 4 [OPTIONAL]: Obtain Strava API token
* Install dependencies: stravalib, six
* e.g., on Linux/Mac: ``pip install stravalib six``
* e.g., on Windows in command prompt: ``C:\Python27\Scripts\pip.exe install stravalib six``
* Install dependencies: stravalib
* e.g., on Linux/Mac: ``pip install stravalib``
* e.g., on Windows in command prompt: ``C:\Python27\Scripts\pip.exe install stravalib``
* Get CLIENT_ID and CLIENT_SECRET from https://www.strava.com/settings/api
* Run ``scripts/strava_auth.py --client-id CLIENT_ID --client-secret CLIENT_SECRET``
* Open http://localhost:8000/ and authorize.
* Move the resulting strava_token.txt (saved in whatever directory you ran strava_auth.py in) into the ``storage`` directory.
* Move the resulting strava_token.txt (saved in whatever directory you ran strava_auth.py in) into the ``storage/<player_id>`` directory.
### Step 5 [OPTIONAL]: Upload activities to Garmin Connect
* Install dependencies: garmin-uploader
* e.g., on Linux/Mac: ``pip install garmin-uploader``
* e.g., on Windows in command prompt: ``C:\Python27\Scripts\pip.exe install garmin-uploader``
* Create a file garmin_credentials.txt in the ``storage/<player_id>`` directory containing your login credentials
```
<username>
<password>
```
* Note: this is not secure. Only do this if you are comfortable with your login credentials being stored in a clear text file.
## Dependencies
@@ -182,6 +213,8 @@ Docker
* ``pip install protobuf_to_dict``
* OPTIONAL: stravalib (https://github.com/hozn/stravalib)
* ``pip install stravalib``
* OPTIONAL: garmin-uploader (https://github.com/La0/garmin-uploader)
* ``pip install garmin-uploader``
## Known issues
@@ -1,7 +1,7 @@
<Zwift
version="1.0.48307"
version="1.0.48969"
GAME_URL="https://us-or-rly101.zwift.com"
manifest="ZwiftMac_1.0.48307_5019d0f8_manifest.xml"
manifest_checksum="-683988237"
manifest="ZwiftMac_1.0.48969_ec6cdfaf_manifest.xml"
manifest_checksum="1897373237"
mandatory_version="0.0.0"
ver_cur_checksum="933528709"/>
ver_cur_checksum="1271531551"/>
@@ -1,7 +1,7 @@
<Zwift
version="1.0.48307"
version="1.0.48969"
GAME_URL="https://us-or-rly101.zwift.com"
manifest="Zwift_1.0.48307_5019d0f8_manifest.xml"
manifest_checksum="-2074220869"
manifest="Zwift_1.0.48969_ec6cdfaf_manifest.xml"
manifest_checksum="-567094760"
mandatory_version="0.0.0"
ver_cur_checksum="-1992170625"/>
ver_cur_checksum="317148842"/>
+21 -19
View File
@@ -1,29 +1,31 @@
<html>
<head>
<title>Loading Zwift</title>
<style>
a, a:visited {
color: blue;
}
</style>
</head>
<body>
<h1>zoffline Launcher</h1>
</br>
<h2><a href="https://secure.zwift.com/ride">Click here to start Zwift</a></h2>
</br>
<h3>Launch into a specific map*:
<table border=0 cellpadding=15><tr>
<td><a href="http://cdn.zwift.com/INNSBRUCK">Innsbruck</a></td>
<td><a href="http://cdn.zwift.com/LONDON">London</a></td>
<td><a href="http://cdn.zwift.com/NEWYORK">New York</a></td>
<td><a href="http://cdn.zwift.com/RICHMOND">Richmond</a></td>
<td><a href="http://cdn.zwift.com/WATOPIA">Watopia</a></td>
<td><a href="http://cdn.zwift.com/YORKSHIRE">Yorkshire</a></td>
</tr></table>
</h3>
<form method="POST" action="/start-zwift">
<table border=0 cellpadding=15>
<tr><td><p>Select profile:</p>
<select name="id">
{% for profile in profiles %}
<option value="{{ profile.id }}">{{ profile.f4 + ' ' + profile.f5 }}</option>
{% endfor %}
</select></td>
<td><p>Select map*:</p>
<select name="map">
<option value="CALENDAR">Calendar</option>
<option value="INNSBRUCK">Innsbruck</option>
<option value="LONDON">London</option>
<option value="NEWYORK">New York</option>
<option value="RICHMOND">Richmond</option>
<option value="WATOPIA">Watopia</option>
<option value="YORKSHIRE">Yorkshire</option>
</select></td></tr>
<tr><td><button type="submit">Start Zwift</button></td></tr>
</table>
</form>
</br>
To automatically launch Zwift and skip this step create a file called "auto_launch.txt" inside zoffline's storage directory.
+180 -46
View File
@@ -14,7 +14,7 @@ from datetime import timedelta
from io import BytesIO
from shutil import copyfile
from flask import Flask, request, jsonify, g, redirect
from flask import Flask, request, jsonify, g, redirect, render_template
from google.protobuf.descriptor import FieldDescriptor
from protobuf_to_dict import protobuf_to_dict, TYPE_CALLABLE_MAP
@@ -29,7 +29,7 @@ import protobuf.world_pb2 as world_pb2
import protobuf.zfiles_pb2 as zfiles_pb2
logging.basicConfig()
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
logger = logging.getLogger('zoffline')
logger.setLevel(logging.WARN)
@@ -56,12 +56,11 @@ DATABASE_CUR_VER = 1
# For auth server
AUTOLAUNCH_FILE = "%s/auto_launch.txt" % STORAGE_DIR
NOAUTO_EMBED = "http://cdn.zwift.com/static/web/launcher/embed-noauto.html"
from tokens import *
# Android uses https for cdn
app = Flask(__name__, static_folder='%s/cdn/gameassets' % SCRIPT_DIR, static_url_path='/gameassets')
app = Flask(__name__, static_folder='%s/cdn/gameassets' % SCRIPT_DIR, static_url_path='/gameassets', template_folder='%s/cdn/static/web/launcher' % SCRIPT_DIR)
####
@@ -73,6 +72,10 @@ type_callable_map[FieldDescriptor.TYPE_BYTES] = str
type_callable_map[FieldDescriptor.TYPE_UINT64] = str
profiles = list()
selected_profile = 1000
def insert_protobuf_into_db(table_name, msg):
cur = g.db.cursor()
msg_dict = protobuf_to_dict(msg, type_callable_map=type_callable_map)
@@ -135,7 +138,7 @@ def world_time():
@app.route('/api/auth', methods=['GET'])
def api_auth():
return '{"realm":"zwift","url":"https://secure.zwift.com/auth/"}'
return '{"realm":"zwift","launcher":"https://launcher.zwift.com/launcher","url":"https://secure.zwift.com/auth/"}'
@app.route('/api/users/login', methods=['POST'])
@@ -155,6 +158,8 @@ def api_users_login():
@app.route('/api/users/logout', methods=['POST'])
def api_users_logout():
# update profiles list in case new user was created
list_profiles()
return '', 204
@@ -200,26 +205,45 @@ def api_private_event_feed():
@app.route('/api/profiles/me', methods=['GET'])
def api_profiles_me():
profile_dir = '%s/%s' % (STORAGE_DIR, selected_profile)
try:
if not os.path.isdir(profile_dir):
os.makedirs(profile_dir)
except IOError, e:
logger.error("failed to create profile dir (%s): %s", profile_dir, str(e))
sys.exit(1)
profile = profile_pb2.Profile()
profile_file = '%s/profile.bin' % STORAGE_DIR
profile_file = '%s/profile.bin' % profile_dir
if not os.path.isfile(profile_file):
profile.id = 1000
profile.id = selected_profile
profile.is_connected_to_strava = True
profile.f3 = 'user@email.com'
return profile.SerializeToString(), 200
with open(profile_file, 'rb') as fd:
profile.ParseFromString(fd.read())
# ensure profile.id = directory (in case directory is renamed)
if profile.id != selected_profile:
logger.warn('player_id is different from profile directory, updating database...')
cur = g.db.cursor()
cur.execute('UPDATE activity SET player_id = ? WHERE player_id = ?', (str(selected_profile), str(profile.id)))
cur.execute('UPDATE goal SET player_id = ? WHERE player_id = ?', (str(selected_profile), str(profile.id)))
cur.execute('UPDATE segment_result SET player_id = ? WHERE player_id = ?', (str(selected_profile), str(profile.id)))
g.db.commit()
profile.id = selected_profile
if not profile.f3:
profile.f3 = 'user@email.com'
# clear f60 to remove free trial limit
if profile.f60:
logger.warn('Profile contains bytes related to subscription/billing, removing...')
del profile.f60[:]
return profile.SerializeToString(), 200
# FIXME (not going to fix unless really bored): only supports 1 profile
@app.route('/api/profiles/<int:player_id>', methods=['PUT'])
def api_profiles_id(player_id):
if not request.stream:
return '', 400
with open('%s/profile.bin' % STORAGE_DIR, 'wb') as f:
with open('%s/%s/profile.bin' % (STORAGE_DIR, player_id), 'wb') as f:
f.write(request.stream.read())
return '', 204
@@ -246,6 +270,70 @@ def api_profiles_activities(player_id):
return activities.SerializeToString(), 200
def strava_upload(player_id, activity):
try:
from stravalib.client import Client
except ImportError:
logger.warn("stravalib is not installed. Skipping Strava upload attempt.")
return
profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
strava = Client()
try:
with open('%s/strava_token.txt' % profile_dir, 'r') as f:
client_id = f.readline().rstrip('\r\n')
client_secret = f.readline().rstrip('\r\n')
strava.access_token = f.readline().rstrip('\r\n')
refresh_token = f.readline().rstrip('\r\n')
expires_at = f.readline().rstrip('\r\n')
except:
logger.warn("Failed to read %s/strava_token.txt. Skipping Strava upload attempt." % profile_dir)
return
try:
if time.time() > int(expires_at):
refresh_response = strava.refresh_access_token(client_id=client_id, client_secret=client_secret,
refresh_token=refresh_token)
with open('%s/strava_token.txt' % profile_dir, 'w') as f:
f.write(client_id + '\n')
f.write(client_secret + '\n')
f.write(refresh_response['access_token'] + '\n')
f.write(refresh_response['refresh_token'] + '\n')
f.write(str(refresh_response['expires_at']) + '\n')
except:
logger.warn("Failed to refresh token. Skipping Strava upload attempt.")
return
try:
# See if there's internet to upload to Strava
strava.upload_activity(BytesIO(activity.fit), data_type='fit', name=activity.name)
# XXX: assume the upload succeeds on strava's end. not checking on it.
except:
logger.warn("Strava upload failed. No internet?")
def garmin_upload(player_id, activity):
try:
from garmin_uploader.workflow import Workflow
except ImportError:
logger.warn("garmin_uploader is not installed. Skipping Garmin upload attempt.")
return
profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
try:
with open('%s/garmin_credentials.txt' % profile_dir, 'r') as f:
username = f.readline().rstrip('\r\n')
password = f.readline().rstrip('\r\n')
except:
logger.warn("Failed to read %s/garmin_credentials.txt. Skipping Garmin upload attempt." % profile_dir)
return
try:
with open('%s/last_activity.fit' % profile_dir, 'wb') as f:
f.write(activity.fit)
except:
logger.warn("Failed to save fit file. Skipping Garmin upload attempt.")
return
try:
w = Workflow(['%s/last_activity.fit' % profile_dir], activity_name=activity.name, username=username, password=password)
w.run()
except:
logger.warn("Garmin upload failed. No internet?")
# With 64 bit ids Zwift can pass negative numbers due to overflow, which the flask int
# converter does not handle so it's a string argument
@app.route('/api/profiles/<int:player_id>/activities/<string:activity_id>', methods=['PUT'])
@@ -260,41 +348,8 @@ def api_profiles_activities_id(player_id, activity_id):
response = '{"id":%s}' % activity_id
if request.args.get('upload-to-strava') != 'true':
return response, 200
try:
from stravalib.client import Client
except ImportError:
logger.warn("stravalib is not installed. Skipping Strava upload attempt.")
return response, 200
strava = Client()
try:
with open('%s/strava_token.txt' % STORAGE_DIR, 'r') as f:
client_id = f.readline().rstrip('\n')
client_secret = f.readline().rstrip('\n')
strava.access_token = f.readline().rstrip('\n')
refresh_token = f.readline().rstrip('\n')
expires_at = f.readline().rstrip('\n')
except:
logger.warn("Failed to read %s/strava_token.txt. Skipping Strava upload attempt." % STORAGE_DIR)
return response, 200
try:
if time.time() > int(expires_at):
refresh_response = strava.refresh_access_token(client_id=client_id, client_secret=client_secret,
refresh_token=refresh_token)
with open('%s/strava_token.txt' % STORAGE_DIR, 'w') as f:
f.write(client_id + '\n');
f.write(client_secret + '\n');
f.write(refresh_response['access_token'] + '\n');
f.write(refresh_response['refresh_token'] + '\n');
f.write(str(refresh_response['expires_at']) + '\n');
except:
logger.warn("Failed to refresh token. Skipping Strava upload attempt.")
return response, 200
try:
# See if there's internet to upload to Strava
strava.upload_activity(BytesIO(activity.fit), data_type='fit', name=activity.name)
# XXX: assume the upload succeeds on strava's end. not checking on it.
except:
logger.warn("Strava upload failed. No internet?")
strava_upload(player_id, activity)
garmin_upload(player_id, activity)
return response, 200
@@ -574,7 +629,51 @@ def teardown_request(exception):
g.db.close()
@app.before_first_request
def move_old_profile():
profile_file = '%s/profile.bin' % STORAGE_DIR
if os.path.isfile(profile_file):
with open(profile_file, 'rb') as fd:
profile = profile_pb2.Profile()
profile.ParseFromString(fd.read())
profile_dir = '%s/%s' % (STORAGE_DIR, profile.id)
try:
if not os.path.isdir(profile_dir):
os.makedirs(profile_dir)
except IOError, e:
logger.error("failed to create profile dir (%s): %s", profile_dir, str(e))
sys.exit(1)
os.rename(profile_file, '%s/profile.bin' % profile_dir)
strava_file = '%s/strava_token.txt' % STORAGE_DIR
if os.path.isfile(strava_file):
os.rename(strava_file, '%s/strava_token.txt' % profile_dir)
def list_profiles():
global profiles
global selected_profile
del profiles[:]
for (root, dirs, files) in os.walk(STORAGE_DIR):
dirs.sort()
for profile_id in dirs:
profile = profile_pb2.Profile()
profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, profile_id)
if os.path.isfile(profile_file):
with open(profile_file, 'rb') as fd:
profile.ParseFromString(fd.read())
# ensure profile.id = directory (in case directory is renamed)
profile.id = int(profile_id)
profiles.append(profile)
profile = profile_pb2.Profile()
if profiles:
profile.id = profiles[-1].id + 1
# select first profile for auto launch
selected_profile = profiles[0].id
else:
profile.id = 1000
profile.f4 = 'New profile'
profiles.append(profile)
def init_database():
conn = connect_db()
cur = conn.cursor()
@@ -612,6 +711,13 @@ def init_database():
conn.close()
@app.before_first_request
def before_first_request():
move_old_profile()
list_profiles()
init_database()
####################
#
# Auth server (secure.zwift.com) routes below here
@@ -624,6 +730,8 @@ def auth_rb():
@app.route('/launcher', methods=['GET'])
@app.route('/launcher/realms/zwift/protocol/openid-connect/auth', methods=['GET'])
@app.route('/launcher/realms/zwift/protocol/openid-connect/registrations', methods=['GET'])
@app.route('/auth/realms/zwift/protocol/openid-connect/auth', methods=['GET'])
@app.route('/auth/realms/zwift/login-actions/request/login', methods=['GET', 'POST'])
@app.route('/auth/realms/zwift/protocol/openid-connect/registrations', methods=['GET'])
@@ -634,21 +742,47 @@ def auth_rb():
def launch_zwift():
# Zwift client has switched to calling https://launcher.zwift.com/launcher/ride
if request.path != "/ride" and not os.path.exists(AUTOLAUNCH_FILE):
return redirect(NOAUTO_EMBED, 302)
return render_template("embed-noauto.html", profiles=profiles)
else:
return redirect("http://zwift/?code=zwift_refresh_token%s" % REFRESH_TOKEN, 302)
@app.route('/auth/realms/zwift/protocol/openid-connect/token', methods=['POST'])
def auth_realms_zwift_protocol_openid_connect_token():
# select profile on Android
global selected_profile
profile_id = None
username = request.form.get('username')
if username:
try:
profile_id = int(username)
except ValueError:
pass
if profile_id:
selected_profile = profile_id
return FAKE_JWT, 200
@app.route("/start-zwift" , methods=['POST'])
def start_zwift():
global selected_profile
selected_profile = int(request.form['id'])
selected_map = request.form['map']
if selected_map == 'CALENDAR':
return redirect("/ride", 302)
else:
return redirect("http://cdn.zwift.com/%s" % selected_map, 302)
# Called by Mac, but not Windows
@app.route('/auth/realms/zwift/tokens/access/codes', methods=['POST'])
def auth_realms_zwift_tokens_access_codes():
return FAKE_JWT, 200
@app.route('/static/web/launcher/<filename>', methods=['GET'])
def static_web_launcher(filename):
return render_template(filename)
def run_standalone():
app.run(ssl_context=('%s/cert-zwift-com.pem' % SSL_DIR, '%s/key-zwift-com.pem' % SSL_DIR),