codexctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codexctl/updates.py ADDED
@@ -0,0 +1,368 @@
1
+ import os
2
+ import requests
3
+ import uuid
4
+ import sys
5
+ import json
6
+ import hashlib
7
+ import logging
8
+
9
+ from pathlib import Path
10
+ from datetime import datetime
11
+
12
+ import xml.etree.ElementTree as ET
13
+
14
+
15
+ class UpdateManager:
16
+ def __init__(self, logger=None) -> None:
17
+ """Manager for downloading update versions
18
+
19
+ Args:
20
+ logger (logger, optional): Logger object for logging. Defaults to None.
21
+ """
22
+
23
+ self.logger = logger
24
+
25
+ if logging is None:
26
+ self.logger = logging
27
+
28
+ (
29
+ self.remarkablepp_versions,
30
+ self.remarkable2_versions,
31
+ self.remarkable1_versions,
32
+ self.external_provider_url,
33
+ ) = self.get_remarkable_versions()
34
+
35
+ def get_remarkable_versions(self) -> tuple[dict, dict, dict, str, str]:
36
+ """Gets the avaliable versions for the device, by checking the local version-ids.json file and then updating it if necessary
37
+
38
+ Returns:
39
+ tuple: A tuple containing the version ids for the remarkablepp, remarkable2, remarkable1, toltec version and external provider (in that order)
40
+ """
41
+
42
+ if os.path.exists("data/version-ids.json"):
43
+ file_location = "data/version-ids.json"
44
+
45
+ self.logger.debug("Found version-ids at data/version-ids.json")
46
+
47
+ else:
48
+ if os.name == "nt": # Windows
49
+ folder_location = os.getenv("APPDATA") + "/codexctl"
50
+ elif os.name == "posix" or "darwin": # Linux or MacOS
51
+ folder_location = os.path.expanduser("~/.config/codexctl")
52
+ else:
53
+ raise SystemError("Unsupported OS")
54
+
55
+ self.logger.debug(f"Version config folder location is {folder_location}")
56
+ if not os.path.exists(folder_location):
57
+ os.makedirs(folder_location, exist_ok=True)
58
+
59
+ file_location = folder_location + "/version-ids.json"
60
+
61
+ if not os.path.exists(file_location):
62
+ self.update_version_ids(file_location)
63
+
64
+ try:
65
+ with open(file_location) as f:
66
+ contents = json.load(f)
67
+ except ValueError:
68
+ raise SystemError(
69
+ f"Version-ids.json @ {file_location} is corrupted! Please delete it and try again. Also, PLEASE open an issue on the repo showing the contents of the file."
70
+ )
71
+
72
+ if (
73
+ int(datetime.now().timestamp()) - contents["last-updated"]
74
+ > 5256000 # 2 months
75
+ ):
76
+ self.update_version_ids(file_location)
77
+
78
+ with open(file_location) as f:
79
+ contents = json.load(f)
80
+
81
+ self.logger.debug(f"Version ids contents are {contents}")
82
+
83
+ return (
84
+ contents["remarkablepp"],
85
+ contents["remarkable2"],
86
+ contents["remarkable1"],
87
+ contents["external-provider-url"],
88
+ )
89
+
90
+ def update_version_ids(self, location: str) -> None:
91
+ """Updates the version-ids.json file
92
+
93
+ Args:
94
+ location (str): Location to save the file
95
+
96
+ Raises:
97
+ SystemExit: If the file cannot be updated
98
+ """
99
+ with open(location, "w", newline="\n") as f:
100
+ try:
101
+ self.logger.debug("Downloading version-ids.json")
102
+ contents = requests.get(
103
+ "https://raw.githubusercontent.com/Jayy001/codexctl/main/data/version-ids.json"
104
+ ).json()
105
+ json.dump(contents, f, indent=4)
106
+ f.write("\n")
107
+ except requests.exceptions.Timeout:
108
+ raise SystemExit(
109
+ "Connection timed out while downloading version-ids.json! Do you have an internet connection?"
110
+ )
111
+ except Exception as error:
112
+ raise SystemExit(
113
+ f"Unknown error while downloading version-ids.json! {error}"
114
+ )
115
+
116
+ def get_latest_version(self, device_type: str) -> str:
117
+ """Gets the latest version available for the device
118
+
119
+ Args:
120
+ device_type (str): Type of the device (remarkablepp or remarkable2 or remarkable1)
121
+
122
+ Returns:
123
+ str: Latest version available for the device
124
+ """
125
+ if "1" in device_type:
126
+ versions = self.remarkable1_versions
127
+ elif "2" in device_type:
128
+ versions = self.remarkable2_versions
129
+ elif "ferrari" in device_type or "pp" in device_type:
130
+ versions = self.remarkablepp_versions
131
+ else:
132
+ return None # Explicit?
133
+
134
+ return self.__max_version(versions.keys())
135
+
136
+ def get_toltec_version(self, device_type: str) -> str:
137
+ """Gets the latest version available toltec for the device
138
+
139
+ Args:
140
+ device_type (str): Type of the device (remarkablepp or remarkable2 or remarkable1)
141
+
142
+ Returns:
143
+ str: Latest version available for the device
144
+ """
145
+
146
+ if "ferrari" in device_type:
147
+ raise SystemExit("ReMarkable Paper Pro does not support toltec")
148
+
149
+ response = requests.get("https://toltec-dev.org/stable/Compatibility")
150
+ if response.status_code != 200:
151
+ raise SystemExit(
152
+ f"Error: Failed to get toltec compatibility table: {response.status_code}"
153
+ )
154
+
155
+ return self.__max_version(
156
+ [
157
+ x.split("=")[1]
158
+ for x in response.text.splitlines()
159
+ if x.startswith(f"{device_type}=")
160
+ ]
161
+ )
162
+
163
+ def download_version(
164
+ self, device_type: str, update_version: str, download_folder: str = None
165
+ ) -> str | None:
166
+ """Downloads the specified version of the update
167
+
168
+ Args:
169
+ device_type (str): Type of the device (remarkable2 or remarkable1)
170
+ update_version (str): Id of version to download.
171
+ download_folder (str, optional): Location of download folder. Defaults to download folder for OS.
172
+
173
+ Returns:
174
+ str | None: Location of the file if the download was successful, None otherwise
175
+ """
176
+
177
+ if download_folder is None:
178
+ download_folder = Path(
179
+ os.environ["XDG_DOWNLOAD_DIR"]
180
+ if (
181
+ "XDG_DOWNLOAD_DIR" in os.environ
182
+ and os.path.exists(os.environ["XDG_DOWNLOAD_DIR"])
183
+ )
184
+ else Path.home() / "Downloads"
185
+ )
186
+
187
+ if not os.path.exists(download_folder):
188
+ self.logger.error(
189
+ f"Download folder {download_folder} does not exist! Creating it now."
190
+ )
191
+ os.makedirs(download_folder)
192
+
193
+ BASE_URL = "https://updates-download.cloud.remarkable.engineering/build/reMarkable%20Device%20Beta/RM110" # Default URL for v2 versions
194
+ BASE_URL_V3 = "https://updates-download.cloud.remarkable.engineering/build/reMarkable%20Device/reMarkable"
195
+
196
+ if device_type in ("rm1", "reMarkable 1", "remarkable1"):
197
+ version_lookup = self.remarkable1_versions
198
+ elif device_type in ("rm2", "reMarkable 2", "remarkable2"):
199
+ version_lookup = self.remarkable2_versions
200
+ BASE_URL_V3 += "2"
201
+ elif device_type in ("rmpp", "rmpro", "reMarkable Ferrari", "ferrari"):
202
+ version_lookup = self.remarkablepp_versions
203
+ else:
204
+ raise SystemError("Hardware version does not exist! (rm1,rm2,rmpp)")
205
+
206
+ if update_version not in version_lookup:
207
+ self.logger.error(
208
+ f"Version {update_version} not found in version-ids.json! Please update your version-ids.json file."
209
+ )
210
+ return
211
+
212
+ version_major, version_minor, version_patch, version_build = (
213
+ update_version.split(".")
214
+ )
215
+ version_id, version_checksum = version_lookup[update_version]
216
+ version_external = False
217
+
218
+ if int(version_major) >= 3:
219
+ BASE_URL = BASE_URL_V3
220
+
221
+ if int(version_minor) >= 11:
222
+ version_external = True
223
+
224
+ if version_external:
225
+ file_url = self.external_provider_url.replace("REPLACE_ID", version_id)
226
+ file_name = f"remarkable-production-memfault-image-{update_version}-{device_type.replace(' ', '-')}-public"
227
+ else:
228
+ file_name = f"{update_version}_reMarkable{'2' if '2' in device_type else ''}-{version_id}.signed"
229
+ file_url = f"{BASE_URL}/{update_version}/{file_name}"
230
+
231
+ self.logger.debug(f"File URL is {file_url}, File name is {file_name}")
232
+
233
+ return self.__download_version_file(
234
+ file_url, file_name, download_folder, version_checksum
235
+ )
236
+
237
+ def __generate_xml_data(self) -> str:
238
+ """Generates and returns XML data for the update request"""
239
+ params = {
240
+ "installsource": "scheduler",
241
+ "requestid": str(uuid.uuid4()),
242
+ "sessionid": str(uuid.uuid4()),
243
+ "machineid": "00".zfill(32),
244
+ "oem": "RM100-753-12345",
245
+ "appid": "98DA7DF2-4E3E-4744-9DE6-EC931886ABAB",
246
+ "bootid": str(uuid.uuid4()),
247
+ "current": "3.2.3.1595",
248
+ "group": "Prod",
249
+ "platform": "reMarkable2",
250
+ }
251
+
252
+ return """<?xml version="1.0" encoding="UTF-8"?>
253
+ <request protocol="3.0" version="{current}" requestid="{{{requestid}}}" sessionid="{{{sessionid}}}" updaterversion="0.4.2" installsource="{installsource}" ismachine="1">
254
+ <os version="zg" platform="{platform}" sp="{current}_armv7l" arch="armv7l"></os>
255
+ <app appid="{{{appid}}}" version="{current}" track="{group}" ap="{group}" bootid="{{{bootid}}}" oem="{oem}" oemversion="2.5.2" alephversion="{current}" machineid="{machineid}" lang="en-US" board="" hardware_class="" delta_okay="false" nextversion="" brand="" client="" >
256
+ <updatecheck/>
257
+ </app>
258
+ </request>""".format(
259
+ **params
260
+ )
261
+
262
+ def __parse_response(self, resp: str) -> tuple[str, str, str] | None:
263
+ """Parses the response from the update server and returns the file name, uri, and version if an update is available
264
+
265
+ Args:
266
+ resp (str): Response from the server
267
+
268
+ Returns:
269
+ tuple[str, str, str] | None: File name, uri, and version if an update is available, None otherwise
270
+ """
271
+ xml_data = ET.fromstring(resp)
272
+
273
+ if "noupdate" in resp or xml_data is None:
274
+ return None
275
+
276
+ file_name = xml_data.find("app/updatecheck/manifest/packages/package").attrib[
277
+ "name"
278
+ ]
279
+ file_uri = (
280
+ f"{xml_data.find('app/updatecheck/urls/url').attrib['codebase']}{file_name}"
281
+ )
282
+ file_version = xml_data.find("app/updatecheck/manifest").attrib["version"]
283
+
284
+ self.logger.debug(
285
+ f"File version is {file_version}, file uri is {file_uri}, file name is {file_name}"
286
+ )
287
+ return file_version, file_uri, file_name
288
+
289
+ def __download_version_file(
290
+ self, uri: str, name: str, download_folder: str, checksum: str
291
+ ) -> str | None:
292
+ """Downloads the version file from the server and checks the checksum
293
+
294
+ Args:
295
+ uri (str): Location to the file
296
+ name (str): Name of the file
297
+ download_folder (str): Location of download folder
298
+ checksum (str): Sha256 Checksum of the file
299
+
300
+ Returns:
301
+ str | None: Location of the file if the checksum matches, None otherwise
302
+ """
303
+ response = requests.get(uri, stream=True)
304
+ file_length = response.headers.get("content-length")
305
+
306
+ self.logger.debug(f"Downloading {name} from {uri} to {download_folder}")
307
+ try:
308
+ file_length = int(file_length)
309
+
310
+ if int(file_length) < 10000000: # 10MB, invalid version file
311
+ self.logger.error(
312
+ f"File {name} is too small to be a valid version file"
313
+ )
314
+ return None
315
+ except TypeError:
316
+ self.logger.error(
317
+ f"Could not get content length for {name}. Do you have an internet connection?"
318
+ )
319
+ return None
320
+
321
+ self.logger.debug(f"{name} is {file_length} bytes")
322
+
323
+ filename = f"{download_folder}/{name}"
324
+ with open(filename, "wb") as out_file:
325
+ dl = 0
326
+
327
+ for data in response.iter_content(chunk_size=4096):
328
+ dl += len(data)
329
+ out_file.write(data)
330
+ if sys.stdout.isatty():
331
+ done = int(50 * dl / file_length)
332
+ sys.stdout.write("\r[%s%s]" % ("=" * done, " " * (50 - done)))
333
+ sys.stdout.flush()
334
+
335
+ if sys.stdout.isatty():
336
+ print(end="\r\n")
337
+
338
+ self.logger.debug(f"Downloaded {name}")
339
+
340
+ with open(filename, "rb") as f:
341
+ file_checksum = hashlib.sha256(f.read()).hexdigest()
342
+
343
+ if file_checksum != checksum:
344
+ os.remove(filename)
345
+ self.logger.error(
346
+ f"File checksum mismatch! Expected {checksum}, got {file_checksum}"
347
+ )
348
+ return None
349
+
350
+ return filename
351
+
352
+ @staticmethod
353
+ def uses_new_update_engine(version: str) -> bool:
354
+ """
355
+ Checks if the version given is above 3.11 and so requires the newer update engine
356
+
357
+ Args:
358
+ version (str): version to check against
359
+
360
+ Returns:
361
+ bool: If it uses the new update engine or not
362
+ """
363
+ return int(version.split(".")[0]) >= 3 and int(version.split(".")[1]) >= 11
364
+
365
+ @staticmethod
366
+ def __max_version(versions: list) -> str:
367
+ """Returns the highest avaliable version from a list with semantic versioning"""
368
+ return sorted(versions, key=lambda v: tuple(map(int, v.split("."))))[-1]