pendify 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.
pendify/client.py ADDED
@@ -0,0 +1,223 @@
1
+ """The game client's side: where its credentials are (S:693-716) and its loopback calls (S:718-786).
2
+
3
+ The client writes its port and token to a lockfile while it runs. When no lockfile answers, one read of
4
+ the client process's command line through PowerShell takes the place of the script's psutil fallback, at
5
+ most once every 10 s. Every failure is no client, never an exception. Nothing here prints, logs or raises
6
+ the client's token, and Credentials hides it from repr. The client's phase says the loading screen opened;
7
+ the game's own loopback port is asked one number, its clock, to tell when the match itself starts. Nothing
8
+ else of the live data is read, kept, printed or sent.
9
+ """
10
+ import base64
11
+ import http.client
12
+ import json
13
+ import re
14
+ import ssl
15
+ import subprocess
16
+ import time
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from dataclasses import dataclass, field
21
+
22
+ # S:139-140: the two Windows places of the client's lockfile, the one list of them.
23
+ LOCKFILE_PATHS = (r"C:\Riot Games\League of Legends\lockfile", r"D:\Riot Games\League of Legends\lockfile")
24
+ # S:705: the process whose command line carries the port and the token, matched as a part of its name.
25
+ PROCESS_NAME = "LeagueClient"
26
+ # S:707-708, the script's two expressions.
27
+ PORT_PATTERN = re.compile(r"--app-port=(\d+)")
28
+ TOKEN_PATTERN = re.compile(r"--remoting-auth-token=([\w-]+)")
29
+ # Written by [Console]::Out: the host's own output wraps a long line at the console width, which would
30
+ # cut the token in two.
31
+ PROCESS_QUERY = ("powershell", "-NoProfile", "-Command",
32
+ f"Get-CimInstance -ClassName Win32_Process -Filter 'Name LIKE ''%{PROCESS_NAME}%''' "
33
+ "| ForEach-Object { [Console]::Out.WriteLine($_.CommandLine) }")
34
+ PROCESS_READ_INTERVAL = 10.0
35
+ PROCESS_READ_TIMEOUT = 5
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Credentials:
40
+ """The client's port and its token; the token is never shown by repr."""
41
+
42
+ port: int
43
+ token: str = field(repr=False)
44
+
45
+
46
+ def _credentials(port, token):
47
+ """Credentials when the port is a TCP port and the token is not empty, else None."""
48
+ if not port.isdigit() or not 0 < int(port) < 65536 or not token:
49
+ return None
50
+ return Credentials(int(port), token)
51
+
52
+
53
+ def read_lockfile(path):
54
+ """The third and fourth fields of the lockfile (S:697-700); a missing, short or unreadable file is None."""
55
+ try:
56
+ with open(path, "rb") as handle:
57
+ data = handle.read().decode("utf-8").split(":")
58
+ except (OSError, UnicodeDecodeError):
59
+ return None
60
+ if len(data) < 4:
61
+ return None
62
+ return _credentials(data[2].strip(), data[3].strip())
63
+
64
+
65
+ def parse_command_lines(text):
66
+ """The first command line that carries both the port and the token (S:704-709), or None."""
67
+ for line in text.splitlines():
68
+ port, token = PORT_PATTERN.search(line), TOKEN_PATTERN.search(line)
69
+ if port and token:
70
+ return _credentials(port.group(1), token.group(1))
71
+ return None
72
+
73
+
74
+ def query_process(run):
75
+ """The client process's command lines, or None on a timeout, a nonzero exit or a missing shell."""
76
+ try:
77
+ done = run(list(PROCESS_QUERY), capture_output=True, text=True, errors="replace",
78
+ timeout=PROCESS_READ_TIMEOUT, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
79
+ except (OSError, subprocess.SubprocessError):
80
+ return None
81
+ return done.stdout if done.returncode == 0 and isinstance(done.stdout, str) else None
82
+
83
+
84
+ class ClientCredentials:
85
+ """The lockfiles first; the process read only when none answers and at most once every 10 s.
86
+ `run` None turns the process read off (the test override reads its one lockfile only)."""
87
+
88
+ def __init__(self, paths=LOCKFILE_PATHS, run=subprocess.run, clock=time.monotonic):
89
+ self._paths, self._run, self._clock = tuple(paths), run, clock
90
+ self._last_query = None
91
+
92
+ def read(self):
93
+ for path in self._paths:
94
+ found = read_lockfile(path)
95
+ if found is not None:
96
+ return found
97
+ if self._run is None:
98
+ return None
99
+ now = self._clock()
100
+ if self._last_query is not None and now - self._last_query < PROCESS_READ_INTERVAL:
101
+ return None
102
+ self._last_query = now
103
+ output = query_process(self._run)
104
+ return None if output is None else parse_command_lines(output)
105
+
106
+
107
+
108
+ # S:742: the client's user name beside its token, in basic authentication.
109
+ USER_NAME = "riot"
110
+ CLIENT_HOST = "127.0.0.1"
111
+ PHASE_PATH = "/lol-gameflow/v1/gameflow-phase" # S:745
112
+ ACCEPT_PATH = "/lol-matchmaking/v1/ready-check/accept" # S:784
113
+ CLIENT_TIMEOUT = 2.0 # S:746, S:785
114
+ _MAX_ANSWER_BYTES = 64 * 1024
115
+ # The game's own loopback port while a match runs, and the one route of it that is read: its clock.
116
+ LIVE_PORT = 2999
117
+ LIVE_CLOCK_PATH = "/liveclientdata/gamestats"
118
+ LIVE_TIMEOUT = 1.0
119
+
120
+
121
+ class ClientUnreachable(Exception):
122
+ """The client gave no answer. The message names the failure's type only."""
123
+
124
+
125
+ def real_addresses(port):
126
+ """The client's base address in a real run (S:743)."""
127
+ return f"https://{CLIENT_HOST}:{port}"
128
+
129
+
130
+ def loopback_addresses(port):
131
+ """The test override's base address: a fake on 127.0.0.1, over plain http."""
132
+ return f"http://{CLIENT_HOST}:{port}"
133
+
134
+
135
+ def real_live_address():
136
+ """The game's own loopback port in a real run."""
137
+ return f"https://{CLIENT_HOST}:{LIVE_PORT}"
138
+
139
+
140
+ def loopback_tls_context(host):
141
+ """The one TLS context in the package that does not verify, for the client's self-signed certificate.
142
+ Only the literal 127.0.0.1 is served; any other host, a name that resolves there included, is refused."""
143
+ if host != CLIENT_HOST:
144
+ raise ValueError("an unverified TLS context is only for 127.0.0.1")
145
+ context = ssl.create_default_context()
146
+ context.check_hostname = False
147
+ context.verify_mode = ssl.CERT_NONE
148
+ return context
149
+
150
+
151
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
152
+ """A redirect answer is returned as it is, never followed off the loopback address."""
153
+
154
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
155
+ return None
156
+
157
+
158
+ def _call(url, token, timeout, data=None):
159
+ """(status, body) of one call on 127.0.0.1; ClientUnreachable when no answer came."""
160
+ parts = urllib.parse.urlsplit(url)
161
+ if parts.hostname != CLIENT_HOST or parts.scheme not in ("http", "https"):
162
+ raise ValueError("the client is only called on 127.0.0.1")
163
+ handlers = [urllib.request.ProxyHandler({}), _NoRedirect()]
164
+ if parts.scheme == "https":
165
+ handlers.append(urllib.request.HTTPSHandler(context=loopback_tls_context(parts.hostname)))
166
+ headers = {"Accept": "application/json"}
167
+ if token is not None:
168
+ try:
169
+ pair = f"{USER_NAME}:{token}".encode("latin-1") # as requests encodes basic authentication
170
+ except UnicodeEncodeError:
171
+ raise ClientUnreachable("UnicodeEncodeError") from None
172
+ headers["Authorization"] = "Basic " + base64.b64encode(pair).decode("ascii")
173
+ if data is not None:
174
+ headers["Content-Type"] = "application/json"
175
+ request = urllib.request.Request(url, data=data, headers=headers, method="GET" if data is None else "POST")
176
+ try:
177
+ try:
178
+ answer = urllib.request.build_opener(*handlers).open(request, timeout=timeout)
179
+ except urllib.error.HTTPError as refused: # an answer all the same
180
+ answer = refused
181
+ with answer:
182
+ return answer.getcode(), answer.read(_MAX_ANSWER_BYTES)
183
+ except urllib.error.URLError as failure:
184
+ reason = failure.reason
185
+ name = type(reason).__name__ if isinstance(reason, BaseException) else type(failure).__name__
186
+ raise ClientUnreachable(name) from None
187
+ except (OSError, http.client.HTTPException, ValueError) as failure: # a reset, a timeout, a broken answer
188
+ raise ClientUnreachable(type(failure).__name__) from None
189
+
190
+
191
+ def get(url, token, timeout):
192
+ """GET on the client: (status, body), or ClientUnreachable. `token` None sends no authentication."""
193
+ return _call(url, token, timeout)
194
+
195
+
196
+ def post(url, token, timeout):
197
+ """POST of an empty JSON object on the client, as S:784-786: the status, or ClientUnreachable."""
198
+ return _call(url, token, timeout, data=b"{}")[0]
199
+
200
+
201
+ def game_clock(base, get=get):
202
+ """The game's clock in seconds, from one GET of its clock route with no authentication; None when the
203
+ port gave no answer, the status is not 200, the body is not a JSON object or its gameTime is not a
204
+ number (a bool is not one). Never raises; no other key is read and nothing is kept."""
205
+ try:
206
+ status, raw = get(base + LIVE_CLOCK_PATH, None, LIVE_TIMEOUT)
207
+ except ClientUnreachable:
208
+ return None
209
+ if status != 200:
210
+ return None
211
+ try:
212
+ answer = json.loads(raw.decode("utf-8"))
213
+ except (ValueError, RecursionError): # not JSON, not UTF-8, or nested past the parser's depth
214
+ return None
215
+ if not isinstance(answer, dict):
216
+ return None
217
+ seconds = answer.get("gameTime")
218
+ if isinstance(seconds, bool) or not isinstance(seconds, (int, float)):
219
+ return None
220
+ try:
221
+ return float(seconds)
222
+ except OverflowError: # an int no float holds
223
+ return None
pendify/codes.py ADDED
@@ -0,0 +1,38 @@
1
+ """The pairing value: the Worker's alphabet and length, a mint, normalization and the 4-4-4 display.
2
+
3
+ The rules are the Worker's own (proxy/src/link-registry.ts:31-32, :45 and :97-101), so a value this
4
+ program normalizes is the value the Worker normalizes and digests.
5
+ """
6
+ import secrets
7
+
8
+ # 32 symbols with no 0, O, 1 or I (link-registry.ts:31); twelve of them are 2^60.
9
+ ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
10
+ LENGTH = 12
11
+ # A pasted value may carry the display dashes; anything longer cannot be one (link-registry.ts:45).
12
+ # Counted in UTF-16 units, as the Worker's raw.length counts it (link-registry.ts:98).
13
+ MAX_RAW_CHARS = 32
14
+
15
+
16
+ def mint():
17
+ """Twelve symbols of the alphabet from the system's cryptographic random source."""
18
+ return "".join(secrets.choice(ALPHABET) for _ in range(LENGTH))
19
+
20
+
21
+ def normalize(value):
22
+ """Upper-cased, every character outside the alphabet dropped; None unless exactly twelve remain.
23
+
24
+ The raw bound is in UTF-16 units, as the Worker's raw.length is: a character outside the Basic
25
+ Multilingual Plane counts two, so a value the Worker refuses is never stored here. A lone surrogate
26
+ counts one unit, as in JavaScript, and is no encoding error."""
27
+ if not isinstance(value, str) or len(value.encode("utf-16-le", "surrogatepass")) // 2 > MAX_RAW_CHARS:
28
+ return None
29
+ code = "".join(char for char in value.upper() if char in ALPHABET)
30
+ return code if len(code) == LENGTH else None
31
+
32
+
33
+ def display(code):
34
+ """The value as 4-4-4. A value that does not normalize is refused and never echoed."""
35
+ normal = normalize(code)
36
+ if normal is None:
37
+ raise ValueError("not a pairing value of twelve symbols")
38
+ return f"{normal[0:4]}-{normal[4:8]}-{normal[8:12]}"
pendify/config.py ADDED
@@ -0,0 +1,223 @@
1
+ """The config file: the secret and the link id, under the user's profile (design P1, P6), and the page's theme
2
+ choice when there is one (light or dark; the system's choice is no key), and its language choice when there is one
3
+ (es or en; with none the page follows the browser).
4
+
5
+ The file is `<base>/<folder>/config.json`, where the base is %APPDATA% in a real run and is injected
6
+ everywhere else, so no test touches the real profile. A write goes to a temp file in the same folder
7
+ and is moved over the old one with os.replace, so a reader sees the old file or the new one, never half.
8
+ """
9
+ import json
10
+ import os
11
+ import tempfile
12
+ import threading
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from . import codes
19
+
20
+ # The folder under the base is named after the package, the one constant that names it.
21
+ FOLDER_NAME = __name__.split(".")[0]
22
+ FILE_NAME = "config.json"
23
+ # The one sentence for a config file that is there but cannot be read or replaced, most often because
24
+ # another program holds it open. It names the path, never anything the file holds.
25
+ UNAVAILABLE = "the config file {path} cannot be used now: close any program that holds it open and start again"
26
+ # A refused os.replace is tried again after each pause: five retries over one second.
27
+ REPLACE_PAUSES = (0.2,) * 5
28
+ # The theme button's choices; "system" follows the system and is kept as no key.
29
+ THEMES = ("light", "dark")
30
+ SYSTEM_THEME = "system"
31
+ # The language switch's choices; with no key the page follows the browser's language.
32
+ LANGS = ("es", "en")
33
+ # _write's default: the stored theme and language choices are kept.
34
+ _KEEP = object()
35
+
36
+
37
+ class ConfigError(OSError):
38
+ """The config file is there but cannot be read or written; the message is UNAVAILABLE."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Pairing:
43
+ """What the file holds. Neither value is ever shown by repr."""
44
+
45
+ secret: str = field(repr=False)
46
+ link_id: Optional[str] = field(repr=False, default=None)
47
+
48
+
49
+ def default_base_dir(environ=None):
50
+ """%APPDATA%, the base of a real run. Its absence is an error, never a silent fallback."""
51
+ environ = os.environ if environ is None else environ
52
+ value = (environ.get("APPDATA") or "").strip()
53
+ if not value:
54
+ raise RuntimeError("APPDATA is not set, so the config folder has no place")
55
+ return Path(value)
56
+
57
+
58
+ class ConfigStore:
59
+ def __init__(self, base_dir, *, pause=time.sleep):
60
+ self.path = Path(base_dir) / FOLDER_NAME / FILE_NAME
61
+ self._lock = threading.Lock()
62
+ self._pause = pause
63
+
64
+ def read(self):
65
+ """The stored pair, or None when the file is missing, empty, corrupt or its secret does not
66
+ normalize. A malformed link id beside a good secret reads as no link id. Never writes. A file
67
+ that is there and cannot be read raises ConfigError, never a first load over the stored pair."""
68
+ try:
69
+ raw = self.path.read_bytes()
70
+ except FileNotFoundError:
71
+ return None
72
+ except OSError:
73
+ raise self._unavailable() from None
74
+ try:
75
+ data = json.loads(raw.decode("utf-8"))
76
+ except ValueError: # JSONDecodeError and UnicodeDecodeError alike
77
+ return None
78
+ if not isinstance(data, dict):
79
+ return None
80
+ secret = codes.normalize(data.get("secret"))
81
+ if secret is None:
82
+ return None
83
+ return Pairing(secret, codes.normalize(data.get("linkId")))
84
+
85
+ def read_theme(self):
86
+ """The stored theme choice, light or dark, or None when there is none (the system's). A file that is
87
+ there and cannot be read raises ConfigError, as read() does; a missing, corrupt or foreign value is None."""
88
+ return _choice(self._stored(), "theme", THEMES)
89
+
90
+ def read_lang(self):
91
+ """The stored language choice, es or en, or None when there is none (the browser's). A file that is there
92
+ and cannot be read raises ConfigError, as read_theme() does; a missing, corrupt or foreign value is None."""
93
+ return _choice(self._stored(), "lang", LANGS)
94
+
95
+ def set_theme(self, choice):
96
+ """The theme button's choice written beside the pair: light or dark kept, system kept as no key. Any
97
+ other value is refused and nothing is written. Answers the stored choice, None for the system's."""
98
+ if choice not in THEMES + (SYSTEM_THEME,):
99
+ raise ValueError("the theme is not light, dark or system")
100
+ theme = None if choice == SYSTEM_THEME else choice
101
+ with self._lock:
102
+ self._write(self._current(), theme=theme)
103
+ return theme
104
+
105
+ def set_lang(self, choice):
106
+ """The language switch's choice, es or en, written beside the pair and the theme. Any other value is
107
+ refused and nothing is written. Answers the stored choice."""
108
+ if choice not in LANGS:
109
+ raise ValueError("the language is not es or en")
110
+ with self._lock:
111
+ self._write(self._current(), lang=choice)
112
+ return choice
113
+
114
+ def load(self):
115
+ """The stored pair; when there is none, a first load: a new secret, written (P1)."""
116
+ with self._lock:
117
+ current = self.read()
118
+ if current is None:
119
+ current = self._write(Pairing(codes.mint()))
120
+ return current
121
+
122
+ def forget(self):
123
+ """«Olvidar este PC»: a new secret and no link id."""
124
+ with self._lock:
125
+ old = self.read()
126
+ secret = codes.mint()
127
+ while old is not None and secret == old.secret:
128
+ secret = codes.mint()
129
+ return self._write(Pairing(secret))
130
+
131
+ def set_typed(self, link_id, secret):
132
+ """The typed road (P6): both values normalized, a malformed pair refused without echoing it."""
133
+ normal_link_id, normal_secret = codes.normalize(link_id), codes.normalize(secret)
134
+ if normal_link_id is None or normal_secret is None:
135
+ raise ValueError("the typed pair is not two values of twelve symbols")
136
+ with self._lock:
137
+ return self._write(Pairing(normal_secret, normal_link_id))
138
+
139
+ def set_link_id(self, link_id):
140
+ """The link id a check answered, stored beside the current secret."""
141
+ normal = codes.normalize(link_id)
142
+ if normal is None:
143
+ raise ValueError("the link id is not twelve symbols")
144
+ with self._lock:
145
+ return self._write(Pairing(self._current().secret, normal))
146
+
147
+ def clear_link_id(self):
148
+ """«Volver a enlazar»: no link id, the SAME secret."""
149
+ with self._lock:
150
+ return self._write(Pairing(self._current().secret))
151
+
152
+ def _current(self):
153
+ """The stored pair whose secret a write keeps; a file gone mid-run is the one sentence, as a held one is."""
154
+ current = self.read()
155
+ if current is None:
156
+ raise self._unavailable()
157
+ return current
158
+
159
+ def _unavailable(self):
160
+ return ConfigError(UNAVAILABLE.format(path=self.path))
161
+
162
+ def _stored(self):
163
+ """The file's object, or None when the file is missing, corrupt or not an object. A file that is there and
164
+ cannot be read raises ConfigError."""
165
+ try:
166
+ raw = self.path.read_bytes()
167
+ except FileNotFoundError:
168
+ return None
169
+ except OSError:
170
+ raise self._unavailable() from None
171
+ try:
172
+ data = json.loads(raw.decode("utf-8"))
173
+ except ValueError: # JSONDecodeError and UnicodeDecodeError alike
174
+ return None
175
+ return data if isinstance(data, dict) else None
176
+
177
+ def _write(self, pairing, theme=_KEEP, lang=_KEEP):
178
+ """The pair, and the theme and language choices: each stored one kept unless set_theme or set_lang passes
179
+ its own."""
180
+ folder = self.path.parent
181
+ if theme is _KEEP or lang is _KEEP:
182
+ stored = self._stored()
183
+ theme = _choice(stored, "theme", THEMES) if theme is _KEEP else theme
184
+ lang = _choice(stored, "lang", LANGS) if lang is _KEEP else lang
185
+ values = {"secret": pairing.secret, "linkId": pairing.link_id}
186
+ if theme is not None:
187
+ values["theme"] = theme
188
+ if lang is not None:
189
+ values["lang"] = lang
190
+ data = json.dumps(values).encode("utf-8")
191
+ try:
192
+ folder.mkdir(parents=True, exist_ok=True)
193
+ handle, temp = tempfile.mkstemp(dir=folder, prefix=".config-", suffix=".tmp")
194
+ except OSError:
195
+ raise self._unavailable() from None
196
+ try:
197
+ with os.fdopen(handle, "wb") as out:
198
+ out.write(data)
199
+ out.flush()
200
+ os.fsync(out.fileno())
201
+ self._replace(temp)
202
+ except BaseException as failure:
203
+ if os.path.exists(temp):
204
+ os.remove(temp)
205
+ if isinstance(failure, OSError):
206
+ raise self._unavailable() from None
207
+ raise
208
+ return pairing
209
+
210
+ def _replace(self, temp):
211
+ """os.replace, retried while Windows refuses it because another program holds the file open."""
212
+ for pause in REPLACE_PAUSES:
213
+ try:
214
+ return os.replace(temp, self.path)
215
+ except PermissionError:
216
+ self._pause(pause)
217
+ return os.replace(temp, self.path)
218
+
219
+
220
+ def _choice(stored, key, allowed):
221
+ """The value of `key` in the file's object when it is one of `allowed`, else None (missing, corrupt or foreign)."""
222
+ value = stored.get(key) if stored is not None else None
223
+ return value if isinstance(value, str) and value in allowed else None
pendify/icon.py ADDED
@@ -0,0 +1,14 @@
1
+ """The program's icon for the page: the 32 px frame of pendify.ico as PNG in base64.
2
+
3
+ Written by tools/make_icon.py from tools/icon_grid.py: run the tool, never edit this file by hand.
4
+ """
5
+
6
+ PNG_32 = (
7
+ "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABlElEQVR4AWMYBcSEQFfMh/8wTEg9TB2IJqSWKHmQQf+B4OW9"
8
+ "30DyP9ghuDSSohaXGRjipBhKiloMi3AJYDMUJIYLg4KJmNDCZR9WcZBlIINJwSA9WA0jVRBkECkWI6sF6SXVPhT1IAOQDSSH"
9
+ "DTIDxVBSOCDN5FgK0wNKCyAzSLETrhakEWYQJTTZjsDmADlR4/8gjMtBIDkQRpYfUAeALAdhkGfgQUssA6QJ2SfksEGWgzDI"
10
+ "LGLthasDacrVuPmfGhhkFtxgYhkgTeRa7qBe8x8Zg8wi1l64OpAmUoId5liQxaBgR8Ygs+AGk8IAaSTWEbgcADKDFDtR1II0"
11
+ "k+IAkK9BDgHRMAwyA8VQUjkgA7A5AmYBPhqklyT7QBrogbE6CmTx4Y3f4Z4FsZHx8vmH/pOCkfWis0F2oTgCJABSBLIdRNMD"
12
+ "g+yEOwLEoYelyHaA7Bx1wOAJAZBLQHGCHEeUsAnlFpBdIDsxMEgChJEtB/GpjTEsRhYAWQZzAIiNLEcXNshSkANANF0sRLcE"
13
+ "ZDEIo4vTig8AgGltBNeEC2EAAAAASUVORK5CYII="
14
+ )