diff --git a/README.md b/README.md index 3c5aee9..5abea81 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,9 @@ dependencies listed below. The necessary Apache configuration is inside the
Windows 10 Instructions * 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: `` us-or-rly101.zwift.com secure.zwift.com cdn.zwift.com`` + * Append this line: `` us-or-rly101.zwift.com secure.zwift.com cdn.zwift.com launcher.zwift.com``
(Where ```` 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.
Mac OS X Instructions (Thanks @oldnapalm!) * 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: + ``` + NSAppTransportSecurity + + NSExceptionDomains + + zwift.com + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + + ``` * Using a text editor (with admin privileges) open ``/etc/hosts`` - * Append this line: `` us-or-rly101.zwift.com secure.zwift.com cdn.zwift.com`` + * Append this line: `` us-or-rly101.zwift.com secure.zwift.com cdn.zwift.com launcher.zwift.com``
(Where ```` 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/`` 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/`` directory containing your login credentials + ``` + + + ``` + * 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 diff --git a/cdn/gameassets/Zwift_Updates_Root/ZwiftMac_ver_cur.xml b/cdn/gameassets/Zwift_Updates_Root/ZwiftMac_ver_cur.xml index 9fcbb36..57e791f 100644 --- a/cdn/gameassets/Zwift_Updates_Root/ZwiftMac_ver_cur.xml +++ b/cdn/gameassets/Zwift_Updates_Root/ZwiftMac_ver_cur.xml @@ -1,7 +1,7 @@ +ver_cur_checksum="1271531551"/> diff --git a/cdn/gameassets/Zwift_Updates_Root/Zwift_ver_cur.xml b/cdn/gameassets/Zwift_Updates_Root/Zwift_ver_cur.xml index 99cfdc9..af53ba4 100644 --- a/cdn/gameassets/Zwift_Updates_Root/Zwift_ver_cur.xml +++ b/cdn/gameassets/Zwift_Updates_Root/Zwift_ver_cur.xml @@ -1,7 +1,7 @@ +ver_cur_checksum="317148842"/> diff --git a/cdn/static/web/launcher/embed-noauto.html b/cdn/static/web/launcher/embed-noauto.html index 9268629..a6f032f 100644 --- a/cdn/static/web/launcher/embed-noauto.html +++ b/cdn/static/web/launcher/embed-noauto.html @@ -1,29 +1,31 @@ Loading Zwift -

zoffline Launcher

-
-

Click here to start Zwift

-
- -

Launch into a specific map*: - - - - - - - -
InnsbruckLondonNew YorkRichmondWatopiaYorkshire
-

+
+ + + + +

Select profile:

+

Select map*:

+
+

To automatically launch Zwift and skip this step create a file called "auto_launch.txt" inside zoffline's storage directory. diff --git a/zwift_offline.py b/zwift_offline.py index 16c80e5..6c606a6 100644 --- a/zwift_offline.py +++ b/zwift_offline.py @@ -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/', 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//activities/', 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/', 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),