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/__init__.py +1 -0
- pendify/__main__.py +265 -0
- pendify/alert.py +132 -0
- pendify/autostart.py +105 -0
- pendify/client.py +223 -0
- pendify/codes.py +38 -0
- pendify/config.py +223 -0
- pendify/icon.py +14 -0
- pendify/page.py +1136 -0
- pendify/pairing.py +192 -0
- pendify/pendify.ico +0 -0
- pendify/plate_almena.py +348 -0
- pendify/qr.py +433 -0
- pendify/runfile.py +246 -0
- pendify/watcher.py +348 -0
- pendify/worker.py +159 -0
- pendify-0.1.0.dist-info/METADATA +155 -0
- pendify-0.1.0.dist-info/RECORD +21 -0
- pendify-0.1.0.dist-info/WHEEL +5 -0
- pendify-0.1.0.dist-info/licenses/LICENSE +21 -0
- pendify-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|