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/__init__.py +462 -0
- codexctl/__main__.py +4 -0
- codexctl/analysis.py +30 -0
- codexctl/device.py +640 -0
- codexctl/server.py +172 -0
- codexctl/sync.py +218 -0
- codexctl/updates.py +368 -0
- codexctl-0.1.0.dist-info/LICENSE +674 -0
- codexctl-0.1.0.dist-info/METADATA +105 -0
- codexctl-0.1.0.dist-info/RECORD +11 -0
- codexctl-0.1.0.dist-info/WHEEL +4 -0
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]
|